diff --git a/app/tests/brush-selection.spec.ts b/app/tests/brush-selection.spec.ts index a5e4f76c..794b41b3 100644 --- a/app/tests/brush-selection.spec.ts +++ b/app/tests/brush-selection.spec.ts @@ -38,8 +38,9 @@ async function brushSelect( if (!plot) return { brushCreated: false, selectionMode: false }; const selectionMode = !!plot.selectionMode; - const brushGroup = plot._brushGroup; - const brush = plot._brush; + // B8/F-07: brush + brushGroup moved into PlotInteractionController. + const brushGroup = plot._interaction?._brushGroup; + const brush = plot._interaction?._brush; const brushCreated = !!brush; if (!brush || !brushGroup) { @@ -87,11 +88,13 @@ async function setZoomTransform(page: Page, k: number, tx: number, ty: number): await page.evaluate( ({ k, tx, ty }) => { const plot = document.querySelector('#myPlot') as any; - if (!plot?._svgSelection || !plot._zoom) return; + // B8/F-07: zoom behavior + svg selection moved into PlotInteractionController. + const ix = plot?._interaction; + if (!ix?._svgSelection || !ix._zoom) return; const ZoomTransform = plot._transform.constructor; const transform = new ZoomTransform(k, tx, ty); - plot._svgSelection.call(plot._zoom.transform, transform); + ix._svgSelection.call(ix._zoom.transform, transform); }, { k, tx, ty }, ); @@ -116,14 +119,14 @@ async function enableSelectionMode(page: Page): Promise { await page.waitForFunction( () => { const plot = document.querySelector('#myPlot') as any; - return !!plot?.selectionMode && !!plot?._brush; + return !!plot?.selectionMode && !!plot?._interaction?._brush; }, { timeout: 5_000 }, ); return page.evaluate(() => { const plot = document.querySelector('#myPlot') as any; - return !!plot?.selectionMode && !!plot?._brush; + return !!plot?.selectionMode && !!plot?._interaction?._brush; }); } @@ -252,11 +255,12 @@ test.describe('Brush selection works at all zoom levels (#189)', () => { const extentInfo = await page.evaluate(() => { const plot = document.querySelector('#myPlot') as any; - if (!plot?._brush) return null; + // B8/F-07: brush moved into PlotInteractionController. + if (!plot?._interaction?._brush) return null; const config = plot._mergedConfig; const t = plot._transform; - const extent = plot._brush.extent()(); + const extent = plot._interaction._brush.extent()(); return { extentX0: extent[0][0], diff --git a/packages/core/src/components/legend/controllers/scatterplot-sync-controller.test.ts b/packages/core/src/components/legend/controllers/scatterplot-sync-controller.test.ts index dcacb68e..ce4dc6bd 100644 --- a/packages/core/src/components/legend/controllers/scatterplot-sync-controller.test.ts +++ b/packages/core/src/components/legend/controllers/scatterplot-sync-controller.test.ts @@ -279,22 +279,22 @@ describe('ScatterplotSyncController', () => { }); it('updates scatterplot config with new values', () => { - mockScatterplot.config = { existing: 'value' }; + mockScatterplot.config = { width: 800 }; controller.updateConfig({ pointSize: 100 }); expect(mockScatterplot.config).toEqual({ - existing: 'value', + width: 800, pointSize: 100, }); }); it('preserves existing config values', () => { - mockScatterplot.config = { a: 1, b: 2 }; + mockScatterplot.config = { width: 1, height: 2 }; - controller.updateConfig({ b: 3, c: 4 }); + controller.updateConfig({ height: 3, pointSize: 4 }); - expect(mockScatterplot.config).toEqual({ a: 1, b: 3, c: 4 }); + expect(mockScatterplot.config).toEqual({ width: 1, height: 3, pointSize: 4 }); }); it('preserves config identity for no-op updates', () => { @@ -305,6 +305,11 @@ describe('ScatterplotSyncController', () => { expect(mockScatterplot.config).toBe(previousConfig); }); + + it('updateConfig parameter is shape-checked against ScatterplotConfig keys (F-47)', () => { + // @ts-expect-error — pointSize must be a number per ScatterplotConfig, not a string. + controller.updateConfig({ pointSize: 'not-a-number' }); + }); }); describe('syncNumericAnnotationSettings', () => { diff --git a/packages/core/src/components/legend/controllers/scatterplot-sync-controller.ts b/packages/core/src/components/legend/controllers/scatterplot-sync-controller.ts index 73355ef3..67e7d206 100644 --- a/packages/core/src/components/legend/controllers/scatterplot-sync-controller.ts +++ b/packages/core/src/components/legend/controllers/scatterplot-sync-controller.ts @@ -1,6 +1,6 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { ScatterplotData, OtherItem } from '../types'; -import type { NumericAnnotationDisplaySettingsMap } from '@protspace/utils'; +import type { NumericAnnotationDisplaySettingsMap, ScatterplotConfig } from '@protspace/utils'; import type { LegendSortMode } from '../types'; import { isScatterplotElement, @@ -14,6 +14,10 @@ import { import { LEGEND_EVENTS } from '../config'; import { expandHiddenValues, buildZOrderMapping, buildColorShapeMappings } from '../legend-helpers'; import type { LegendItem } from '../types'; +import type { + LegendColorMappingChangeEvent, + LegendZOrderChangeEvent, +} from '../legend-mapping-events'; /** Maximum number of retries when looking for scatterplot element */ const MAX_DISCOVERY_RETRIES = 10; @@ -153,11 +157,13 @@ export class ScatterplotSyncController implements ReactiveController { /** * Update scatterplot config */ - updateConfig(updates: Record): void { + updateConfig(updates: Partial): void { if (!this._scatterplotElement || !supportsConfig(this._scatterplotElement)) return; const currentConfig = this._scatterplotElement.config || {}; - const hasChanges = Object.entries(updates).some(([key, value]) => currentConfig[key] !== value); + const hasChanges = Object.entries(updates).some( + ([key, value]) => (currentConfig as Record)[key] !== value, + ); if (!hasChanges) return; this._scatterplotElement.config = { ...currentConfig, ...updates }; @@ -169,7 +175,7 @@ export class ScatterplotSyncController implements ReactiveController { dispatchZOrderChange(): void { const zOrderMapping = buildZOrderMapping(this.callbacks.getLegendItems()); - const event = new CustomEvent(LEGEND_EVENTS.ZORDER_CHANGE, { + const event: LegendZOrderChangeEvent = new CustomEvent(LEGEND_EVENTS.ZORDER_CHANGE, { detail: { zOrderMapping }, bubbles: !this._scatterplotElement, }); @@ -188,10 +194,13 @@ export class ScatterplotSyncController implements ReactiveController { dispatchColorMappingChange(colorOnly: boolean = false): void { const { colorMapping, shapeMapping } = buildColorShapeMappings(this.callbacks.getLegendItems()); - const event = new CustomEvent(LEGEND_EVENTS.COLORMAPPING_CHANGE, { - detail: { colorMapping, shapeMapping, colorOnly }, - bubbles: !this._scatterplotElement, - }); + const event: LegendColorMappingChangeEvent = new CustomEvent( + LEGEND_EVENTS.COLORMAPPING_CHANGE, + { + detail: { colorMapping, shapeMapping, colorOnly }, + bubbles: !this._scatterplotElement, + }, + ); if (this._scatterplotElement) { this._scatterplotElement.dispatchEvent(event); diff --git a/packages/core/src/components/legend/legend-mapping-events.test.ts b/packages/core/src/components/legend/legend-mapping-events.test.ts new file mode 100644 index 00000000..7951d9f6 --- /dev/null +++ b/packages/core/src/components/legend/legend-mapping-events.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { + isLegendColorMappingDetail, + isLegendZOrderDetail, + type LegendColorMappingDetail, + type LegendZOrderDetail, +} from './legend-mapping-events'; + +describe('legend-mapping-events guards (INV-07)', () => { + it('accepts a full color-mapping detail', () => { + const d: LegendColorMappingDetail = { + colorMapping: { A: '#ff0000' }, + shapeMapping: { A: 'circle' }, + colorOnly: false, + }; + expect(isLegendColorMappingDetail(d)).toBe(true); + }); + + it('rejects a color-mapping detail missing shapeMapping', () => { + expect(isLegendColorMappingDetail({ colorMapping: { A: '#fff' } })).toBe(false); + }); + + it('rejects null / non-object', () => { + expect(isLegendColorMappingDetail(null)).toBe(false); + expect(isLegendColorMappingDetail(undefined)).toBe(false); + expect(isLegendZOrderDetail('nope')).toBe(false); + }); + + it('accepts a full z-order detail', () => { + const d: LegendZOrderDetail = { zOrderMapping: { A: 0, B: 1 } }; + expect(isLegendZOrderDetail(d)).toBe(true); + }); + + it('rejects a z-order detail with no zOrderMapping', () => { + expect(isLegendZOrderDetail({})).toBe(false); + }); +}); diff --git a/packages/core/src/components/legend/legend-mapping-events.ts b/packages/core/src/components/legend/legend-mapping-events.ts new file mode 100644 index 00000000..1b99cda5 --- /dev/null +++ b/packages/core/src/components/legend/legend-mapping-events.ts @@ -0,0 +1,42 @@ +/** + * Canonical typed details for the legend → scatter-plot mapping transport (INV-06/07). + * color/shape + z-order travel as events; the scatter-plot consumes them here, the + * legend's scatterplot-sync-controller produces them. One shape, two ends. + */ + +/** INV-07: legend-colormapping-change.detail */ +export interface LegendColorMappingDetail { + colorMapping: Record; + shapeMapping: Record; + /** INV-08 contract: true skips depth re-sort + virtualization invalidation. */ + colorOnly: boolean; +} + +/** INV-07: legend-zorder-change.detail (keyed by annotation value). */ +export interface LegendZOrderDetail { + zOrderMapping: Record; +} + +export type LegendColorMappingChangeEvent = CustomEvent; +export type LegendZOrderChangeEvent = CustomEvent; + +/** Runtime guard: a well-formed color-mapping detail carries both maps. */ +export function isLegendColorMappingDetail(d: unknown): d is LegendColorMappingDetail { + if (typeof d !== 'object' || d === null) return false; + const detail = d as Record; + return ( + typeof detail.colorMapping === 'object' && + detail.colorMapping !== null && + typeof detail.shapeMapping === 'object' && + detail.shapeMapping !== null + ); +} + +/** Runtime guard: a well-formed z-order detail carries the zOrderMapping. */ +export function isLegendZOrderDetail(d: unknown): d is LegendZOrderDetail { + if (typeof d !== 'object' || d === null) return false; + return ( + typeof (d as Record).zOrderMapping === 'object' && + (d as Record).zOrderMapping !== null + ); +} diff --git a/packages/core/src/components/legend/scatterplot-interface.ts b/packages/core/src/components/legend/scatterplot-interface.ts index 1f3d71af..915e1232 100644 --- a/packages/core/src/components/legend/scatterplot-interface.ts +++ b/packages/core/src/components/legend/scatterplot-interface.ts @@ -1,5 +1,5 @@ import type { ScatterplotData } from './types'; -import type { NumericAnnotationDisplaySettingsMap } from '@protspace/utils'; +import type { NumericAnnotationDisplaySettingsMap, ScatterplotConfig } from '@protspace/utils'; import type { LegendSortMode } from './types'; /** @@ -21,7 +21,7 @@ export interface IScatterplotElement extends Element { numericManualOrderIdsByAnnotation?: Record; // Configuration - config: Record; + config: Partial; // Isolation mode (optional - may not exist on all implementations) isIsolationMode?(): boolean; diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.test.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.test.ts new file mode 100644 index 00000000..1d965af6 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.test.ts @@ -0,0 +1,138 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect } from 'vitest'; +import { + DuplicateBadgesCanvasRenderer, + cullAndCapStacks, + DUPLICATE_BADGES_MAX_VISIBLE, + BADGE_RADIUS, + BADGE_OFFSET, + BADGE_EXPANDED_FILL, + BADGE_DEFAULT_FILL, +} from './duplicate-badges-canvas-renderer'; +import type { RenderDuplicateStack } from './duplicate-stack-types'; + +function fakeCanvas() { + const calls: Array<[string, unknown[]]> = []; + const ctx = new Proxy({} as Record, { + get: (_t, p) => + typeof p === 'string' && + ['setTransform', 'clearRect', 'beginPath', 'arc', 'fill', 'stroke', 'fillText'].includes(p) + ? (...a: unknown[]) => calls.push([p, a]) + : undefined, + set: () => true, + }); + const canvas = { + width: 1600, + height: 1200, + getContext: () => ctx, + } as unknown as HTMLCanvasElement; + return { canvas, calls }; +} + +const stk = (key: string, px: number, py: number, n: number): RenderDuplicateStack => ({ + key, + px, + py, + points: Array.from({ length: n }, (_, i) => ({ + id: `${key}-${i}`, + x: 0, + y: 0, + originalIndex: i, + })), +}); + +describe('DuplicateBadgesCanvasRenderer', () => { + it('exposes the named geometry/style constants (no magic numbers)', () => { + expect(BADGE_RADIUS).toBe(9); + expect(BADGE_OFFSET).toEqual({ x: 10, y: -10 }); + expect(BADGE_EXPANDED_FILL).toBe('rgba(59, 130, 246, 0.9)'); + expect(BADGE_DEFAULT_FILL).toBe('rgba(17, 24, 39, 0.85)'); + }); + + it('clear() resets the device-pixel transform and clears the full canvas', () => { + const { canvas, calls } = fakeCanvas(); + const r = new DuplicateBadgesCanvasRenderer({ + getCanvas: () => canvas, + getTransform: () => ({ x: 0, y: 0, k: 1 }), + getSize: () => ({ width: 800, height: 600 }), + getExpandedKey: () => null, + }); + r.clear(); + expect(calls[0]).toEqual(['setTransform', [1, 0, 0, 1, 0, 0]]); + expect(calls[1]).toEqual(['clearRect', [0, 0, 1600, 1200]]); + }); + + it('render() draws one arc + one count label per stack and tints the expanded one', () => { + const { canvas, calls } = fakeCanvas(); + const r = new DuplicateBadgesCanvasRenderer({ + getCanvas: () => canvas, + getTransform: () => ({ x: 0, y: 0, k: 1 }), + getSize: () => ({ width: 800, height: 600 }), + getExpandedKey: () => 'b', + }); + r.render([stk('a', 10, 10, 3), stk('b', 20, 20, 5)]); + expect(calls.filter(([m]) => m === 'arc')).toHaveLength(2); + expect(calls.filter(([m]) => m === 'fillText').map(([, a]) => a[0])).toEqual(['3', '5']); + }); +}); + +const stack = (key: string, px: number, py: number, n: number): RenderDuplicateStack => ({ + key, + px, + py, + points: Array.from({ length: n }, (_, i) => ({ + id: `${key}-${i}`, + x: px, + y: py, + originalIndex: i, + })), +}); +const win = { minX: 0, maxX: 100, minY: 0, maxY: 100 }; + +describe('cullAndCapStacks', () => { + it('drops stacks whose px/py fall outside the window', () => { + const out = cullAndCapStacks( + [stack('in', 50, 50, 2), stack('out', 200, 50, 2)], + win, + null, + new Map(), + ); + expect(out.map((s) => s.key)).toEqual(['in']); + }); + + it('keeps all visible stacks when under the cap', () => { + const stacks = Array.from({ length: 5 }, (_, i) => stack(`s${i}`, 10 + i, 10, 2)); + expect(cullAndCapStacks(stacks, win, null, new Map())).toHaveLength(5); + }); + + it('caps to the top-N by points.length when over DUPLICATE_BADGES_MAX_VISIBLE', () => { + const stacks = Array.from({ length: DUPLICATE_BADGES_MAX_VISIBLE + 10 }, (_, i) => + stack(`s${i}`, 10, 10, i + 2), + ); + const out = cullAndCapStacks(stacks, win, null, new Map()); + expect(out).toHaveLength(DUPLICATE_BADGES_MAX_VISIBLE); + // largest groups survive + expect(Math.min(...out.map((s) => s.points.length))).toBeGreaterThan(2); + }); + + it('force-keeps the expanded stack even if it is not in the top-N (and is in-window)', () => { + const big = Array.from({ length: DUPLICATE_BADGES_MAX_VISIBLE }, (_, i) => + stack(`big${i}`, 10, 10, i + 100), + ); + const small = stack('expanded', 50, 50, 2); // small ⇒ would be culled by cap + const byKey = new Map([[small.key, small]]); + const out = cullAndCapStacks([...big, small], win, 'expanded', byKey); + expect(out.some((s) => s.key === 'expanded')).toBe(true); + expect(out).toHaveLength(DUPLICATE_BADGES_MAX_VISIBLE + 1); + }); + + it('does NOT re-add the expanded stack when it is out of window', () => { + const big = Array.from({ length: DUPLICATE_BADGES_MAX_VISIBLE }, (_, i) => + stack(`big${i}`, 10, 10, i + 100), + ); + const offscreen = stack('expanded', 999, 999, 2); + const byKey = new Map([[offscreen.key, offscreen]]); + const out = cullAndCapStacks([...big, offscreen], win, 'expanded', byKey); + expect(out.some((s) => s.key === 'expanded')).toBe(false); + }); +}); diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.ts new file mode 100644 index 00000000..24cbaef0 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-badges-canvas-renderer.ts @@ -0,0 +1,116 @@ +/** + * Canvas2D badge engine for the duplicate-stack overlay, extracted byte-faithfully + * from `scatter-plot.ts` (`_clearDuplicateBadgesCanvas` + `_renderDuplicateBadgesCanvas`, + * report F-30) plus the viewport cull + top-N cap (`cullAndCapStacks`, report F-52, + * replacing the inline filter + `_capDuplicateStacksForRendering`). + * + * Pure/decoupled: this module does NOT import `scatter-plot.ts`. The host wires its + * canvas, transform, config size, and expanded-key state through the deps callbacks. + * All geometry/style literals are preserved verbatim from the original inline code. + */ + +import type { RenderDuplicateStack } from './duplicate-stack-types'; +import { pointInWindow, type ViewportWindow } from './duplicate-stack-viewport'; + +/** Badge geometry/style constants — verbatim from the original inline literals. */ +export const BADGE_RADIUS = 9; +export const BADGE_OFFSET = { x: 10, y: -10 } as const; +const BADGE_FONT = + '700 10px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif'; +const BADGE_STROKE = 'rgba(255, 255, 255, 0.9)'; +const BADGE_LINE_WIDTH = 1.5; +export const BADGE_EXPANDED_FILL = 'rgba(59, 130, 246, 0.9)'; +export const BADGE_DEFAULT_FILL = 'rgba(17, 24, 39, 0.85)'; +const BADGE_TEXT_FILL = '#ffffff'; + +/** Cap: max duplicate badges drawn per frame (verbatim from `scatter-plot.ts:52`). */ +export const DUPLICATE_BADGES_MAX_VISIBLE = 800; + +interface BadgesRendererDeps { + getCanvas: () => HTMLCanvasElement | undefined; + getTransform: () => { x: number; y: number; k: number }; + getSize: () => { width: number; height: number }; + getExpandedKey: () => string | null; +} + +/** + * Cull `_duplicateStacks` to the viewport window, then cap to the top-N largest + * groups — but always keep the currently-expanded stack if it is still on screen + * (so its spider stays visible when a denser region pushes it out of the top-N). + * Pure: caller passes the expanded key + byKey map. Preserves the exact predicate + * (inclusive bounds) and cap behavior previously inline at the two badge-draw sites. + */ +export function cullAndCapStacks( + stacks: RenderDuplicateStack[], + win: ViewportWindow, + expandedKey: string | null, + byKey: Map, +): RenderDuplicateStack[] { + const visible = stacks.filter((s) => pointInWindow(s, win)); + if (visible.length <= DUPLICATE_BADGES_MAX_VISIBLE) return visible; + + let capped: RenderDuplicateStack[] = [...visible] + .sort((a, b) => b.points.length - a.points.length) + .slice(0, DUPLICATE_BADGES_MAX_VISIBLE); + + if (expandedKey && !capped.some((s) => s.key === expandedKey)) { + const expanded = byKey.get(expandedKey); + if (expanded && pointInWindow(expanded, win)) { + capped = [...capped, expanded]; + } + } + return capped; +} + +export class DuplicateBadgesCanvasRenderer { + constructor(private readonly deps: BadgesRendererDeps) {} + + /** Clear in device pixels (canvas is sized to DPR). */ + clear(): void { + const canvas = this.deps.getCanvas(); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + render(stacks: RenderDuplicateStack[]): void { + const canvas = this.deps.getCanvas(); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const { width, height } = this.deps.getSize(); + + // Work in CSS pixels for drawing; scale to device pixels once. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const t = this.deps.getTransform(); + const expandedKey = this.deps.getExpandedKey(); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = BADGE_FONT; + ctx.lineWidth = BADGE_LINE_WIDTH; + ctx.strokeStyle = BADGE_STROKE; + + for (let i = 0; i < stacks.length; i++) { + const s = stacks[i]; + const x = t.x + t.k * s.px + BADGE_OFFSET.x; + const y = t.y + t.k * s.py + BADGE_OFFSET.y; + const isExpanded = s.key === expandedKey; + + ctx.fillStyle = isExpanded ? BADGE_EXPANDED_FILL : BADGE_DEFAULT_FILL; + ctx.beginPath(); + ctx.arc(x, y, BADGE_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = BADGE_TEXT_FILL; + ctx.fillText(String(s.points.length), x, y); + } + } +} diff --git a/packages/core/src/components/scatter-plot/duplicate-stack-helpers.test.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.test.ts similarity index 93% rename from packages/core/src/components/scatter-plot/duplicate-stack-helpers.test.ts rename to packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.test.ts index 6786ecc4..c90e27e1 100644 --- a/packages/core/src/components/scatter-plot/duplicate-stack-helpers.test.ts +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.test.ts @@ -46,6 +46,14 @@ describe('buildDuplicateStacks', () => { expect(result.byKey.get(getDuplicateStackKey({ x: 1, y: 1 }))?.points).toHaveLength(2); }); + it('exposes the data-space x/y of the stack so callers can re-project to pixels', () => { + // Contract for the production viewport path: it re-projects stack.x/stack.y + // through scales.x/scales.y to get px/py, so the helper must surface them. + const result = buildDuplicateStacks([point('a', 1.5, 2.5), point('b', 1.5, 2.5)]); + expect(result.stacks[0].x).toBe(1.5); + expect(result.stacks[0].y).toBe(2.5); + }); + it('handles multiple independent groups', () => { const result = buildDuplicateStacks([ point('a', 0, 0), diff --git a/packages/core/src/components/scatter-plot/duplicate-stack-helpers.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.ts similarity index 90% rename from packages/core/src/components/scatter-plot/duplicate-stack-helpers.ts rename to packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.ts index bcdba068..12b1119e 100644 --- a/packages/core/src/components/scatter-plot/duplicate-stack-helpers.ts +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-helpers.ts @@ -5,9 +5,9 @@ * by embedding identity (across projections) would lie about where the other * members render. * - * The chunked, viewport-aware implementation lives in `scatter-plot.ts`; - * these helpers exist so the algorithm itself can be unit-tested - * (legend-hide, projection-switch — see #121). + * The chunked viewport pass in `scatter-plot.ts` feeds its materialized + * points through this same function, so production grouping and these tests + * share one implementation (legend-hide, projection-switch — see #121). */ export interface DuplicateStackPoint { diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts new file mode 100644 index 00000000..a164f7c5 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts @@ -0,0 +1,414 @@ +/** + * Owns the entire duplicate-stack / spiderfy / badge overlay subsystem, lifted + * verbatim out of `scatter-plot.ts` (report F-06). It holds all duplicate-stack + * state (the per-viewport stack list + lookup maps, the expanded key + spider + * anchor, the debounce/compute job tokens), schedules the debounced overlay + * update, runs the chunked viewport compute, and coordinates the badge canvas + * renderer (F-30) + spiderfy SVG layer (F-32) via the shared helpers (F-36/F-51/ + * F-52). + * + * Pure/decoupled: this module does NOT import `scatter-plot.ts`. The host wires + * its overlay group, badges canvas, transform, config, scales, plot data, + * quadtree, enablement/selection flags, color getter, and the click/hover hooks + * through the {@link DuplicateStackOverlayDeps} accessor bundle. Event dispatch + * stays on the host via `onPointActivate`/`onHover`/`onHoverEnd` (INV-05/INV-03). + * + * All geometry/style/timing constants and control flow are preserved verbatim + * from the original inline subsystem; the only edits are `this._x` → + * `this.deps.getX()` accessors and `this._field` → `this.field` state. + */ + +import type { Selection } from 'd3'; +import type { ZoomTransform } from 'd3'; +import type { PlotData, PlotDataPoint, ScatterplotConfig } from '@protspace/utils'; +import { materializePlotDataPoint } from '@protspace/utils'; +import { buildDuplicateStacks } from './duplicate-stack-helpers'; +import { + computeViewportWindow, + pointInWindow, + buildViewKey, + type ViewportWindow, +} from './duplicate-stack-viewport'; +import { + cullAndCapStacks, + DuplicateBadgesCanvasRenderer, +} from './duplicate-badges-canvas-renderer'; +import { SpiderfyLayer } from './spiderfy-layer'; +import type { ViewportDuplicateStack } from './duplicate-stack-types'; +import type { QuadtreeIndex } from '../interaction/quadtree-index'; + +// Duplicate stack UI performance tuning (target: M1 MacBook + Chrome) +const DUPLICATE_BADGES_VIEWPORT_PADDING = 60; +const DUPLICATE_BADGES_UPDATE_DEBOUNCE_MS = 120; +const DUPLICATE_STACK_COMPUTE_CHUNK_SIZE = 25_000; + +interface DuplicateStackOverlayDeps { + getOverlayGroup: () => Selection | null; + getBadgesCanvas: () => HTMLCanvasElement | undefined; + getTransform: () => ZoomTransform; + getConfig: () => Required; // _mergedConfig (width/height/margin) + getScales: () => { x: (n: number) => number; y: (n: number) => number } | null; + getPlotData: () => PlotData; + getQuadtree: () => QuadtreeIndex; + isEnabled: () => boolean; // _mergedConfig.enableDuplicateStackUI + isSelectionMode: () => boolean; + getColor: (p: PlotDataPoint) => string; // _getColors(p)[0] ?? '#888888' + onPointActivate: (event: MouseEvent, p: PlotDataPoint) => void; // host _handleClick + onHover: (event: MouseEvent, p: PlotDataPoint) => void; // host _handleMouseOver + onHoverEnd: () => void; // host _clearHoverState +} + +export class DuplicateStackOverlayController { + private stacks: ViewportDuplicateStack[] = []; + private byKey = new Map(); + private pointIdToKey = new Map(); + private expandedKey: string | null = null; + // Anchor position the user clicked to open the current spider. Stored separately + // from the per-viewport stack object so it survives the rebuild that happens on + // every pan/zoom (see applyExpandedAnchor). + private expandedAnchor: { stackKey: string; x: number; y: number } | null = null; + private debounceId: number | null = null; + private cacheKey: string | null = null; + private computeJobId = 0; + private computing = false; + private readonly badges: DuplicateBadgesCanvasRenderer; + private readonly spiderfy: SpiderfyLayer; + + constructor(private readonly deps: DuplicateStackOverlayDeps) { + this.badges = new DuplicateBadgesCanvasRenderer({ + getCanvas: () => this.deps.getBadgesCanvas(), + getTransform: () => this.deps.getTransform(), + getSize: () => ({ + width: this.deps.getConfig().width, + height: this.deps.getConfig().height, + }), + getExpandedKey: () => this.expandedKey, + }); + // Spiderfy interaction can lose native 'click' due to d3.zoom gesture handling in some browsers. + // The layer owns the press/release map and reconstructs taps; dispatch stays on the host (INV-05). + this.spiderfy = new SpiderfyLayer({ + getColor: (p) => this.deps.getColor(p), + onActivate: (e, p) => this.deps.onPointActivate(e, p), + onHover: (e, p) => this.deps.onHover(e, p), + onHoverEnd: () => this.deps.onHoverEnd(), + }); + } + + // ----- Public surface (1:1 with the old private methods on the host) ----- + + updateSelectionOverlays(options: { duplicateImmediate?: boolean } = {}): void { + if (!this.deps.getOverlayGroup()) return; + this.scheduleUpdate(options.duplicateImmediate ?? true); + } + + cancelDebounce(): void { + if (this.debounceId !== null) { + window.clearTimeout(this.debounceId); + this.debounceId = null; + } + } + + cancelCompute(): void { + // Bump job id so any in-flight chunked compute aborts early. + this.computeJobId++; + this.computing = false; + } + + clearBadges(): void { + this.badges.clear(); + } + + /** + * Invalidate only the viewport cache key (next overlay update recomputes) — + * verbatim from the config-toggle path, which reset the cache without + * clearing the live stacks/maps. + */ + resetCacheKey(): void { + this.cacheKey = null; + } + + resetState(): void { + this.stacks = []; + this.byKey.clear(); + this.pointIdToKey.clear(); + this.expandedKey = null; + this.expandedAnchor = null; + this.cacheKey = null; + this.spiderfy.reset(); + } + + /** True when a duplicate-badge spider is currently expanded. */ + hasExpanded(): boolean { + return this.expandedKey !== null; + } + + /** Collapse the currently-open duplicate-badge spider, if any. */ + closeExpanded(): void { + this.collapseExpanded(); + } + + /** + * Click hit-test hook: returns true (handled) if `point` belongs to a real + * (>1 member) duplicate stack, toggling its spider. Verbatim from the host + * click branch. + */ + maybeSpiderfyPoint(point: PlotDataPoint): boolean { + if (!this.deps.isEnabled()) return false; + // If this point belongs to a duplicate stack, spiderfy instead of picking an arbitrary member. + const stackKey = this.pointIdToKey.get(point.id); + const stack = stackKey ? this.byKey.get(stackKey) : undefined; + if (stack && stack.points.length > 1) { + this.toggleSpiderfy(stack.key, point); + return true; + } + return false; + } + + /** + * Click hit-test hook: clicking anywhere outside the expanded stack collapses + * it. Returns whether a stack was expanded (host treats a collapse-click as a + * dismiss). Verbatim from the host hit-test. + */ + collapseExpanded(): boolean { + const hadExpanded = !!this.expandedKey; + if (this.expandedKey) { + this.expandedKey = null; + this.expandedAnchor = null; + this.updateOverlays(); + } + return hadExpanded; + } + + // ----- Relocated private bodies (verbatim modulo accessor substitution) ----- + + private scheduleUpdate(immediate: boolean): void { + if (!this.deps.getOverlayGroup()) return; + + // When the feature is disabled, keep this lightweight and synchronous. + if (!this.deps.isEnabled()) { + this.updateOverlays(); + return; + } + + if (immediate) { + this.cancelDebounce(); + this.updateOverlays(); + return; + } + + // Cheap path: redraw existing badges with the current zoom transform (no recompute, no DOM churn). + this.redrawBadgesOnly(); + + // Debounce to avoid DOM churn during pan/zoom. + this.cancelDebounce(); + this.debounceId = window.setTimeout(() => { + this.debounceId = null; + this.updateOverlays(); + }, DUPLICATE_BADGES_UPDATE_DEBOUNCE_MS); + } + + private ensureForViewport( + viewKey: string, + minX: number, + minY: number, + maxX: number, + maxY: number, + ): boolean { + if (this.cacheKey === viewKey) return true; + if (this.computing) return false; + + this.computing = true; + const jobId = ++this.computeJobId; + + // Query only the slots currently in (or near) the viewport. This is the key perf win. + const candidateSlots = this.deps.getQuadtree().queryByPixels(minX, minY, maxX, maxY); + const scales = this.deps.getScales(); + if (!scales) { + this.computing = false; + return false; + } + + const collected: PlotDataPoint[] = []; + + let idx = 0; + const step = () => { + if (jobId !== this.computeJobId) return; // cancelled + const end = Math.min(candidateSlots.length, idx + DUPLICATE_STACK_COMPUTE_CHUNK_SIZE); + for (; idx < end; idx++) { + const slot = candidateSlots[idx]; + const p = materializePlotDataPoint(this.deps.getPlotData(), slot); + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + collected.push(p); + } + + if (idx < candidateSlots.length) { + requestAnimationFrame(step); + return; + } + + // Finalize: group via the same pure helper the F-24 tests exercise + // (same key fn, finite skip, drop-solos, idToKey-records-solos), then + // re-project each surviving stack's data-space coords to base pixels. + const { stacks: rawStacks, idToKey } = buildDuplicateStacks(collected); + const stacks: ViewportDuplicateStack[] = rawStacks.map((s) => ({ + ...s, + px: scales.x(s.x), + py: scales.y(s.y), + })); + const byKey = new Map(stacks.map((s) => [s.key, s])); + + this.stacks = stacks; + this.byKey = byKey; + this.pointIdToKey = idToKey; + + // If the expanded stack is no longer available for this viewport, collapse it. + if (this.expandedKey && !this.byKey.has(this.expandedKey)) { + this.expandedKey = null; + this.expandedAnchor = null; + } + + // Restore the user's spider anchor on the freshly built stack object so + // pan/zoom doesn't snap the spider back to whichever group member was + // iterated first. + this.applyExpandedAnchor(); + + this.cacheKey = viewKey; + this.computing = false; + + // Re-render overlays for the freshly computed viewport stacks. + this.updateOverlays(); + }; + + requestAnimationFrame(step); + return false; + } + + private redrawBadgesOnly(): void { + if (!this.deps.isEnabled() || this.deps.isSelectionMode()) { + this.badges.clear(); + return; + } + if (!this.deps.getScales()) return; + + const win = computeViewportWindow( + this.deps.getTransform(), + this.deps.getConfig(), + DUPLICATE_BADGES_VIEWPORT_PADDING, + ); + + this.renderBadgesForViewport(win); + } + + /** Cull the current stacks to `win` (top-N cap, keep-expanded) and draw the badges. */ + private renderBadgesForViewport(win: ViewportWindow): void { + const stacksToRender = cullAndCapStacks(this.stacks, win, this.expandedKey, this.byKey); + + // Note: canvas drawing uses screen coordinates and already keeps badge size constant. + this.badges.render(stacksToRender); + } + + private ensureSpiderfyLayer(): Selection | null { + const overlayGroup = this.deps.getOverlayGroup(); + if (!overlayGroup) return null; + let spiderfyLayer = overlayGroup.select('g.duplicate-spiderfy-layer'); + if (spiderfyLayer.empty()) { + spiderfyLayer = overlayGroup.append('g').attr('class', 'duplicate-spiderfy-layer'); + } + return spiderfyLayer; + } + + private updateOverlays(): void { + const overlayGroup = this.deps.getOverlayGroup(); + if (!overlayGroup || !this.deps.getScales()) return; + + // When disabled, remove both layers to clean up older DOM from previous + // versions; while brushing/selecting, don't show stack UI either. + if (!this.deps.isEnabled() || this.deps.isSelectionMode()) { + overlayGroup.selectAll('g.duplicate-stacks-layer, g.duplicate-spiderfy-layer').remove(); + this.expandedKey = null; + this.expandedAnchor = null; + this.badges.clear(); + return; + } + + const spiderfyLayer = this.ensureSpiderfyLayer(); + if (!spiderfyLayer) return; + + const transform = this.deps.getTransform(); + const k = transform.k || 1; + const config = this.deps.getConfig(); + const viewKey = buildViewKey(transform, config.width, config.height); + + // Compute visible window in "base pixel space" (same as quadtree indexing). + const win = computeViewportWindow(transform, config, DUPLICATE_BADGES_VIEWPORT_PADDING); + + // Ensure we have duplicate stacks for the current viewport before trying to render. + if (!this.ensureForViewport(viewKey, win.minX, win.minY, win.maxX, win.maxY)) { + // Keep existing DOM as-is until computation finishes; updateOverlays will rerun. + return; + } + + // --- Badges (N) --- + // Phase 3: render badges via a lightweight 2D canvas overlay (much faster than many SVG nodes). + // Spiderfy remains in SVG for interaction. + this.renderBadgesForViewport(win); + + // --- Spiderfy --- + if (!this.expandedKey) { + this.spiderfy.reset(); + spiderfyLayer.selectAll('*').remove(); + return; + } + + const stack = this.byKey.get(this.expandedKey); + if (!stack) { + this.expandedKey = null; + this.expandedAnchor = null; + spiderfyLayer.selectAll('*').remove(); + return; + } + + // Hide spiderfy if the stack is off-screen (e.g., after a zoom/pan). + if (!pointInWindow(stack, win)) { + this.expandedKey = null; + this.expandedAnchor = null; + spiderfyLayer.selectAll('*').remove(); + return; + } + + this.spiderfy.render(spiderfyLayer, stack, k); + } + + private toggleSpiderfy(stackKey: string, anchorPoint?: PlotDataPoint): void { + this.expandedKey = this.expandedKey === stackKey ? null : stackKey; + + if (this.expandedKey && anchorPoint) { + // Remember where the user clicked so the spider stays anchored to that + // point across pan/zoom — byKey rebuilds with fresh + // objects on every viewport recompute and would otherwise drop the anchor. + this.expandedAnchor = { + stackKey: this.expandedKey, + x: anchorPoint.x, + y: anchorPoint.y, + }; + this.applyExpandedAnchor(); + } else { + this.expandedAnchor = null; + } + + this.updateOverlays(); + } + + private applyExpandedAnchor(): void { + const anchor = this.expandedAnchor; + const scales = this.deps.getScales(); + if (!anchor || !scales) return; + if (anchor.stackKey !== this.expandedKey) return; + const stack = this.byKey.get(anchor.stackKey); + if (!stack) return; + stack.x = anchor.x; + stack.y = anchor.y; + stack.px = scales.x(anchor.x); + stack.py = scales.y(anchor.y); + } +} diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-types.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-types.ts new file mode 100644 index 00000000..769da485 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-types.ts @@ -0,0 +1,24 @@ +import type { PlotDataPoint } from '@protspace/utils'; + +/** + * A duplicate-stack as carried through the viewport pipeline: the helper-level + * {@link DuplicateStack} shape (key/x/y/points in data space) plus the + * pre-projected pixel coords (px/py) the badge/spiderfy renderers draw with. + */ +export interface ViewportDuplicateStack { + key: string; + x: number; + y: number; + /** scales.x(x) — base-pixel-space X (pre-zoom-transform). */ + px: number; + /** scales.y(y) — base-pixel-space Y (pre-zoom-transform). */ + py: number; + points: PlotDataPoint[]; +} + +/** + * The render-time subset of {@link ViewportDuplicateStack} used by the badge + * canvas cull/cap/draw path: screen-space `px`/`py` plus member `points`, keyed + * by `key`. Structurally a slice of the canonical viewport stack. + */ +export type RenderDuplicateStack = Pick; diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.test.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.test.ts new file mode 100644 index 00000000..111442c6 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { computeViewportWindow, buildViewKey } from './duplicate-stack-viewport'; +import { zoomIdentity } from 'd3'; + +const config = { width: 800, height: 600, margin: { top: 10, right: 20, bottom: 30, left: 40 } }; + +describe('computeViewportWindow', () => { + it('matches the inline invertX/invertY + min/max math at identity transform', () => { + const padding = 60; + const t = zoomIdentity; // x=0,y=0,k=1 → invertX(v)=v, invertY(v)=v + const w = computeViewportWindow(t, config, padding); + // left = margin.left - padding = -20 ; right = width - margin.right + padding = 840 + // top = margin.top - padding = -50 ; bottom = height - margin.bottom + padding = 630 + expect(w).toEqual({ minX: -20, maxX: 840, minY: -50, maxY: 630 }); + }); + + it('inverts the zoom transform (k=2, translate 100/50) and orders min<=max', () => { + const padding = 100; + const t = zoomIdentity.translate(100, 50).scale(2); // invertX(v)=(v-100)/2 + const w = computeViewportWindow(t, config, padding); + expect(w.minX).toBeLessThanOrEqual(w.maxX); + expect(w.minY).toBeLessThanOrEqual(w.maxY); + expect(w.minX).toBeCloseTo((config.margin.left - padding - 100) / 2, 6); + expect(w.maxX).toBeCloseTo((config.width - config.margin.right + padding - 100) / 2, 6); + }); + + it('respects the padding parameter (larger padding ⇒ wider window)', () => { + const a = computeViewportWindow(zoomIdentity, config, 60); + const b = computeViewportWindow(zoomIdentity, config, 100); + expect(b.minX).toBeLessThan(a.minX); + expect(b.maxX).toBeGreaterThan(a.maxX); + }); +}); + +describe('buildViewKey', () => { + it('matches the exact `${round(x)}|${round(y)}|${k.toFixed(3)}|${w}|${h}` template', () => { + const t = zoomIdentity.translate(12.4, -7.6).scale(1.5); + expect(buildViewKey(t, 800, 600)).toBe('12|-8|1.500|800|600'); + }); + + it('rounds translate but fixes k to 3 decimals so sub-pixel pans reuse the cache', () => { + const a = buildViewKey(zoomIdentity.translate(0.2, 0.2).scale(1), 800, 600); + const b = buildViewKey(zoomIdentity.translate(-0.2, -0.2).scale(1), 800, 600); + expect(a).toBe(b); // both round to 0|0 + }); +}); diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.ts new file mode 100644 index 00000000..f726def1 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-viewport.ts @@ -0,0 +1,52 @@ +import type { ZoomTransform } from 'd3'; + +export interface ViewportWindow { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +interface ViewportConfigSlice { + width: number; + height: number; + margin: { top: number; right: number; bottom: number; left: number }; +} + +/** + * The visible window in base-pixel space (pre-zoom-transform, same space the + * quadtree indexes), inflated by `padding` on every edge. Verbatim extraction + * of the invertX/invertY + min/max block previously duplicated at three sites. + */ +export function computeViewportWindow( + transform: ZoomTransform, + config: ViewportConfigSlice, + padding: number, +): ViewportWindow { + const leftPx = transform.invertX(config.margin.left - padding); + const rightPx = transform.invertX(config.width - config.margin.right + padding); + const topPx = transform.invertY(config.margin.top - padding); + const bottomPx = transform.invertY(config.height - config.margin.bottom + padding); + return { + minX: Math.min(leftPx, rightPx), + maxX: Math.max(leftPx, rightPx), + minY: Math.min(topPx, bottomPx), + maxY: Math.max(topPx, bottomPx), + }; +} + +/** + * Inclusive point-in-window test in base-pixel space. Single source of truth for + * the predicate previously copy-pasted at the badge-cull and spiderfy-hide sites. + */ +export function pointInWindow(p: { px: number; py: number }, win: ViewportWindow): boolean { + return p.px >= win.minX && p.px <= win.maxX && p.py >= win.minY && p.py <= win.maxY; +} + +/** Cache key shared by virtualization, badge culling, and overlays — they must agree. */ +export function buildViewKey(transform: ZoomTransform, width: number, height: number): string { + // Coerce k the same way the overlay render path does (`transform.k || 1`) so a + // degenerate k=0 keys to the geometry actually rendered, never a stale view. + const k = transform.k || 1; + return `${Math.round(transform.x)}|${Math.round(transform.y)}|${k.toFixed(3)}|${width}|${height}`; +} diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.test.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.test.ts new file mode 100644 index 00000000..e6b2c606 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import type { PlotDataPoint } from '@protspace/utils'; +import { + computeSpiderNodes, + isClickGesture, + SPIDERFY_CLICK_DIST2_MAX, + SPIDERFY_CLICK_MS_MAX, +} from './spiderfy-layer'; + +const pt = (id: string): PlotDataPoint => ({ id, x: 0, y: 0, originalIndex: 0 }); + +describe('computeSpiderNodes', () => { + it('places n nodes on a ring starting at -PI/2 (top), CW', () => { + const pts = [pt('a'), pt('b'), pt('c'), pt('d')]; + const nodes = computeSpiderNodes(pts); + expect(nodes).toHaveLength(4); + expect(nodes[0].x).toBeCloseTo(0, 6); // cos(-PI/2)=0 + expect(nodes[0].y).toBeLessThan(0); // sin(-PI/2)=-1 ⇒ top + expect(nodes.map((n) => n.idx)).toEqual([0, 1, 2, 3]); + }); + + it('ring radius = min(70, max(22, 12 + n*2))', () => { + expect(computeSpiderNodes(Array.from({ length: 2 }, () => pt('x')))[0].r).toBe(22); // 12+4=16 → max→22 + expect(computeSpiderNodes(Array.from({ length: 20 }, () => pt('x')))[0].r).toBe(52); // 12+40=52 + expect(computeSpiderNodes(Array.from({ length: 40 }, () => pt('x')))[0].r).toBe(70); // 12+80=92 → min→70 + }); +}); + +describe('isClickGesture', () => { + it('accepts a short low-movement press/release (dist2<=16 && dt<=700)', () => { + expect(isClickGesture({ x: 0, y: 0, t: 0 }, { clientX: 3, clientY: 1, now: 500 })).toBe(true); // 9+1=10 + }); + it('rejects a long-distance drag', () => { + expect(isClickGesture({ x: 0, y: 0, t: 0 }, { clientX: 5, clientY: 5, now: 100 })).toBe(false); // 50>16 + }); + it('rejects a slow press (dt>700)', () => { + expect(isClickGesture({ x: 0, y: 0, t: 0 }, { clientX: 0, clientY: 0, now: 800 })).toBe(false); + }); + it('exposes the thresholds as named constants', () => { + expect(SPIDERFY_CLICK_DIST2_MAX).toBe(16); + expect(SPIDERFY_CLICK_MS_MAX).toBe(700); + }); +}); diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.ts new file mode 100644 index 00000000..8031d720 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/spiderfy-layer.ts @@ -0,0 +1,153 @@ +import type { Selection } from 'd3'; +import type { PlotDataPoint } from '@protspace/utils'; +import type { ViewportDuplicateStack } from './duplicate-stack-types'; + +const SPIDERFY_NODE_RADIUS = 5; +export const SPIDERFY_CLICK_DIST2_MAX = 16; // (4px)² movement budget for a tap +export const SPIDERFY_CLICK_MS_MAX = 700; + +interface SpiderNode { + point: PlotDataPoint; + idx: number; + x: number; + y: number; + /** ring radius (shared by all nodes of the stack). */ + r: number; +} + +/** Ring geometry: radius = min(70, max(22, 12 + n*2)); node i at angle i/n*2π − π/2. */ +export function computeSpiderNodes(points: PlotDataPoint[]): SpiderNode[] { + const n = points.length; + const r = Math.min(70, Math.max(22, 12 + n * 2)); + return points.map((point, idx) => { + const angle = (idx / n) * Math.PI * 2 - Math.PI / 2; + return { point, idx, x: r * Math.cos(angle), y: r * Math.sin(angle), r }; + }); +} + +/** A press/release counts as a click iff it was short and barely moved. */ +export function isClickGesture( + press: { x: number; y: number; t: number }, + release: { clientX: number; clientY: number; now: number }, +): boolean { + const dx = release.clientX - press.x; + const dy = release.clientY - press.y; + return ( + dx * dx + dy * dy <= SPIDERFY_CLICK_DIST2_MAX && release.now - press.t <= SPIDERFY_CLICK_MS_MAX + ); +} + +interface SpiderfyLayerDeps { + getColor: (p: PlotDataPoint) => string; + onActivate: (event: MouseEvent, p: PlotDataPoint) => void; // → host _handleClick (INV-05) + onHover: (event: MouseEvent, p: PlotDataPoint) => void; // → host _handleMouseOver + onHoverEnd: () => void; // → host _clearHoverState +} + +type SvgG = Selection; + +/** + * Owns the SVG spiderfy ring + the pointer-capture click-synthesis state machine. + * Native 'click' can be eaten by d3.zoom, so taps are reconstructed from + * pointerdown/up via {@link isClickGesture}. Caller positions the layer; this + * builds the ring (constant screen-size via scale(1/k)) and wires interaction. + */ +export class SpiderfyLayer { + private readonly pressByPointerId = new Map(); + + constructor(private readonly deps: SpiderfyLayerDeps) {} + + /** Clear any tracked presses (called on collapse / data swap). */ + reset(): void { + this.pressByPointerId.clear(); + } + + /** Render the ring for `stack` into `layer`, scaled to `k` so it stays screen-constant. */ + render(layer: SvgG, stack: ViewportDuplicateStack, k: number): void { + layer.selectAll('*').remove(); + const nodes = computeSpiderNodes(stack.points); + const spiderGroup = layer + .append('g') + .attr('class', 'dup-spiderfy') + // Keep spiderfy UI constant-size in screen pixels via scale(1/k) + .attr('transform', `translate(${stack.px},${stack.py}) scale(${1 / k})`); + + // Leader lines + spiderGroup + .selectAll('line.dup-spiderfy-line') + .data(nodes) + .enter() + .append('line') + .attr('class', 'dup-spiderfy-line') + .attr('x1', 0) + .attr('y1', 0) + .attr('x2', (d) => d.x) + .attr('y2', (d) => d.y); + + // Clickable nodes + const gNodes = spiderGroup + .selectAll('g.dup-spiderfy-node') + .data(nodes) + .enter() + .append('g') + .attr('class', 'dup-spiderfy-node') + .attr('transform', (d) => `translate(${d.x},${d.y})`); + + // Create circles with explicit pointer-events and handle selection via pointer press/release. + // We avoid relying on the native 'click' event because it can be suppressed by d3.zoom gesture handling. + gNodes + .append('circle') + .attr('class', 'dup-spiderfy-node-circle') + .attr('r', SPIDERFY_NODE_RADIUS) + .attr('fill', (d) => this.deps.getColor(d.point)) + .style('pointer-events', 'all') + .style('cursor', 'pointer') + .on('pointerdown', (event: PointerEvent) => { + event.stopPropagation(); + if (typeof event.pointerId === 'number') { + this.pressByPointerId.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + t: Date.now(), + }); + } + // Keep pointer events routed to this element even if the pointer moves slightly. + const el = event.currentTarget as HTMLElement | null; + if ( + el && + typeof el.setPointerCapture === 'function' && + typeof event.pointerId === 'number' + ) { + try { + el.setPointerCapture(event.pointerId); + } catch { + // ignore + } + } + }) + .on('pointerup', (event: PointerEvent, d: SpiderNode) => { + event.stopPropagation(); + const rec = + typeof event.pointerId === 'number' + ? this.pressByPointerId.get(event.pointerId) + : undefined; + if (typeof event.pointerId === 'number') this.pressByPointerId.delete(event.pointerId); + if (!rec) return; + // Treat a short, low-movement press/release as a click. + if ( + isClickGesture(rec, { clientX: event.clientX, clientY: event.clientY, now: Date.now() }) + ) { + this.deps.onActivate(event as unknown as MouseEvent, d.point); + } + }) + .on('lostpointercapture', (event: PointerEvent) => { + if (typeof event.pointerId === 'number') this.pressByPointerId.delete(event.pointerId); + }) + .on('pointercancel', (event: PointerEvent) => { + if (typeof event.pointerId === 'number') this.pressByPointerId.delete(event.pointerId); + }) + // Show the real tooltip for the hovered protein (not the stack centroid) + .on('mouseenter', (event: MouseEvent, d: SpiderNode) => this.deps.onHover(event, d.point)) + .on('mouseleave', () => this.deps.onHoverEnd()); + } +} diff --git a/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.test.ts b/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.test.ts new file mode 100644 index 00000000..a62fd3e3 --- /dev/null +++ b/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment jsdom + * + * F-07: PlotInteractionController owns the d3 zoom/brush/lasso lifecycle and the + * zoom/lasso RAF loops, signalling the host via callbacks (event dispatch + * stays on the host — INV-03/INV-05). These unit tests drive the controller with + * a real SVG element + injected callbacks and a synchronous RAF. + */ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { PlotInteractionController } from './plot-interaction-controller'; + +function syncRaf() { + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); +} + +function makeHostBridge(svg: SVGSVGElement) { + const calls = { transforms: [] as d3.ZoomTransform[], selections: [] as string[][] }; + return { + bridge: { + getSvg: () => svg, + getCanvas: () => document.createElement('canvas'), + getMergedConfig: () => ({ + width: 800, + height: 600, + zoomExtent: [0.1, 10] as [number, number], + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }), + getSelectionMode: () => false, + getSelectionTool: () => 'rectangle' as const, + hasScales: () => true, + getTransform: () => d3.zoomIdentity, + // slot resolution is host-owned (reuses _slotsToInteractiveIds) + resolveSlotsToIds: (slots: number[]) => slots.map((s) => `p${s}`), + queryByPolygon: (_v: ReadonlyArray<[number, number]>) => [0, 1], + queryByPixels: () => [0, 1], + onTransform: (t: d3.ZoomTransform) => calls.transforms.push(t), + onSelect: (ids: string[]) => calls.selections.push(ids), + onHover: () => {}, + onHoverEnd: () => {}, + onClick: () => {}, + renderWebGL: () => {}, + updateSelectionOverlays: () => {}, + }, + calls, + }; +} + +describe('PlotInteractionController', () => { + let svg: SVGSVGElement; + beforeEach(() => { + syncRaf(); + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + document.body.appendChild(svg); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + svg.remove(); + }); + + it('initialize() creates the three SVG groups and a zoom behavior', () => { + const { bridge } = makeHostBridge(svg); + const c = new PlotInteractionController(bridge); + c.initialize(); + expect(svg.querySelector('g.scatter-plot-container')).not.toBeNull(); + expect(svg.querySelector('g.brush-container')).not.toBeNull(); + expect(svg.querySelector('g.overlay-container')).not.toBeNull(); + }); + + it('zoom emits onTransform and schedules a single render RAF', () => { + const { bridge, calls } = makeHostBridge(svg); + const renderSpy = vi.fn(); + const c = new PlotInteractionController({ ...bridge, renderWebGL: renderSpy }); + c.initialize(); + const t = d3.zoomIdentity.translate(10, 20).scale(2); + // drive the controller's zoom handler directly via its public hook + c.applyZoom(t); + expect(calls.transforms.at(-1)?.k).toBe(2); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('lasso end resolves slots → ids via the host bridge and fires onSelect', () => { + const { bridge, calls } = makeHostBridge(svg); + const c = new PlotInteractionController(bridge); + c.initialize(); + c.beginLasso([0, 0]); + c.extendLasso([10, 0]); + c.extendLasso([10, 10]); + c.endLasso(); + expect(calls.selections.at(-1)).toEqual(['p0', 'p1']); + }); + + it('teardown() cancels every interaction RAF and clears lasso visuals', () => { + const cancel = vi.fn(); + vi.stubGlobal('cancelAnimationFrame', cancel); + const { bridge } = makeHostBridge(svg); + const c = new PlotInteractionController(bridge); + c.initialize(); + c.beginLasso([0, 0]); + c.extendLasso([1, 1]); // arms _lassoRafId + c.teardown(); + expect(cancel).toHaveBeenCalled(); + expect(svg.querySelector('path.lasso-path')).toBeNull(); + }); + + // F-12: resetZoom() runs a 750ms d3 transition on the SVG selection. teardown() + // (called from the host's disconnectedCallback) must interrupt that transition so + // it cannot keep re-arming the zoom RAF / writing the transform after disconnect. + // d3 stores the pending transition schedule on node.__transition synchronously when + // .transition() is called; interrupt() removes it. We assert teardown() clears it. + it('teardown() interrupts the in-flight resetZoom transition', () => { + const { bridge } = makeHostBridge(svg); + const c = new PlotInteractionController(bridge); + c.initialize(); + type NodeWithTransition = SVGSVGElement & { __transition?: unknown }; + // Start the 750ms reset transition, then tear down before it can settle. + c.resetZoom(); + expect((svg as NodeWithTransition).__transition).not.toBeUndefined(); // scheduled + c.teardown(); + expect((svg as NodeWithTransition).__transition).toBeUndefined(); // interrupt() cleared it + }); +}); diff --git a/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.ts b/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.ts new file mode 100644 index 00000000..67f67c7e --- /dev/null +++ b/packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.ts @@ -0,0 +1,363 @@ +import * as d3 from 'd3'; +import type { PlotDataPoint } from '@protspace/utils'; +import type { RenderWebGLTrigger } from '../webgl-render-perf'; + +export interface PlotInteractionHost { + getSvg(): SVGSVGElement | undefined; + getCanvas(): HTMLCanvasElement | undefined; + getMergedConfig(): { + width: number; + height: number; + zoomExtent: [number, number]; + margin: { top: number; right: number; bottom: number; left: number }; + }; + getSelectionMode(): boolean; + getSelectionTool(): 'rectangle' | 'lasso'; + // Readiness: whether the host's scales (and thus data) exist yet. Mirrors main's + // `!this._scales` guard so updateSelectionMode is a no-op before data arrives. + hasScales(): boolean; + // host owns _transform (F-48): the controller reads it back through this getter + // rather than keeping a parallel copy. applyZoom funnels new transforms through + // onTransform first, so reads here always see the latest value. + getTransform(): d3.ZoomTransform; + // host-owned spatial selection (reuses _quadtreeIndex / _slotsToInteractiveIds) + queryByPolygon(vertices: ReadonlyArray<[number, number]>): number[]; + queryByPixels(x0: number, y0: number, x1: number, y1: number): number[]; + resolveSlotsToIds(slots: number[]): string[]; + // callbacks — dispatch stays on the host (INV-03/INV-05) + onTransform(t: d3.ZoomTransform): void; + onSelect(ids: string[], clearVisual: () => void): void; + onHover(event: MouseEvent, point: PlotDataPoint | null): void; + onHoverEnd(): void; + onClick(event: MouseEvent): void; + renderWebGL(trigger: RenderWebGLTrigger): void; + updateSelectionOverlays(opts?: { duplicateImmediate?: boolean }): void; +} + +/** + * Owns the d3 zoom/brush/lasso interaction layer, the three SVG groups, and the + * zoom/lasso RAF loops, lifted out of the scatter-plot god component (F-07). It + * signals the host via callbacks; the host keeps event dispatch and owns the + * transform value (written back via onTransform — F-48). Hover throttling and + * picking stay on the host (host-only quadtree/visibility access). + */ +export class PlotInteractionController { + private _zoom: d3.ZoomBehavior | null = null; + private _svgSelection: d3.Selection | null = null; + private _mainGroup: d3.Selection | null = null; + private _brushGroup: d3.Selection | null = null; + private _overlayGroup: d3.Selection | null = null; + private _brush: d3.BrushBehavior | null = null; + private _isBrushing = false; + private _lassoVertices: Array<[number, number]> = []; + private _lassoPath: SVGPathElement | null = null; + private _isLassoing = false; + + private _zoomRafId: number | null = null; + private _lassoRafId: number | null = null; + + constructor(private readonly host: PlotInteractionHost) {} + + get mainGroup() { + return this._mainGroup; + } + get overlayGroup() { + return this._overlayGroup; + } + get isBrushing() { + return this._isBrushing; + } + + initialize(): void { + const svg = this.host.getSvg(); + if (!svg) return; + + this._svgSelection = d3.select(svg); + + // Clear existing content + this._svgSelection.selectAll('*').remove(); + + // Create main container group + this._mainGroup = this._svgSelection.append('g').attr('class', 'scatter-plot-container'); + + // Create brush group + this._brushGroup = this._svgSelection.append('g').attr('class', 'brush-container'); + + // Create overlay group (above brush) for transient drawings like selections + this._overlayGroup = this._svgSelection.append('g').attr('class', 'overlay-container'); + + this._zoom = d3 + .zoom() + .scaleExtent(this.host.getMergedConfig().zoomExtent) + .on('zoom', (event) => this.applyZoom(event.transform)); + this._svgSelection.call(this._zoom); + this._setupDblClickHandlers(); + } + + /** Apply a transform (from the d3 zoom handler or programmatic reset). */ + applyZoom(t: d3.ZoomTransform): void { + // Host owns the transform (F-48): write it back first so the brush-extent sync + // below (and any other host.getTransform() read) sees the new value. + this.host.onTransform(t); + if (this._mainGroup) { + this._mainGroup.attr('transform', t.toString()); + } + if (this._brushGroup) { + this._brushGroup.attr('transform', t.toString()); + } + if (this._overlayGroup) { + this._overlayGroup.attr('transform', t.toString()); + } + // Smooth WebGL rendering during zoom using requestAnimationFrame + if (this.host.getCanvas()) { + if (this._zoomRafId !== null) { + cancelAnimationFrame(this._zoomRafId); + } + this._zoomRafId = requestAnimationFrame(() => { + this._zoomRafId = null; + this.host.renderWebGL('zoom'); + // During active zoom/pan, defer duplicate badge DOM updates to keep interactions smooth. + this.host.updateSelectionOverlays({ duplicateImmediate: false }); + }); + } + // Keep brush extent in sync with the viewport when scroll-zooming in selection mode. + // Skip if a brush gesture is in progress — re-applying the brush resets D3's drag state. + if ( + this.host.getSelectionMode() && + this.host.getSelectionTool() === 'rectangle' && + this._brush && + !this._isBrushing + ) { + this.updateBrushExtent(); + } + } + + resetZoom(): void { + if (this._zoom && this._svgSelection) { + this._svgSelection.transition().duration(750).call(this._zoom.transform, d3.zoomIdentity); + } + } + + /** Disable D3's built-in double-click zoom and attach our own reset handler. */ + private _setupDblClickHandlers(): void { + if (!this._svgSelection) return; + this._svgSelection.on('dblclick.zoom', null); + this._svgSelection.on('dblclick.reset', (event: MouseEvent) => { + event.preventDefault(); + this.resetZoom(); + }); + } + + setupCanvasEventHandling(): void { + if (!this._svgSelection) return; + + // Use event delegation on the SVG overlay for canvas interactions + this._svgSelection + .on('mousemove.canvas', (event) => this.host.onHover(event, null)) + .on('click.canvas', (event) => this.host.onClick(event)) + .on('mouseout.canvas', () => this.host.onHoverEnd()); + } + + updateSelectionMode(): void { + if (!this._svgSelection || !this._brushGroup || !this.host.hasScales()) return; + + // Clean up both selection tools + this._brushGroup.selectAll('*').remove(); + this._cleanupLasso(); + this._brush = null; + this._isBrushing = false; + + if (this.host.getSelectionMode()) { + // Keep scroll-wheel zoom active but disable drag-to-pan (drag = selection) + if (this._zoom && this._svgSelection) { + this._svgSelection + .on('mousedown.zoom', null) + .on('touchstart.zoom', null) + .on('touchmove.zoom', null) + .on('touchend.zoom', null); + } + + if (this.host.getSelectionTool() === 'lasso') { + this._setupLasso(); + } else { + this._setupBrush(); + } + } else { + // Re-enable zoom + if (this._zoom) { + this._svgSelection.call(this._zoom); + this._setupDblClickHandlers(); + } + } + } + + private _setupBrush(): void { + if (!this._svgSelection || !this._brushGroup) return; + + this._brush = d3 + .brush() + .handleSize(0) + .on('start', () => { + this._isBrushing = true; + }) + .on('end', (event) => { + this._isBrushing = false; + this._handleBrushEnd(event); + }); + + this.updateBrushExtent(); + } + + /** Recompute the brush extent from the current zoom transform and re-apply. */ + updateBrushExtent(): void { + if (!this._brush || !this._brushGroup) return; + + const config = this.host.getMergedConfig(); + const t = this.host.getTransform(); + const vx0 = t.invertX(0); + const vy0 = t.invertY(0); + const vx1 = t.invertX(config.width); + const vy1 = t.invertY(config.height); + + this._brush.extent([ + [Math.min(vx0, vx1), Math.min(vy0, vy1)], + [Math.max(vx0, vx1), Math.max(vy0, vy1)], + ]); + + this._brushGroup.call(this._brush); + } + + // ── Lasso selection ────────────────────────────────────────────── + + private _setupLasso(): void { + if (!this._svgSelection) return; + + this._svgSelection + .on('pointerdown.lasso', (event: PointerEvent) => { + if (event.button !== 0) return; // left click only + event.preventDefault(); + this.beginLasso(this._pointerToLocal(event)); + // Capture pointer for reliable tracking even if cursor leaves the SVG + (event.target as Element)?.setPointerCapture?.(event.pointerId); + }) + .on('pointermove.lasso', (event: PointerEvent) => { + if (!this._isLassoing || !this._lassoPath) return; + event.preventDefault(); + this.extendLasso(this._pointerToLocal(event)); + }) + .on('pointerup.lasso', (event: PointerEvent) => { + if (!this._isLassoing) return; + event.preventDefault(); + (event.target as Element)?.releasePointerCapture?.(event.pointerId); + this.endLasso(); + }); + } + + /** Convert a pointer event to local (untransformed) SVG coordinates. */ + private _pointerToLocal(event: PointerEvent): [number, number] { + const [svgX, svgY] = d3.pointer(event); + const t = this.host.getTransform(); + const localX = (svgX - t.x) / t.k; + const localY = (svgY - t.y) / t.k; + return [localX, localY]; + } + + beginLasso(start: [number, number]): void { + this._isLassoing = true; + this._lassoVertices = [start]; + + // Create the SVG path in the brush group (same coordinate space as the brush) + if (this._brushGroup) { + this._lassoPath = this._brushGroup.append('path').attr('class', 'lasso-path').node(); + } + } + + extendLasso(pt: [number, number]): void { + this._lassoVertices.push(pt); + + // Throttle SVG path updates to animation frames + if (this._lassoRafId === null) { + this._lassoRafId = requestAnimationFrame(() => { + this._lassoRafId = null; + if (!this._lassoPath || this._lassoVertices.length < 2) return; + + const d = this._lassoVertices + .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x},${y}`) + .join(' '); + this._lassoPath.setAttribute('d', d); + }); + } + } + + endLasso(): void { + this._isLassoing = false; + + // Need at least 3 vertices to form a polygon + if (this._lassoVertices.length < 3) { + this._clearLassoVisual(); + return; + } + + // Close the path visually + if (this._lassoPath) { + const d = this._lassoPath.getAttribute('d') ?? ''; + this._lassoPath.setAttribute('d', d + ' Z'); + } + + const slots = this.host.queryByPolygon(this._lassoVertices); + const selectedIds = this.host.resolveSlotsToIds(slots); + this.host.onSelect(selectedIds, () => this._clearLassoVisual()); + } + + private _handleBrushEnd(event: d3.D3BrushEvent): void { + if (!event.selection) return; + + const [[x0, y0], [x1, y1]] = event.selection as [[number, number], [number, number]]; + const slots = this.host.queryByPixels(x0, y0, x1, y1); + const selectedIds = this.host.resolveSlotsToIds(slots); + this.host.onSelect(selectedIds, () => { + if (this._brush && this._brushGroup) { + this._brushGroup.call(this._brush.move, null); + } + }); + } + + private _clearLassoVisual(): void { + if (this._lassoPath) { + this._lassoPath.remove(); + this._lassoPath = null; + } + this._lassoVertices = []; + } + + private _cleanupLasso(): void { + if (this._svgSelection) { + this._svgSelection.on('pointerdown.lasso', null); + this._svgSelection.on('pointermove.lasso', null); + this._svgSelection.on('pointerup.lasso', null); + } + if (this._lassoRafId !== null) { + cancelAnimationFrame(this._lassoRafId); + this._lassoRafId = null; + } + this._lassoPath?.remove(); + this._lassoPath = null; + this._lassoVertices = []; + this._isLassoing = false; + } + + /** Cancel the zoom/lasso RAFs, interrupt the reset transition, tear down brush + lasso. */ + teardown(): void { + if (this._zoomRafId !== null) { + cancelAnimationFrame(this._zoomRafId); + this._zoomRafId = null; + } + this._svgSelection?.interrupt(); + if (this._brush) { + this._brush.on('start', null).on('end', null); + this._brush = null; + this._isBrushing = false; + } + this._cleanupLasso(); + } +} diff --git a/packages/core/src/components/scatter-plot/quadtree-index.test.ts b/packages/core/src/components/scatter-plot/interaction/quadtree-index.test.ts similarity index 100% rename from packages/core/src/components/scatter-plot/quadtree-index.test.ts rename to packages/core/src/components/scatter-plot/interaction/quadtree-index.test.ts diff --git a/packages/core/src/components/scatter-plot/quadtree-index.ts b/packages/core/src/components/scatter-plot/interaction/quadtree-index.ts similarity index 100% rename from packages/core/src/components/scatter-plot/quadtree-index.ts rename to packages/core/src/components/scatter-plot/interaction/quadtree-index.ts diff --git a/packages/core/src/components/scatter-plot/projection-metadata.styles.ts b/packages/core/src/components/scatter-plot/projection-metadata/projection-metadata.styles.ts similarity index 100% rename from packages/core/src/components/scatter-plot/projection-metadata.styles.ts rename to packages/core/src/components/scatter-plot/projection-metadata/projection-metadata.styles.ts diff --git a/packages/core/src/components/scatter-plot/projection-metadata.ts b/packages/core/src/components/scatter-plot/projection-metadata/projection-metadata.ts similarity index 98% rename from packages/core/src/components/scatter-plot/projection-metadata.ts rename to packages/core/src/components/scatter-plot/projection-metadata/projection-metadata.ts index 0b0f0339..bd61cfc1 100644 --- a/packages/core/src/components/scatter-plot/projection-metadata.ts +++ b/packages/core/src/components/scatter-plot/projection-metadata/projection-metadata.ts @@ -1,6 +1,6 @@ import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; -import { customElement } from '../../utils/safe-custom-element'; +import { customElement } from '../../../utils/safe-custom-element'; import type { Projection } from '@protspace/utils'; import { NA_DISPLAY } from '@protspace/utils'; import { projectionMetadataStyles } from './projection-metadata.styles'; diff --git a/packages/core/src/components/scatter-plot/scatter-plot.b6.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.b6.test.ts new file mode 100644 index 00000000..a8ea1060 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.b6.test.ts @@ -0,0 +1,342 @@ +/** + * @vitest-environment jsdom + * + * B6 component characterization for the wired scatter-plot changes + * (F-60, F-40, F-17, F-18). + * + * These tests pin the externally observable contract of the B6 batch so the + * refactor stays behavior-preserving. They follow the proven B7 pattern: the + * element is constructed via `createElement` and NEVER appended, so Lit's + * `connectedCallback` / WebGL init never runs (no WebGL context exists in + * jsdom). The reactive `updated()` dispatcher is exercised by calling it + * directly with an explicit `changedProperties` Map — this drives the real + * `_processData` / filter-clear / data-change-emit logic without the Lit render + * lifecycle. `_processData()` populates `_plotData` via + * `DataProcessor.processVisualizationData` and needs no GPU. + * + * Fixture shape mirrors the neighbour B7 tests + * (scatter-plot.materialize-cache.test.ts / scatter-plot.scales-cache.test.ts): + * { protein_ids, projections:[{name,data:Float32Array,dimension:2}], + * annotations:{key:{values,colors,shapes}}, annotation_data:{key:[...]}, + * numeric_annotation_data:{...} } — NOT a makeViz factory. + * + * RED/GREEN status on the UNMODIFIED tree: + * - F-60 (ref fast-path) : GREEN (existing behavior) + * - F-40 includeFilteredProteinIds:false : GREEN (existing fast path) + * - F-40 filtered correctness : GREEN (existing slice) + * - F-40 filtered memoization (toBe) : RED (not-yet-wired memo) + * - F-40 recompute on ref change : GREEN (rebuilds anyway today) + * - F-17 generation bump : RED (_quadtreeGeneration absent) + * - F-17 cacheKey folds generation : RED (rebuild ignores virt key) + * - F-18 filter clear before reprocess : GREEN (existing order) + * - F-18 data-change emit gating : GREEN (existing gate) + * - F-18 INV-10 re-default : GREEN (existing default) + */ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import type { + VisualizationData, + NumericAnnotationDisplaySettingsMap, + PlotData, +} from '@protspace/utils'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; + +const RED = '#ff0000'; +const GREEN = '#00ff00'; + +type Internals = HTMLElement & { + // public reactive props + data: VisualizationData; + selectedAnnotation: string; + selectedProjectionIndex: number; + projectionPlane: string; + filteredProteinIds: string[]; + filtersActive: boolean; + selectedProteinIds: string[]; + numericAnnotationSettings: NumericAnnotationDisplaySettingsMap; + // internals under test + _plotData: PlotData; + _quadtreeGeneration: number; + _virtualizationCacheKey: string | null; + updated(changed: Map): void; + _processData(): void; + _buildQuadtree(): void; + _getMaterializedData(): VisualizationData | null; + _getCurrentDisplayData(options?: { + includeFilteredProteinIds?: boolean; + }): VisualizationData | null; +}; + +/** + * Categorical family fixture with N points across two families plus a numeric + * column that is NEVER the selected annotation — this forces + * materializeVisualizationData to return a FRESH object on each materialization + * (its categorical-only short-circuit echoes the source ref otherwise), so the + * reference-identity assertions below are meaningful. Mirrors makeFamilyData in + * scatter-plot.materialize-cache.test.ts. + */ +function makeFamilyData(opts?: { n?: number; idPrefix?: string }): VisualizationData { + const n = opts?.n ?? 6; + const idPrefix = opts?.idPrefix ?? 'p'; + const families = Array.from({ length: n }, (_, i) => (i < Math.ceil(n / 2) ? 'A' : 'B')); + const colorFor = (v: string) => (v === 'A' ? RED : GREEN); + const coords = new Float32Array(n * 2); + for (let i = 0; i < n; i++) { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + } + return { + protein_ids: families.map((_, i) => `${idPrefix}${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + other: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + other: families.map((v) => [families.indexOf(v)]), + }, + numeric_annotation_data: { + score: families.map((_, i) => i), + }, + } as unknown as VisualizationData; +} + +/** Fixture whose single annotation key is `only` (for the INV-10 re-default). */ +function makeSingleAnnotationData(n = 4): VisualizationData { + const values = Array.from({ length: n }, (_, i) => (i % 2 === 0 ? 'x' : 'y')); + const coords = new Float32Array(n * 2); + for (let i = 0; i < n; i++) { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + } + return { + protein_ids: values.map((_, i) => `s${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + only: { + values, + colors: values.map(() => RED), + shapes: values.map(() => 'circle'), + }, + }, + annotation_data: { + only: values.map((v) => [v === 'x' ? 0 : 1]), + }, + numeric_annotation_data: { + score: values.map((_, i) => i), + }, + } as unknown as VisualizationData; +} + +function makeScatter(): Internals { + return document.createElement('protspace-scatterplot') as Internals; +} + +/** Build a changedProperties Map mirroring Lit's contract (key -> oldValue). */ +function changed(keys: string[]): Map { + const m = new Map(); + for (const k of keys) m.set(k, undefined); + return m; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// F-60 — single numeric-column read in _getMaterializedData (ref fast-path) +// --------------------------------------------------------------------------- +describe('B6 F-60 _getMaterializedData single numeric read', () => { + it('returns a stable reference on repeated calls with unchanged inputs (GREEN)', () => { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + + // Prime: first call populates the cache + fast-path key fields. + const first = el._getMaterializedData(); + expect(first).toBeTruthy(); + + // Repeated calls with unchanged inputs hit the ref/primitive fast-path and + // return the SAME cached object reference (the merge of the two numeric + // reads into one local must keep this fast-path intact). + const a = el._getMaterializedData(); + const b = el._getMaterializedData(); + expect(a).toBe(first); + expect(b).toBe(first); + }); + + it('fast-path miss: changing selectedAnnotation re-materializes (GREEN)', () => { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + const first = el._getMaterializedData(); + + el.selectedAnnotation = 'other'; + const next = el._getMaterializedData(); + expect(next).not.toBe(first); + }); +}); + +// --------------------------------------------------------------------------- +// F-40 — memoize the filtered display-data rebuild +// --------------------------------------------------------------------------- +describe('B6 F-40 filtered display-data memoization', () => { + function primed(): Internals { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + el.filteredProteinIds = ['p1', 'p3']; + el.filtersActive = true; + return el; + } + + it('filtered slice preserves correctness (GREEN)', () => { + const el = primed(); + const a = el._getCurrentDisplayData(); + expect(a).not.toBeNull(); + expect(a!.protein_ids).toEqual(['p1', 'p3']); + }); + + it('returns the SAME filtered object on repeated calls with unchanged inputs (RED pre-wire — memoization)', () => { + const el = primed(); + const a = el._getCurrentDisplayData(); + const b = el._getCurrentDisplayData(); + expect(b).toBe(a); + }); + + it('recomputes when filteredProteinIds ref changes (GREEN)', () => { + const el = primed(); + const a = el._getCurrentDisplayData(); + el.filteredProteinIds = ['p2']; + el.filtersActive = true; + const b = el._getCurrentDisplayData(); + expect(b).not.toBe(a); + expect(b!.protein_ids).toEqual(['p2']); + }); + + it('includeFilteredProteinIds:false bypasses the cache and returns the materialized object (GREEN)', () => { + const el = primed(); + const mat = el._getMaterializedData(); + const out = el._getCurrentDisplayData({ includeFilteredProteinIds: false }); + expect(out).toBe(mat); + }); +}); + +// --------------------------------------------------------------------------- +// F-17 — virtualization cache invalidation on quadtree rebuild (sanctioned bug fix) +// +// The >=1M-point regime is not unit-reproducible cheaply, so we test the +// MECHANISM directly: a quadtree rebuild must advance a generation counter that +// is folded into the virtualization cacheKey, forcing the next visible-points +// read to miss even when the transform is unchanged. +// --------------------------------------------------------------------------- +describe('B6 F-17 virtualization cache invalidated on quadtree rebuild', () => { + function withPlotData(): Internals { + const el = makeScatter(); + el.data = makeFamilyData({ n: 8 }); + el.selectedAnnotation = 'fam'; + // Build _plotData + _scales so _buildQuadtree takes the real rebuild path + // (not the empty early-return). + el._processData(); + return el; + } + + it('bumps the quadtree generation when the quadtree is rebuilt (RED pre-wire — field absent)', () => { + const el = withPlotData(); + const before = el._quadtreeGeneration; + el._buildQuadtree(); + const after = el._quadtreeGeneration; + expect(after).toBe(before + 1); + }); + + it('virtualization cacheKey is invalidated by a quadtree rebuild (RED pre-wire — rebuild ignores virt key)', () => { + const el = withPlotData(); + // Prime with a sentinel key; a rebuild must invalidate it (key cleared OR + // generation advanced so the next computed key differs from the sentinel). + el._virtualizationCacheKey = 'STALE'; + el._buildQuadtree(); + expect(el._virtualizationCacheKey).not.toBe('STALE'); + }); +}); + +// --------------------------------------------------------------------------- +// F-18 — updated() effect ordering & INV-11 gate +// +// updated() is driven directly with an explicit changedProperties Map (the +// element is never appended). This exercises the real dispatcher: the +// filter-clear-before-reprocess order, the data-change emit gate, and the +// INV-10 selectedAnnotation re-default. +// --------------------------------------------------------------------------- +describe('B6 F-18 updated() effect ordering & INV-11 gate', () => { + it('clears stale filters before reprocessing on a data swap (GREEN)', () => { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + el.filteredProteinIds = ['p1']; + el.filtersActive = true; + el._processData(); + + // Swap to a new dataset whose ids do not overlap p*. + el.data = makeFamilyData({ n: 5, idPrefix: 'q' }); + el.updated(changed(['data'])); + + // The data-swap filter reset (INV) must fire BEFORE _processData, so the + // new plot is built from the full 5-point set, not blanked by a stale set. + expect(el.filtersActive).toBe(false); + expect(el.filteredProteinIds).toEqual([]); + expect(el._plotData.length).toBe(5); + }); + + it('emits data-change exactly when an INV-11 geometry input changes (GREEN)', () => { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + el._processData(); + + const seen: string[] = []; + el.addEventListener('data-change', () => seen.push('data-change')); + + // Selection-only change: NOT a geometry change → no data-change emit. + el.selectedProteinIds = ['p0']; + el.updated(changed(['selectedProteinIds'])); + expect(seen).toHaveLength(0); + + // filteredProteinIds + filtersActive: geometry change → exactly one emit. + el.filteredProteinIds = ['p1']; + el.filtersActive = true; + el.updated(changed(['filteredProteinIds', 'filtersActive'])); + expect(seen).toEqual(['data-change']); + }); + + it('re-defaults selectedAnnotation to annotationKeys[0] when data lacks it (INV-10, GREEN)', () => { + const el = makeScatter(); + el.data = makeFamilyData({ n: 6 }); + el.selectedAnnotation = 'fam'; + el._processData(); + + el.selectedAnnotation = 'does-not-exist'; + el.data = makeSingleAnnotationData(4); + el.updated(changed(['data'])); + + expect(el.selectedAnnotation).toBe('only'); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.duplicate-overlay.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.duplicate-overlay.test.ts new file mode 100644 index 00000000..9a38f88b --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.duplicate-overlay.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + * + * Characterization lock for the duplicate-stack overlay subsystem. + * + * Guards the contracts the F-06 controller-extraction move must preserve: + * 1. the shared helper groups exact-coord coincidents and drops solos, keying + * by the same per-projection coord key production groups by (F-36 contract); + * 2. the feature is gated off by default (enableDuplicateStackUI === false) and + * the overlay update is a no-op (does not throw) on an element with no + * overlay group attached; + * 3. cancelCompute bumps the compute job id so any in-flight chunked compute + * aborts early (stale-result race guard). + * + * The element is created via document.createElement and NOT appended, so Lit's + * connectedCallback / WebGL init never runs (same pattern as + * scatter-plot.materialize-cache.test.ts L18-21). + * + * F-06 moved the subsystem into DuplicateStackOverlayController; these probes + * now reach through `el._dupOverlay` while asserting the SAME observable + * contracts (job-id monotonicity, no-op-when-disabled). Lock 1 is name-stable + * and never changes. + */ +import { vi, describe, it, expect } from 'vitest'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; +import { + buildDuplicateStacks, + getDuplicateStackKey, +} from './duplicate-stacks/duplicate-stack-helpers'; + +interface DuplicateOverlayController { + // TS-private at compile time, reachable at runtime — the job-id race guard. + computeJobId: number; + updateSelectionOverlays: (opts?: { duplicateImmediate?: boolean }) => void; + cancelCompute: () => void; +} + +interface DuplicateOverlayInternals extends HTMLElement { + _mergedConfig?: { enableDuplicateStackUI?: boolean }; + _dupOverlay: DuplicateOverlayController; +} + +function makeElement(): DuplicateOverlayInternals { + return document.createElement('protspace-scatterplot') as DuplicateOverlayInternals; +} + +describe('duplicate-overlay characterization', () => { + // Lock 1: helper key contract is the same one production groups by (F-36). + it('groups exact-coord coincidents and drops solos via the shared helper', () => { + const r = buildDuplicateStacks([ + { id: 'a', x: 1, y: 1 }, + { id: 'b', x: 1, y: 1 }, + { id: 'c', x: 9, y: 9 }, + ]); + + // The coincident pair (a, b) forms exactly one stack; the solo (c) is dropped. + expect(r.stacks).toHaveLength(1); + expect(r.stacks[0].points.map((p) => p.id).sort()).toEqual(['a', 'b']); + + // idToKey records membership for ALL points (solos included) via the shared key. + expect(r.idToKey.get('a')).toBe(getDuplicateStackKey({ x: 1, y: 1 })); + expect(r.idToKey.get('b')).toBe(getDuplicateStackKey({ x: 1, y: 1 })); + expect(r.idToKey.get('c')).toBe(getDuplicateStackKey({ x: 9, y: 9 })); + + // The dropped solo's key is absent from byKey/stacks. + expect(r.byKey.has(getDuplicateStackKey({ x: 9, y: 9 }))).toBe(false); + }); + + // Lock 2: the feature is gated off by default -> no badge canvas writes, no SVG layer. + it('does nothing when enableDuplicateStackUI is false (default)', () => { + const el = makeElement(); + expect(el._mergedConfig?.enableDuplicateStackUI ?? false).toBe(false); + // The overlay update is a no-op with no overlay group attached; must not throw. + expect(() => el._dupOverlay.updateSelectionOverlays()).not.toThrow(); + }); + + // Lock 3: cancelCompute bumps the job id so an in-flight chunk aborts (race guard). + it('cancelling compute bumps the job id (stale-result guard)', () => { + const el = makeElement(); + const before = el._dupOverlay.computeJobId; + el._dupOverlay.cancelCompute(); + expect(el._dupOverlay.computeJobId).toBeGreaterThan(before); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.duplicate-stack-compute.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.duplicate-stack-compute.test.ts new file mode 100644 index 00000000..73267af6 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.duplicate-stack-compute.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +// F-06 moved the chunked-compute state into DuplicateStackOverlayController. +// These are TS-private at compile time but reachable at runtime; the probes +// assert the SAME contracts (job-id supersede guard, viewKey cache hit). +type DupOverlay = { + ensureForViewport(k: string, a: number, b: number, c: number, d: number): boolean; + stacks: unknown[]; + cacheKey: string | null; + computeJobId: number; +}; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + config: { enableDuplicateStackUI: boolean }; + _processData(): void; + _buildQuadtree(): void; + _dupOverlay: DupOverlay; +}; + +// Two pairs of EXACT-duplicate coordinates so a stack of >1 forms. +// Fixture shape mirrors the real VisualizationData used by the neighbour suites +// (scatter-plot.materialize-cache.test.ts): projections[].data Float32Array + +// annotations/annotation_data keyed by feature name. +function dupData(): VisualizationData { + const families = ['A', 'A', 'B', 'B']; + return { + protein_ids: ['p0', 'p1', 'p2', 'p3'], + // p0==p1 at (0,0), p2==p3 at (5,5) + projections: [{ name: 'p', data: new Float32Array([0, 0, 0, 0, 5, 5, 5, 5]), dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map((v) => (v === 'A' ? '#f00' : '#0f0')), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + }, + numeric_annotation_data: {}, + } as unknown as VisualizationData; +} + +function prime(): Internals { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.config = { enableDuplicateStackUI: true }; + sp.data = dupData(); + sp.selectedAnnotation = 'fam'; + sp._processData(); // builds _plotData + // The quadtree builds lazily on render (RAF-scheduled). Build it directly here so + // _ensureDuplicateStacksForViewport's queryByPixels has a populated index to scan. + // Called directly (not via _scheduleQuadtreeRebuild) so it doesn't enqueue into the + // stubbed RAF queue installed by the test's beforeEach. + sp._buildQuadtree(); + return sp; +} + +describe('duplicate-stack chunked compute (F-24 characterization lock)', () => { + let rafQueue: FrameRequestCallback[]; + beforeEach(() => { + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + }); + afterEach(() => vi.unstubAllGlobals()); + const drain = () => { + const q = rafQueue; + rafQueue = []; + q.forEach((cb) => cb(0)); + }; + + it('a superseded job (job id bumped before drain) does not commit its results', () => { + const sp = prime(); + sp._dupOverlay.ensureForViewport('view-1', -1000, -1000, 1000, 1000); // starts job, queues RAF + sp._dupOverlay.computeJobId++; // simulate a second viewport/cancel superseding it + drain(); + expect(sp._dupOverlay.cacheKey).not.toBe('view-1'); // first job bailed → cache key not set + }); + + it('a repeat call with the same viewKey short-circuits (cache hit) without recompute', () => { + const sp = prime(); + sp._dupOverlay.ensureForViewport('view-1', -1000, -1000, 1000, 1000); + drain(); // completes; cache key becomes 'view-1' + expect(sp._dupOverlay.cacheKey).toBe('view-1'); + const before = sp._dupOverlay.stacks; + const hit = sp._dupOverlay.ensureForViewport('view-1', -1000, -1000, 1000, 1000); + expect(hit).toBe(true); // cache-key early-return + expect(rafQueue).toHaveLength(0); // no new compute scheduled + expect(sp._dupOverlay.stacks).toBe(before); // results untouched + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts index 4f9ced47..653df5bd 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts @@ -185,3 +185,136 @@ describe('scatter-plot getCurrentData (isolation slicing)', () => { expect(Array.from(r.projections[0].data)).toEqual([10, 11, 30, 33]); }); }); + +describe('scatter-plot isolation render-refresh sequence', () => { + type RefreshInternals = HTMLElement & { + data: VisualizationData; + selectedProteinIds: string[]; + selectedProjectionIndex: number; + _isolationMode: boolean; + _isolationHistory: string[][]; + _plotData: PlotData; + _lastDataRef: unknown; + _processData(): void; + _buildQuadtree(): void; + _updateStyleSignature(): void; + _renderPlot(): void; + _reprocessAndRefresh(): void; + isolateSelection(): void; + resetIsolation(): void; + }; + + function buildData(): VisualizationData { + return { + protein_ids: ['p0', 'p1', 'p2', 'p3', 'p4'], + projections: [ + { + name: 'proj', + dimension: 2, + data: new Float32Array([0, 0, 10, 11, 20, 22, 30, 33, 40, 44]), + }, + ], + annotations: { + cat: { + kind: 'categorical', + values: ['A', 'B'], + colors: ['#000000', '#ffffff'], + shapes: ['circle', 'square'], + }, + }, + annotation_data: { cat: new Int32Array([0, 1, 0, 1, 0]) }, + }; + } + + function makeEl(): RefreshInternals { + const el = document.createElement('protspace-scatterplot') as RefreshInternals; + el.data = buildData(); + el.selectedProjectionIndex = 0; + // Identity view over all 5 proteins. isolateSelection() validates the + // requested ids against plotDataId(_plotData, slot); without a populated + // _plotData the validation finds no survivors and bails before the refresh. + el._plotData = { + length: 5, + xs: new Float32Array([0, 10, 20, 30, 40]), + ys: new Float32Array([0, 11, 22, 33, 44]), + zs: null, + originalIndices: null, + proteinIds: ['p0', 'p1', 'p2', 'p3', 'p4'], + }; + return el; + } + + // Record the order of the staged refresh steps. We spy the pure-ish private + // steps; requestUpdate + the deferred _renderPlot are observed via + // updateComplete resolution. The element is never appended, so Lit's lifecycle + // and WebGL never fire — _webglRenderer stays undefined, exercising the + // `if (this._webglRenderer)` false branch. + function instrument(el: RefreshInternals) { + const calls: string[] = []; + vi.spyOn(el, '_processData').mockImplementation(() => calls.push('processData')); + vi.spyOn(el, '_buildQuadtree').mockImplementation(() => calls.push('buildQuadtree')); + vi.spyOn(el, '_updateStyleSignature').mockImplementation(() => + calls.push('updateStyleSignature'), + ); + vi.spyOn(el, '_renderPlot').mockImplementation(() => calls.push('renderPlot')); + // jsdom element is not connected, so updateComplete is an already-resolved promise. + Object.defineProperty(el, 'updateComplete', { + configurable: true, + get: () => Promise.resolve(true), + }); + const requestUpdate = vi.spyOn(el as unknown as { requestUpdate: () => void }, 'requestUpdate'); + return { calls, requestUpdate }; + } + + it('isolateSelection runs processData → buildQuadtree → requestUpdate, then defers renderPlot', async () => { + const el = makeEl(); + el.selectedProteinIds = ['p1', 'p3']; + const { calls, requestUpdate } = instrument(el); + + el.isolateSelection(); + + // Synchronous portion: process + quadtree happen before requestUpdate; render is deferred. + expect(calls).toEqual(['processData', 'buildQuadtree']); + expect(requestUpdate).toHaveBeenCalled(); + + await el.updateComplete; + expect(calls).toEqual(['processData', 'buildQuadtree', 'renderPlot']); + }); + + it('resetIsolation nulls _lastDataRef BEFORE reprocess, then runs the same refresh sequence', async () => { + const el = makeEl(); + el._isolationMode = true; + el._isolationHistory = [['p1', 'p3']]; + el._lastDataRef = { stale: true }; + const { calls, requestUpdate } = instrument(el); + // Capture _lastDataRef at the moment _processData is (re)invoked. + let lastDataRefAtProcess: unknown = 'unset'; + ( + el._processData as unknown as { mockImplementation: (f: () => void) => void } + ).mockImplementation(() => { + lastDataRefAtProcess = el._lastDataRef; + calls.push('processData'); + }); + + el.resetIsolation(); + + // Divergence preserved: cleared before the shared refresh block runs. + expect(lastDataRefAtProcess).toBeNull(); + expect(calls).toEqual(['processData', 'buildQuadtree']); + expect(requestUpdate).toHaveBeenCalled(); + + await el.updateComplete; + expect(calls).toEqual(['processData', 'buildQuadtree', 'renderPlot']); + }); + + it('_reprocessAndRefresh is the single shared implementation both callers route through', () => { + const el = makeEl(); + const spy = vi.spyOn(el, '_reprocessAndRefresh'); + el.selectedProteinIds = ['p1']; + el.isolateSelection(); + el._isolationMode = true; + el._isolationHistory = [['p1']]; + el.resetIsolation(); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts new file mode 100644 index 00000000..bf72efdd --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts @@ -0,0 +1,274 @@ +/** + * @vitest-environment jsdom + * + * Legend reactivity (B11: F-19 / F-31 / F-57 / F-46). The legend → scatter-plot + * mapping transport (INV-06/07) is consumed by two handlers + * (`_handleZOrderChange` / `_handleColorMappingChange`). This file LOCKS their + * pre-change behavior: + * + * - F-31 single render path: a legend mapping change must render EXACTLY ONCE. + * Today the imperative handler calls `_renderPlot()` once AND the three + * mapping fields are `@state`, so the write calls `requestUpdate(...)`, + * enqueues a Lit update, and `updated()`'s catch-all (scatter-plot.ts + * L774-777, `!onlySelectionChanged`) fires a SECOND `_renderPlot()`. The + * load-bearing signal — identical to F-48/_transform — is whether the field + * write calls `requestUpdate`: a reactive `@state` setter calls it + * synchronously on write (RED: a second render is scheduled); a plain field + * does not (GREEN after F-31). This is observable without connecting and + * without any `updateComplete` await (which hangs on an un-appended element). + * + * - INV-08 colorOnly guardrail: colorOnly=true skips `invalidateDepthOrder()` + * + virtualization invalidation; colorOnly=false forces them. Must stay + * GREEN across the batch. + * + * - F-19 key-validation: a malformed/partial detail must NOT overwrite the + * mapping fields with `undefined`. Today the handlers blind-cast + * `event as CustomEvent` and assign `.detail.shapeMapping` (= undefined) — + * RED until the runtime guards are added. + * + * - F-57 (post-B6 reality): the numeric recompute lifecycle is owned by + * `NumericRecomputeRunner`; the host exposes `_numericRecomputeRunning` + * (`@state` mirror, driven by the runner's `setRunning` host callback) — + * there is NO `_numericRecomputeState` object. The runner's `setRunning` + * write to the `@state` mirror schedules its own Lit update, so the runner's + * explicit `host.requestUpdate()` in the start path is redundant. Signal: + * spy the host `requestUpdate` across a synchronous `schedule()` start. + * + * - F-46: the public `numeric-recompute-start` / `-end` CustomEvents have ZERO + * consumers (confirmed by repo-wide search; absent from INV-05). They must + * be removed while the `_numericRecomputeRunning` busy mirror is preserved. + * Today the runner dispatches them via the host `dispatch` callback — RED for + * "not dispatched" until F-46. + * + * Construct the element via createElement WITHOUT appending (so Lit's + * connectedCallback / WebGL init never runs — same no-append pattern as + * scatter-plot.test.ts / scatter-plot.b6.test.ts). The legend listeners are + * registered in connectedCallback, which never runs here, so the handlers are + * driven DIRECTLY (not via dispatchEvent), matching the sibling tests that call + * private handlers directly. + */ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; + +function makeData(): VisualizationData { + const fams = ['A', 'A', 'B']; + const coords = new Float32Array(fams.length * 2); + fams.forEach((_, i) => { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + }); + return { + protein_ids: fams.map((_, i) => `p${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + fam: { + values: fams, + colors: ['#f00', '#f00', '#0f0'], + shapes: fams.map(() => 'circle'), + }, + }, + annotation_data: { fam: fams.map((v) => [fams.indexOf(v)]) }, + } as unknown as VisualizationData; +} + +type WebglStub = { + invalidateDepthOrder: ReturnType; + invalidateStyleCache: ReturnType; +}; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + _plotData: { length: number }; + _zOrderMapping: Record | null; + _colorMapping: Record | null; + _shapeMapping: Record | null; + _styleGettersCache: unknown; + _renderPlot(): void; + _invalidateVirtualizationCache(): void; + _webglRenderer: WebglStub; + _numericRecomputeRunning: boolean; + _handleZOrderChange(event: Event): void; + _handleColorMappingChange(event: Event): void; + _scheduleNumericAnnotationRefresh(): void; + requestUpdate(name?: PropertyKey, oldValue?: unknown): void; +}; + +function makeEl(): Internals { + const el = document.createElement('protspace-scatterplot') as unknown as Internals; + el.data = makeData(); + el.selectedAnnotation = 'fam'; + // Simulate post-process state: non-empty plot so the handler render branch runs. + (el as unknown as { _plotData: unknown })._plotData = { length: 3 }; + // Stub the renderer so invalidate* calls are no-ops and observable. + (el as unknown as { _webglRenderer: WebglStub })._webglRenderer = { + invalidateDepthOrder: vi.fn(), + invalidateStyleCache: vi.fn(), + }; + return el; +} + +function colorMappingEvent(detail: unknown): Event { + return new CustomEvent('legend-colormapping-change', { detail }); +} +function zOrderEvent(detail: unknown): Event { + return new CustomEvent('legend-zorder-change', { detail }); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('legend mapping handlers — single render path (F-31)', () => { + it('z-order change renders once imperatively and schedules NO second (Lit) render', () => { + const el = makeEl(); + const renderSpy = vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + // requestUpdate is the Lit scheduling hook: a reactive @state write calls it + // (→ updated() catch-all = a SECOND render); a plain field does not. + const reqSpy = vi.spyOn(el, 'requestUpdate'); + + el._handleZOrderChange(zOrderEvent({ zOrderMapping: { A: 1, B: 0 } })); + + expect(renderSpy).toHaveBeenCalledTimes(1); // the imperative render + expect(el._zOrderMapping).toEqual({ A: 1, B: 0 }); + // F-31: while _zOrderMapping is @state, the write schedules the second render. + // RED on the current tree (requestUpdate called); GREEN once demoted to a plain field. + expect(reqSpy).not.toHaveBeenCalled(); + }); + + it('color-mapping change renders once imperatively and schedules NO second (Lit) render', () => { + const el = makeEl(); + const renderSpy = vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + const reqSpy = vi.spyOn(el, 'requestUpdate'); + + el._handleColorMappingChange( + colorMappingEvent({ + colorMapping: { A: '#111111', B: '#222222' }, + shapeMapping: { A: 'circle', B: 'square' }, + colorOnly: false, + }), + ); + + expect(renderSpy).toHaveBeenCalledTimes(1); + expect(el._colorMapping).toEqual({ A: '#111111', B: '#222222' }); + expect(el._shapeMapping).toEqual({ A: 'circle', B: 'square' }); + // F-31: _colorMapping/_shapeMapping are @state today → requestUpdate is + // called → updated() catch-all renders a SECOND time. RED until demoted. + expect(reqSpy).not.toHaveBeenCalled(); + }); +}); + +describe('legend mapping handlers — INV-08 colorOnly contract (guardrail, stays GREEN)', () => { + it('colorOnly=true does NOT call invalidateDepthOrder / virtualization invalidate', () => { + const el = makeEl(); + vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + const virtSpy = vi.spyOn(el, '_invalidateVirtualizationCache').mockImplementation(() => {}); + + el._handleColorMappingChange( + colorMappingEvent({ + colorMapping: { A: '#1' }, + shapeMapping: { A: 'circle' }, + colorOnly: true, + }), + ); + + expect(el._webglRenderer.invalidateDepthOrder).not.toHaveBeenCalled(); + expect(virtSpy).not.toHaveBeenCalled(); + }); + + it('colorOnly=false DOES call invalidateDepthOrder + virtualization invalidate', () => { + const el = makeEl(); + vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + const virtSpy = vi.spyOn(el, '_invalidateVirtualizationCache').mockImplementation(() => {}); + + el._handleColorMappingChange( + colorMappingEvent({ + colorMapping: { A: '#1' }, + shapeMapping: { A: 'circle' }, + colorOnly: false, + }), + ); + + expect(el._webglRenderer.invalidateDepthOrder).toHaveBeenCalledTimes(1); + expect(virtSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('legend mapping handlers — malformed detail key-validation (F-19)', () => { + it('a partial color-mapping detail does NOT overwrite state with undefined', () => { + const el = makeEl(); + el._colorMapping = { A: '#existing' }; + el._shapeMapping = { A: 'circle' }; + vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + + // Missing shapeMapping → a guard must reject, leaving prior state intact. + el._handleColorMappingChange(colorMappingEvent({ colorMapping: { B: '#new' } })); + + // RED today: the handler blind-assigns _shapeMapping = detail.shapeMapping (undefined). + expect(el._colorMapping).toEqual({ A: '#existing' }); + expect(el._shapeMapping).toEqual({ A: 'circle' }); + }); + + it('a malformed z-order detail does NOT overwrite _zOrderMapping with undefined', () => { + const el = makeEl(); + el._zOrderMapping = { A: 0, B: 1 }; + vi.spyOn(el, '_renderPlot').mockImplementation(() => {}); + + // No zOrderMapping key → a guard must reject. + el._handleZOrderChange(zOrderEvent({ wrongKey: { A: 9 } })); + + // RED today: the handler assigns _zOrderMapping = detail.zOrderMapping (undefined). + expect(el._zOrderMapping).toEqual({ A: 0, B: 1 }); + }); +}); + +describe('numeric-recompute scheduling — no redundant requestUpdate (F-57, post-B6)', () => { + it('schedules exactly ONE Lit update on start — the @state mirror, not a duplicate explicit call', () => { + const el = makeEl(); + // POST-B6: busy state is the _numericRecomputeRunning @state mirror, driven + // by the runner's setRunning host callback. Writing that @state field already + // routes through the element's requestUpdate() (Lit's reactive setter) and + // schedules the update. The runner's SEPARATE explicit host.requestUpdate() + // call was the redundant one F-57 drops. + // + // RED on the pre-F-57 tree: schedule() triggers TWO requestUpdate calls (the + // @state setter's own + the explicit host.requestUpdate()). GREEN after F-57: + // exactly ONE — the legitimate @state-driven schedule, with the redundant + // explicit call removed. + const reqSpy = vi.spyOn(el, 'requestUpdate'); + el._scheduleNumericAnnotationRefresh(); + expect(reqSpy).toHaveBeenCalledTimes(1); + // The single call is the reactive @state mirror write (state: true), not a + // bare argument-less explicit requestUpdate(). + expect(reqSpy.mock.calls[0][0]).toBe('_numericRecomputeRunning'); + }); +}); + +describe('numeric-recompute events removed (F-46)', () => { + it('does not dispatch numeric-recompute-start on schedule', () => { + const el = makeEl(); + const startSpy = vi.fn(); + el.addEventListener('numeric-recompute-start', startSpy); + el._scheduleNumericAnnotationRefresh(); + // RED today: the runner dispatches numeric-recompute-start via the host + // dispatch callback. The stale-job guard + busy state are characterized via + // the kept observables below + numeric-recompute-runner.test.ts. + expect(startSpy).not.toHaveBeenCalled(); + }); + + it('still sets _numericRecomputeRunning so the busy UI is unaffected (guardrail)', () => { + const el = makeEl(); + el._scheduleNumericAnnotationRefresh(); + expect(el._numericRecomputeRunning).toBe(true); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.lifecycle.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.lifecycle.test.ts new file mode 100644 index 00000000..cff9d9e6 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.lifecycle.test.ts @@ -0,0 +1,259 @@ +/** + * @vitest-environment jsdom + * + * B2 lifecycle hardening (scatter-plot-part2 audit). These tests pin the + * post-disconnect / post-context-loss behaviour of the host's lifecycle paths. + * Every assertion targets a DETACHED node or a null-renderer state; the + * connected render / zoom / selection / numeric flow is untouched and stays + * byte-identical (INV-03 / INV-05: we only SUPPRESS spurious dispatches from a + * detached node, never alter a dispatch the user observes while connected). + * + * Construct the element via createElement WITHOUT appending it (so isConnected + * stays false and Lit's connectedCallback / WebGL init never auto-run — same + * approach as scatter-plot.test.ts / scatter-plot.isolation.test.ts). We drive + * the private lifecycle methods directly. + * + * Findings: + * - F-35 + F-11: firstUpdated constructs EXACTLY ONE WebGLRenderer and never + * orphans one (currently RED — firstUpdated double-constructs via + * _updateSizeAndRender then again inline). + * - F-05: a numeric recompute does not complete after disconnect — the busy + * state is cleared and a superseded RAF body bails (ALREADY SATISFIED by + * B6/F-04 NumericRecomputeRunner.cancel(); this is a characterization lock). + * (F-46 removed the old `numeric-recompute-end` event; re-characterized via + * the kept `_numericRecomputeRunning` mirror.) + * - F-12: the 750ms resetZoom transition is interrupted on disconnect + * (ALREADY SATISFIED by B8 PlotInteractionController.teardown(); this is a + * characterization lock asserted via the controller teardown path). + * - F-16: a selection committed then disconnected before its deferred RAF + * fires dispatches nothing — disconnectedCallback cancels the tracked + * _commitSelectionRafId (currently RED — the RAF id is not cancelled). The + * suppression is via cancellation, NOT an isConnected body-guard, so the + * connected dispatch (scatter-plot.test.ts B7 locks) stays byte-identical. + * - F-21: `_renderWebGL` is a no-op (does not throw) when `_webglRenderer` is + * null (currently RED — uses a non-null assertion). + */ +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +// Count WebGLRenderer constructions without a real GL context. We preserve the +// real module's other exports (MAX_POINTS_DIRECT_RENDER) and replace only the +// renderer with an instrumented stub that records each construction + destroy. +// The class + registry live in vi.hoisted so the hoisted vi.mock factory can +// close over them (a top-level const would be a TDZ ReferenceError at mock time). +const { webglConstructions, FakeWebGLRenderer } = vi.hoisted(() => { + const constructions: FakeWebGLRenderer[] = []; + class FakeWebGLRenderer { + destroyed = false; + constructor(..._args: unknown[]) { + constructions.push(this); + } + setStyleSignature() {} + setSelectionActive() {} + invalidatePositionCache() {} + invalidateStyleCache() {} + invalidateDepthOrder() {} + setTrackRenderedPointIds() {} + render() {} + clear() {} + resize() {} + releaseDataReferences() {} + destroy() { + this.destroyed = true; + } + } + return { webglConstructions: constructions, FakeWebGLRenderer }; +}); + +vi.mock('./webgl', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, WebGLRenderer: FakeWebGLRenderer }; +}); + +type FakeWebGLRenderer = InstanceType; + +import './scatter-plot'; + +function makeFamilyData(): VisualizationData { + const families = ['A', 'A', 'A', 'B', 'B', 'B']; + const colorFor = (v: string) => (v === 'A' ? '#ff0000' : '#00ff00'); + const coords = new Float32Array(families.length * 2); + families.forEach((_, i) => { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + }); + return { + protein_ids: families.map((_, i) => `p${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + }, + numeric_annotation_data: { + score: families.map((_, i) => i), + }, + } as unknown as VisualizationData; +} + +type Host = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + selectedProteinIds: string[]; + _canvas?: HTMLCanvasElement; + firstUpdated(): void; + disconnectedCallback(): void; + _scheduleNumericAnnotationRefresh(): void; + _commitSelection(ids: string[], clearVisual: () => void): void; + _renderWebGL(trigger?: string): void; + _numericRecomputeRunning: boolean; + _webglRenderer: FakeWebGLRenderer | null; + _interaction: { + teardown(): void; + resetZoom(): void; + initialize(): void; + } | null; +}; + +function makeHost(): Host { + const sp = document.createElement('protspace-scatterplot') as Host; + sp.data = makeFamilyData(); + sp.selectedAnnotation = 'fam'; + return sp; +} + +afterEach(() => { + webglConstructions.length = 0; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('F-35 + F-11: firstUpdated constructs exactly one WebGLRenderer', () => { + it('constructs exactly ONE WebGLRenderer and orphans none', () => { + const sp = makeHost(); + // @query('canvas') resolves only after a render; supply a stub canvas so the + // firstUpdated `if (this._canvas)` branch is taken (mirrors production). + Object.defineProperty(sp, '_canvas', { + configurable: true, + value: document.createElement('canvas'), + }); + + sp.firstUpdated(); + + // The bug: firstUpdated() calls _updateSizeAndRender() (which lazily + // constructs a renderer when _canvas is present) and THEN constructs another + // renderer inline with no null guard — orphaning the first (never destroyed). + expect(webglConstructions).toHaveLength(1); + // The host must reference the surviving renderer. + expect(sp._webglRenderer).toBe(webglConstructions[0]); + // No orphan: every constructed renderer is the one the host holds. + expect(webglConstructions.filter((r) => r !== sp._webglRenderer)).toHaveLength(0); + }); +}); + +describe('F-05: numeric recompute does not complete after disconnect', () => { + it('a numeric recompute scheduled then disconnected leaves no job running', () => { + const rafQueue: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + + const sp = makeHost(); + sp.selectedAnnotation = 'score'; + + sp._scheduleNumericAnnotationRefresh(); // queues the heavy-recompute RAF + expect(sp._numericRecomputeRunning).toBe(true); // running after schedule + + sp.disconnectedCallback(); // cancel() bumps the job id + cancels the RAF + clears running + expect(sp._numericRecomputeRunning).toBe(false); // teardown cleared the busy state + + // Drain whatever RAF bodies are still queued: the superseded job must bail + // (the cancel bumped the job id), so it neither runs the body nor re-enters + // the running state. (F-46: the removed -end event is now re-characterized via + // the kept busy-state mirror.) + rafQueue.forEach((cb) => cb(0)); + + expect(sp._numericRecomputeRunning).toBe(false); + }); +}); + +describe('F-12: resetZoom transition interrupted on disconnect', () => { + it('disconnectedCallback tears down the interaction controller (interrupts the 750ms transition)', () => { + const sp = makeHost(); + const teardown = vi.fn(); + // Stand in for the B8 PlotInteractionController. Its real teardown() calls + // _svgSelection.interrupt(), which aborts the resetZoom .transition(750). + sp._interaction = { + teardown, + resetZoom: () => {}, + initialize: () => {}, + }; + + sp.disconnectedCallback(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); +}); + +describe('F-16: _commitSelection RAF cancelled on disconnect', () => { + it('a selection committed then disconnected before the RAF fires dispatches nothing', () => { + // Map id -> callback so cancelAnimationFrame can actually remove a pending + // RAF body (mirrors the browser: the disconnect cancel must un-queue it). + const rafQueue = new Map(); + let nextId = 1; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = nextId++; + rafQueue.set(id, cb); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafQueue.delete(id); + }); + + const sp = makeHost(); + const brushEvents: unknown[] = []; + sp.addEventListener('brush-selection', (e) => brushEvents.push(e)); + const clearVisual = vi.fn(); + + // Commit schedules the deferred RAF; disconnect must cancel it before it runs. + sp._commitSelection(['p0', 'p1'], clearVisual); + sp.disconnectedCallback(); + + // Drain whatever survives: the cancelled commit RAF is gone, so nothing fires. + rafQueue.forEach((cb) => cb(0)); + + expect(brushEvents).toHaveLength(0); + expect(sp.selectedProteinIds).not.toEqual(['p0', 'p1']); + }); +}); + +describe('F-21: _renderWebGL is a no-op when the renderer is null', () => { + it('does not throw when _webglRenderer is null', () => { + const sp = makeHost(); + // No firstUpdated ran, so the renderer was never constructed (null). The + // current code dereferences `this._webglRenderer!` unconditionally and + // throws a TypeError; a hardened _renderWebGL bails when the renderer is + // null. `_scales` is a getter (null with no processed data), so + // _getPointsForRendering returns EMPTY_PLOT_DATA before the null deref. + sp._webglRenderer = null; + + expect(() => sp._renderWebGL('plot')).not.toThrow(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.numeric-recompute.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.numeric-recompute.test.ts new file mode 100644 index 00000000..85dee163 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.numeric-recompute.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +// +// F-23 characterization lock: the numeric-recompute stale-job generation guard. +// +// `_scheduleNumericAnnotationRefresh` delegates to the NumericRecomputeRunner, +// which bumps + captures a per-schedule job id, flips the `_numericRecomputeRunning` +// @state mirror to true synchronously, and queues the heavy recompute in a +// requestAnimationFrame. The RAF body bails immediately when its captured job id +// was superseded — so a superseded (older) job runs no body and does NOT clear +// the running state; only the surviving (latest) job's RAF clears it. This locks +// "last-write-wins" for two overlapping schedules. +// +// (F-46) The runner's old public `numeric-recompute-start` / `-end` CustomEvents +// were unconsumed and have been removed; the stale-job guard is now characterized +// via the kept `_numericRecomputeRunning` busy-state mirror. +// +// We queue (do NOT run inline) RAFs via a stubbed requestAnimationFrame so the +// two overlapping schedules both register before either body executes, then +// drain them to exercise the drop. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + _scheduleNumericAnnotationRefresh(): void; + _numericRecomputeRunning: boolean; +}; + +/** + * Real VisualizationData fixture, mirroring makeFamilyData from + * scatter-plot.materialize-cache.test.ts (the plan sketch's `features` / + * `feature_data` / `metadata.dimensions` shape is not a real VisualizationData + * and would make `_getMaterializedData` throw on the missing `annotations` map, + * causing the RAF body to bail before reaching the end event). `score` is a + * numeric column selected as the active annotation; it is intentionally absent + * from `annotations` (the production code reads `annotations[selectedAnnotation]` + * with optional chaining, so this stays valid and triggers the numeric path). + */ +function numericData(): VisualizationData { + const families = ['A', 'A', 'A', 'B', 'B', 'B']; + const colorFor = (v: string) => (v === 'A' ? '#ff0000' : '#00ff00'); + const coords = new Float32Array(families.length * 2); + families.forEach((_, i) => { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + }); + return { + protein_ids: families.map((_, i) => `p${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + }, + numeric_annotation_data: { + score: families.map((_, i) => i), + }, + } as unknown as VisualizationData; +} + +describe('numeric-recompute stale-job guard (F-23 characterization lock)', () => { + let rafQueue: FrameRequestCallback[]; + beforeEach(() => { + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + }); + afterEach(() => vi.unstubAllGlobals()); + const drain = () => { + const q = rafQueue; + rafQueue = []; + q.forEach((cb) => cb(0)); + }; + + it('only the latest of two overlapping schedules clears the running state', () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.data = numericData(); + sp.selectedAnnotation = 'score'; + + sp._scheduleNumericAnnotationRefresh(); // job 1 → queues RAF #1 + sp._scheduleNumericAnnotationRefresh(); // job 2 → bumps id, queues RAF #2 + expect(sp._numericRecomputeRunning).toBe(true); // running, nothing drained yet + + drain(); // RAF#1 sees jobId mismatch → bails (no clear); RAF#2 completes → clears + expect(sp._numericRecomputeRunning).toBe(false); // surviving job cleared the state + }); + + it('the superseded job does not clear the running state before the latest job runs', () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.data = numericData(); + sp.selectedAnnotation = 'score'; + + sp._scheduleNumericAnnotationRefresh(); // job 1 → queues RAF #1 + sp._scheduleNumericAnnotationRefresh(); // job 2 → bumps id, queues RAF #2 + expect(sp._numericRecomputeRunning).toBe(true); // each schedule enters running + + // Drain ONLY the superseded RAF #1: it must bail and leave running untouched. + const stale = rafQueue.shift()!; + stale(0); + expect(sp._numericRecomputeRunning).toBe(true); // superseded job did not clear + + drain(); // latest job completes → clears + expect(sp._numericRecomputeRunning).toBe(false); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.pick.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.pick.test.ts new file mode 100644 index 00000000..f447c0a0 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.pick.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + * + * F-28: hover and click must share ONE hit-test (`pickInteractivePointAt`). + * We stub the quadtree + scales + a single rendered point and assert: + * (a) pickInteractivePointAt returns the interactive in-radius point; + * (b) it returns null for a non-interactive (hidden) point; + * (c) it returns null when the resolved point is outside pointRadius; + * (d) the `15` search radius and `/3` point-radius constants survive. + */ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import type { PlotData, PlotDataPoint, VisualizationData } from '@protspace/utils'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; + +type PickInternals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + hiddenAnnotationValues: string[]; + _plotData: PlotData; + _transform: d3.ZoomTransform; + _quadtreeIndex: { findNearest(x: number, y: number, r: number): number }; + _webglRenderer: { isPointRendered(id: string): boolean } | null; + _cachedScales: { x(v: number): number; y(v: number): number } | null; + _scalesCacheDeps: unknown; + pickInteractivePointAt(mouseX: number, mouseY: number): PlotDataPoint | null; +}; + +function makeData(): VisualizationData { + return { + protein_ids: ['p0', 'p1'], + projections: [{ name: 'umap', data: new Float32Array([0, 0, 50, 50]), dimension: 2 }], + annotations: { + fam: { values: ['A', 'B'], colors: ['#f00', '#0f0'], shapes: ['circle', 'circle'] }, + }, + annotation_data: { fam: [[0], [1]] }, + } as unknown as VisualizationData; +} + +function makePickScatter(): PickInternals { + const sp = document.createElement('protspace-scatterplot') as PickInternals; + sp.data = makeData(); + sp.selectedAnnotation = 'fam'; + sp._plotData = { + length: 2, + xs: new Float32Array([0, 50]), + ys: new Float32Array([0, 50]), + zs: null, + originalIndices: null, + proteinIds: sp.data.protein_ids, + } as unknown as PlotData; + sp._transform = d3.zoomIdentity; // identity: dataX===mouseX, searchRadius===15 + sp._webglRenderer = { isPointRendered: () => true }; + // Inject identity scales so scales.x(0)===0 / scales.y(0)===0 (the fixture's + // documented "dataX===mouseX" assumption). _scales is a cached getter keyed on + // _scalesCacheDeps; priming both backing fields with matching deps makes the + // getter skip recompute and return this identity pair verbatim. + sp._cachedScales = { x: (v: number) => v, y: (v: number) => v }; + sp._scalesCacheDeps = { + plotDataLength: sp._plotData.length, + width: 800, + height: 600, + margin: { top: 40, right: 40, bottom: 40, left: 40 }, + }; + return sp; +} + +describe('F-28 pickInteractivePointAt (shared hover/click hit-test)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns the interactive in-radius point at the cursor', () => { + const sp = makePickScatter(); + sp._quadtreeIndex.findNearest = () => 0; // slot 0 (p0 at 0,0) + const pt = sp.pickInteractivePointAt(0, 0); + expect(pt?.id).toBe('p0'); + }); + + it('passes searchRadius 15 / transform.k to findNearest', () => { + const sp = makePickScatter(); + const spy = vi.fn().mockReturnValue(-1); + sp._quadtreeIndex.findNearest = spy; + sp.pickInteractivePointAt(10, 10); + expect(spy).toHaveBeenCalledWith(10, 10, 15); // k=1 + }); + + it('returns null for a non-interactive (hidden) point', () => { + const sp = makePickScatter(); + sp.hiddenAnnotationValues = ['A']; // p0 → opacity 0 → non-interactive + sp._quadtreeIndex.findNearest = () => 0; + expect(sp.pickInteractivePointAt(0, 0)).toBeNull(); + }); + + it('returns null when the resolved point is outside pointRadius', () => { + const sp = makePickScatter(); + sp._quadtreeIndex.findNearest = () => 0; // nearest is p0 at (0,0)... + // ...but query far from it; identity scales => distance >> sqrt(size)/3 + expect(sp.pickInteractivePointAt(40, 40)).toBeNull(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.scales-cache.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.scales-cache.test.ts new file mode 100644 index 00000000..36b7210e --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.scales-cache.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// +// F-22 characterization lock for the `_scales` cache. +// +// `_scales` (a getter) recomputes ONLY when plotDataLength / width / height / +// margin change. A same-length coordinate swap (switching projection or +// projectionPlane) changes none of those deps, so the SOLE guard that forces a +// fresh ScalePair is the `_processData() -> _invalidateScalesCache()` call. +// This test drives `_processData()` directly (the filter-render.test.ts pattern, +// element created via createElement and never appended so Lit's +// connectedCallback / WebGL init never runs) and asserts that a same-length +// projection swap yields a fresh ScalePair whose x-domain reflects the new, +// wider coordinate extent. +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; +import type { ScalePair } from './webgl/types'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + selectedProjectionIndex: number; + _processData(): void; + readonly _scales: ScalePair | null; +}; + +// Two projections, SAME length (3 points), DIFFERENT coordinate extents. +// Fixture shape mirrors makeFamilyData in scatter-plot.materialize-cache.test.ts: +// projections[].data + dimension, annotations + annotation_data (NOT the +// features/feature_data / metadata.dimensions names from the plan sketch). +function twoProjectionData(): VisualizationData { + const families = ['A', 'A', 'B']; + return { + protein_ids: ['p0', 'p1', 'p2'], + projections: [ + { name: 'proj_a', data: new Float32Array([0, 0, 1, 1, 2, 2]), dimension: 2 }, + { name: 'proj_b', data: new Float32Array([0, 0, 50, 50, 100, 100]), dimension: 2 }, + ], + annotations: { + fam: { + values: families, + colors: families.map((v) => (v === 'A' ? '#f00' : '#0f0')), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + }, + } as unknown as VisualizationData; +} + +describe('_scales cache invalidation (F-22 characterization lock)', () => { + it('switching projection (same length, new coords) yields a fresh ScalePair with the new domain', () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.data = twoProjectionData(); + sp.selectedAnnotation = 'fam'; + sp.selectedProjectionIndex = 0; + sp._processData(); + const scalesA = sp._scales!; + const domainA = scalesA.x.domain(); + + sp.selectedProjectionIndex = 1; + sp._processData(); // _invalidateScalesCache() must fire here + const scalesB = sp._scales!; + const domainB = scalesB.x.domain(); + + expect(scalesB).not.toBe(scalesA); // cache miss -> recomputed + expect(domainB).not.toEqual(domainA); // domain reflects proj_b's wider extent + expect(Math.max(...domainB)).toBeGreaterThan(Math.max(...domainA)); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.style-getters-cache.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.style-getters-cache.test.ts new file mode 100644 index 00000000..87172932 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.style-getters-cache.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** + * F-26 characterization lock — `_styleGettersCache` invalidation lifecycle. + * + * `_getStyleGetters` (scatter-plot.ts L2241) rebuilds the cached getters ONLY + * when `_styleGettersCache` is null. The documented invalidation entry points + * null it: + * - `_handleColorMappingChange` (L499) — legend color/shape mapping change + * - `_handleZOrderChange` (L481) — legend z-order change + * - `_refreshSelectedAnnotationValues` (L826) — selected-annotation switch + * + * NOTE (plan B7/F-26): `_processData` itself does NOT null `_styleGettersCache` + * (verified against the unmodified tree — the only nullers are L481/499/826/1168). + * The audit cites L826 in `_refreshSelectedAnnotationValues`, so the + * selected-annotation case is driven through that real nulling path rather than + * through `_processData`. + * + * The lock asserts: while nothing invalidates, repeat `_getStyleGetters()` + * returns the SAME instance; each documented entry point yields a FRESH one. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + _processData(): void; + _refreshSelectedAnnotationValues(dataToUse: VisualizationData): void; + _getStyleGetters(): object; + _handleColorMappingChange(e: Event): void; +}; + +/** + * Categorical fixture mirroring the real VisualizationData shape used by the + * neighbor locks (scatter-plot.materialize-cache.test.ts): `annotations` / + * `annotation_data` (NOT `features`/`feature_data`), projection `dimension: 2`. + */ +function famData(): VisualizationData { + const families = ['A', 'A', 'B']; + const colorFor = (v: string) => (v === 'A' ? '#f00' : '#0f0'); + return { + protein_ids: ['p0', 'p1', 'p2'], + projections: [{ name: 'umap', data: new Float32Array([0, 0, 1, 1, 2, 2]), dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + }, + } as unknown as VisualizationData; +} + +describe('_styleGettersCache invalidation lifecycle (F-26 characterization lock)', () => { + function primed(): Internals { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.data = famData(); + sp.selectedAnnotation = 'fam'; + sp._processData(); + return sp; + } + + it('repeat _getStyleGetters() returns the SAME instance while nothing invalidates', () => { + const sp = primed(); + expect(sp._getStyleGetters()).toBe(sp._getStyleGetters()); + }); + + it('a colormapping change forces a FRESH getter instance', () => { + const sp = primed(); + const before = sp._getStyleGetters(); + sp._handleColorMappingChange( + new CustomEvent('legend-colormapping-change', { + detail: { colorMapping: { A: '#00f', B: '#0f0' }, shapeMapping: {}, colorOnly: true }, + }), + ); + expect(sp._getStyleGetters()).not.toBe(before); + }); + + it('a selectedAnnotation refresh (via _refreshSelectedAnnotationValues) forces a fresh getter instance', () => { + const sp = primed(); + const before = sp._getStyleGetters(); + // _processData does NOT null the cache; the real nulling path for a + // selected-annotation switch is _refreshSelectedAnnotationValues (L826). + sp._refreshSelectedAnnotationValues(sp.data); + expect(sp._getStyleGetters()).not.toBe(before); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.test.ts index 5360b315..a8ce6e3f 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.test.ts @@ -1,19 +1,25 @@ /** * @vitest-environment jsdom * - * Lasso / brush selection: the slot→id resolution is shared by both handlers via - * the `_slotsToInteractiveIds` helper. These tests drive the helper through the - * public selection handlers (`_handleLassoEnd` / `_handleBrushEnd`) and assert - * the dispatched `brush-selection` event carries ONLY the interactive ids, in - * slot order, resolving originalIndex → proteinId correctly in both the - * identity (originalIndices === null) and explicit-mapping cases. + * Lasso / brush selection: the slot→id resolution is shared by both paths via + * the `_slotsToInteractiveIds` helper. The lasso cases drive the live + * PlotInteractionController (via the element's `_interactionHost()` bridge) and + * the brush case drives the `_handleBrushEnd` host shim; both assert the + * dispatched `brush-selection` event carries ONLY the interactive ids, in slot + * order, resolving originalIndex → proteinId correctly in both the identity + * (originalIndices === null) and explicit-mapping cases. * * Construct the element via createElement without appending it (so Lit's * connectedCallback / WebGL init never runs — same approach as - * scatter-plot.isolation.test.ts) and call the private handlers directly. + * scatter-plot.isolation.test.ts) and drive the controller / private handler + * directly through the host bridge. */ import { vi, describe, it, expect, afterEach } from 'vitest'; import type { PlotData, VisualizationData } from '@protspace/utils'; +import { + PlotInteractionController, + type PlotInteractionHost, +} from './interaction/plot-interaction-controller'; vi.hoisted(() => { if (!('ResizeObserver' in globalThis)) { @@ -70,12 +76,25 @@ type SelectionInternals = HTMLElement & { selectedProteinIds: string[]; _plotData: PlotData; _quadtreeIndex: QuadtreeStub; - _isLassoing: boolean; - _lassoVertices: Array<[number, number]>; - _handleLassoEnd(event: PointerEvent): void; + _interactionHost(): PlotInteractionHost; _handleBrushEnd(event: { selection: [[number, number], [number, number]] | null }): void; }; +/** + * Drive the live lasso path through the controller using the element's real + * host bridge: begin + extend to build a >=3-vertex polygon, then endLasso() + * resolves slots → ids via host.queryByPolygon/resolveSlotsToIds and dispatches + * through host.onSelect (_commitSelection). The controller is not initialize()'d, + * so no SVG groups exist and the lasso path stays null (endLasso handles that). + */ +function runLassoSelection(sp: SelectionInternals) { + const controller = new PlotInteractionController(sp._interactionHost()); + controller.beginLasso([0, 0]); + controller.extendLasso([10, 0]); + controller.extendLasso([10, 10]); + controller.endLasso(); +} + /** * Build a scatter element with a 6-point SoA `_plotData`. `originalIndices` * controls the slot→originalIndex mapping (null = identity). @@ -125,17 +144,7 @@ describe('scatter-plot lasso/brush selection (slot → interactive id)', () => { sp.addEventListener('brush-selection', (e) => events.push(e as CustomEvent)); stubSyncRaf(); - sp._isLassoing = true; - sp._lassoVertices = [ - [0, 0], - [10, 0], - [10, 10], - ]; - sp._handleLassoEnd({ - preventDefault() {}, - pointerId: 1, - target: null, - } as unknown as PointerEvent); + runLassoSelection(sp); expect(events).toHaveLength(1); expect(events[0].detail.proteinIds).toEqual(['p0', 'p1', 'p2']); @@ -177,17 +186,7 @@ describe('scatter-plot lasso/brush selection (slot → interactive id)', () => { sp.addEventListener('brush-selection', (e) => events.push(e as CustomEvent)); stubSyncRaf(); - sp._isLassoing = true; - sp._lassoVertices = [ - [0, 0], - [10, 0], - [10, 10], - ]; - sp._handleLassoEnd({ - preventDefault() {}, - pointerId: 1, - target: null, - } as unknown as PointerEvent); + runLassoSelection(sp); expect(events).toHaveLength(1); // Interactive (family B) at slots 0,1,2 → originalIndex 5,4,3 → p5,p4,p3, @@ -218,3 +217,33 @@ describe('scatter-plot lasso/brush selection (slot → interactive id)', () => { expect(sp.selectedProteinIds).toEqual([]); }); }); + +describe('scatter-plot WebGL context-loss recovery (detached guard)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('F-10: recovery microtask does not rebuild renderer after disconnect', async () => { + type RecoveryInternals = HTMLElement & { + updateComplete: Promise; + _updateSizeAndRender(): void; + _handleWebglContextLost(): void; + }; + const sp = document.createElement('protspace-scatterplot') as RecoveryInternals; + // Connect so Lit's update lifecycle (and updateComplete) actually runs, + // then disconnect synchronously after firing the loss event but BEFORE the + // recovery microtask resolves. This is the exact route-change / GPU-recycle + // sequence the finding targets: loss -> detach -> microtask. A disconnected + // element must NOT reconstruct a fresh WebGLRenderer (fresh context + + // listeners) on a detached renderRoot. + document.body.appendChild(sp); + await sp.updateComplete; + const spy = vi.spyOn(sp, '_updateSizeAndRender'); + sp._handleWebglContextLost(); // schedules the recovery microtask + sp.remove(); // isConnected === false before the microtask resolves + await sp.updateComplete; + await Promise.resolve(); // flush the .then microtask + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.tooltip-measure.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.tooltip-measure.test.ts new file mode 100644 index 00000000..2dacd31d --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.tooltip-measure.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeAll } from 'vitest'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +type Internals = HTMLElement & { + _tooltipData: unknown; + _tooltipHeight: number | null; + _tooltipMeasureToken: number; + renderRoot: { querySelector(s: string): unknown }; + _measureTooltipHeight(): void; +}; + +// A controllable deferred standing in for the child tooltip's updateComplete. +function makeDeferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function withStubChild(sp: Internals, height: number, ready: Promise) { + const child = { offsetHeight: height, updateComplete: ready } as unknown; + (sp as unknown as { renderRoot: { querySelector: () => unknown } }).renderRoot = { + querySelector: (s: string) => (s.includes('protein-tooltip') ? child : null), + }; +} + +describe('tooltip-height async measurement race (F-25 characterization lock)', () => { + it('a newer hover (token bump) suppresses the stale measure write', async () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp._tooltipData = { id: 'p0' }; + sp._tooltipHeight = null; + const d = makeDeferred(); + withStubChild(sp, 42, d.promise); + sp._measureTooltipHeight(); // captures token T, awaits d.promise + sp._tooltipMeasureToken++; // a newer hover bumps the token while we wait + d.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(sp._tooltipHeight).toBeNull(); // stale measure must NOT write 42 + }); + + it('clearing _tooltipData before resolve suppresses the write', async () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp._tooltipData = { id: 'p0' }; + sp._tooltipHeight = null; + const d = makeDeferred(); + withStubChild(sp, 42, d.promise); + sp._measureTooltipHeight(); + sp._tooltipData = null; // tooltip cleared mid-flight + d.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(sp._tooltipHeight).toBeNull(); + }); + + it('a single in-flight measure with a matching token writes the height', async () => { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp._tooltipData = { id: 'p0' }; + sp._tooltipHeight = null; + const d = makeDeferred(); + withStubChild(sp, 42, d.promise); + sp._measureTooltipHeight(); + d.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(sp._tooltipHeight).toBe(42); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.transform-reactivity.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.transform-reactivity.test.ts new file mode 100644 index 00000000..b5b306ac --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.transform-reactivity.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment jsdom + * + * F-48 characterization: writing `_transform` must NOT schedule a Lit reactive + * update. Pre-change (`@state() _transform`) Lit installs a reactive accessor, + * so a write calls `requestUpdate('_transform', old)`, enqueues an update, and + * once that update flushes `updated()` runs and (because `_transform` is not in + * `_reconcileSelectionOverlays`'s selectionKeys) calls `_renderPlot()` every + * zoom frame. Post-change (plain field) a write is inert to Lit: it does NOT + * call `requestUpdate`, schedules no update, and triggers no `_renderPlot()`. + * + * The load-bearing signal: a `_transform` write does not call `requestUpdate`, + * the single Lit hook that schedules an update (and hence the downstream + * `updated()` -> `_renderPlot()` pass for this non-selection key). Spying + * `requestUpdate` pins exactly the cause F-48 removes, with no downstream noise. + * + * RED/GREEN status on the UNMODIFIED tree: RED by design — today `_transform` is + * `@state`, so the write calls `requestUpdate`. It goes GREEN once F-48 demotes + * `_transform` to a plain (non-reactive) field. + * + * The element is constructed via `createElement` and NEVER appended: a reactive + * `@state` setter calls `requestUpdate` synchronously on write, so the signal is + * observable without connecting and without any `updateComplete` await (which on + * an un-appended element never resolves: its first update is never enqueued). + * Staying off the DOM also avoids the connect-time one-shot RAF startup render, + * which a connected element runs on the next awaited tick regardless of any + * `_transform` write and which would otherwise mask a `_renderPlot`-based assert. + */ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import * as d3 from 'd3'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; + +type TransformInternals = HTMLElement & { + _transform: d3.ZoomTransform; + requestUpdate(name?: PropertyKey, oldValue?: unknown): void; +}; + +describe('F-48 _transform is not a reactive Lit property', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('writing _transform does not call requestUpdate (no Lit update scheduled)', () => { + const sp = document.createElement('protspace-scatterplot') as TransformInternals; + // Not appended: a reactive @state setter still calls requestUpdate + // synchronously on write, so the spy captures the scheduling hook without + // any connection / updateComplete await (which would hang on an un-appended + // element whose first update is never enqueued). + const reqSpy = vi.spyOn(sp, 'requestUpdate'); + + sp._transform = d3.zoomIdentity.translate(40, 25).scale(2); + + // A reactive @state write calls requestUpdate('_transform', old); a plain + // field write does not call requestUpdate at all. + expect(reqSpy).not.toHaveBeenCalled(); + // The value is still readable by the pull-based getter closures. + expect(sp._transform.k).toBe(2); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index fa11bee0..024eb128 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -8,38 +8,51 @@ import type { PlotDataPoint, ScatterplotConfig, NumericAnnotationDisplaySettingsMap, - AnnotationData, TooltipView, } from '@protspace/utils'; import { DataProcessor, buildTooltipView, materializeVisualizationData, - sliceAnnotationData, + sliceVisualizationDataByIndices, EMPTY_PLOT_DATA, clonePlotData, plotDataId, materializePlotDataPoint, gatherPlotData, } from '@protspace/utils'; +import type { ScalePair } from '@protspace/utils'; import type { LegendSortMode } from '../legend/types'; +import { + isLegendColorMappingDetail, + isLegendZOrderDetail, + type LegendColorMappingChangeEvent, + type LegendZOrderChangeEvent, +} from '../legend/legend-mapping-events'; import { scatterplotStyles } from './scatter-plot.styles'; -import './projection-metadata'; -import './protspace-tips'; -import './protein-tooltip'; +import './projection-metadata/projection-metadata'; +import './tooltips/protspace-tips'; +import './tooltips/protein-tooltip'; import { DEFAULT_CONFIG } from './config'; -import { createStyleGetters } from './style-getters'; -import { computeVisibilityModel } from './visibility-model'; -import type { VisibilityModel } from './visibility-model'; +import { createStyleGetters } from './styling/style-getters'; +import { computeVisibilityModel } from './styling/visibility-model'; +import type { VisibilityModel } from './styling/visibility-model'; import { MAX_POINTS_DIRECT_RENDER, WebGLRenderer } from './webgl'; -import { QuadtreeIndex } from './quadtree-index'; -import { getDuplicateStackKey } from './duplicate-stack-helpers'; -import { estimateTooltipHeight } from './tooltip-height-estimate'; +import { QuadtreeIndex } from './interaction/quadtree-index'; +import { computeViewportWindow, buildViewKey } from './duplicate-stacks/duplicate-stack-viewport'; +import { DuplicateStackOverlayController } from './duplicate-stacks/duplicate-stack-overlay-controller'; +import { estimateTooltipHeight } from './tooltips/tooltip-height-estimate'; +import { computeTooltipStyle, TOOLTIP_FALLBACK_HEIGHT } from './tooltips/tooltip-position'; +import { NumericRecomputeRunner } from './styling/numeric-recompute-runner'; import { WebglRenderPerfRunner, type PerfDatasetInfo, type RenderWebGLTrigger, } from './webgl-render-perf'; +import { + PlotInteractionController, + type PlotInteractionHost, +} from './interaction/plot-interaction-controller'; // Visualization is only needed for viewport culling on very large datasets. // For <= MAX_POINTS_DIRECT_RENDER we can render the full set once and then pan/zoom via uniforms @@ -47,15 +60,15 @@ import { const VIRTUALIZATION_THRESHOLD = MAX_POINTS_DIRECT_RENDER; const VIRTUALIZATION_PADDING = 100; -// Duplicate stack UI performance tuning (target: M1 MacBook + Chrome) -const DUPLICATE_BADGES_MAX_VISIBLE = 800; -const DUPLICATE_BADGES_VIEWPORT_PADDING = 60; -const DUPLICATE_BADGES_UPDATE_DEBOUNCE_MS = 120; -const DUPLICATE_STACK_COMPUTE_CHUNK_SIZE = 25_000; -type ScalePair = { - x: d3.ScaleLinear; - y: d3.ScaleLinear; -}; +// Hit-test tuning (shared by hover + click). Search radius is in screen px and +// is divided by the zoom factor so the data-space radius stays constant; the +// point radius is derived from point size (sqrt(size)/3 matches the WebGL draw). +const HIT_TEST_SEARCH_RADIUS_PX = 15; +const POINT_RADIUS_SIZE_DIVISOR = 3; + +/** Default number of bins for numeric→categorical materialization. Mirrors + * materializeVisualizationData's `defaultBinCount = 10` default. */ +const DEFAULT_NUMERIC_BIN_COUNT = 10; /** * Memoization key for `_getVisibilityModel`. Stored as a plain struct so each @@ -115,22 +128,19 @@ export class ProtspaceScatterplot extends LitElement { } | null = null; @state() private _tooltipHeight: number | null = null; @state() private _mergedConfig = DEFAULT_CONFIG; - @state() private _transform = d3.zoomIdentity; + // Plain field, NOT @state: render() never reads _transform (it drives the + // canvas imperatively via the zoom RAF + d3 attr()), so reactivity here only + // caused a redundant per-frame updated()/_renderPlot() pass (F-48). The + // getter closures passed to WebGLRenderer and the duplicate-overlay/hit-test + // reads are pull-based and keep working unchanged. + private _transform = d3.zoomIdentity; @state() private _isolationHistory: string[][] = []; @state() private _isolationMode = false; - @state() private _zOrderMapping: Record | null = null; - @state() private _colorMapping: Record | null = null; - @state() private _shapeMapping: Record | null = null; + private _zOrderMapping: Record | null = null; + private _colorMapping: Record | null = null; + private _shapeMapping: Record | null = null; @state() private _canvasKey = 0; - @state() private _numericRecomputeState: { - running: boolean; - annotation: string | null; - startedAt: number | null; - } = { - running: false, - annotation: null, - startedAt: null, - }; + @state() private _numericRecomputeRunning = false; // Queries @query('canvas') private _canvas?: HTMLCanvasElement; @@ -140,19 +150,11 @@ export class ProtspaceScatterplot extends LitElement { // Internal private _quadtreeIndex: QuadtreeIndex = new QuadtreeIndex(); private resizeObserver: ResizeObserver; - private _zoom: d3.ZoomBehavior | null = null; - private _svgSelection: d3.Selection | null = null; - private _mainGroup: d3.Selection | null = null; - private _brushGroup: d3.Selection | null = null; - private _overlayGroup: d3.Selection | null = null; - private _brush: d3.BrushBehavior | null = null; - private _isBrushing = false; - private _lassoVertices: Array<[number, number]> = []; - private _lassoPath: SVGPathElement | null = null; - private _isLassoing = false; - private _lassoRafId: number | null = null; + // d3 zoom/brush/lasso lifecycle, the three SVG groups, and the zoom/lasso RAF + // loops live in the controller (F-07). Constructed in firstUpdated. Event + // dispatch + the transform field stay on the host (INV-03/INV-05, F-48). + private _interaction: PlotInteractionController | null = null; private _webglRenderer: WebGLRenderer | null = null; - private _zoomRafId: number | null = null; private _styleSig: string | null = null; private _styleGettersCache: ReturnType | null = null; // Deliberately NOT cleared to `null` by event handlers (unlike _styleGettersCache, @@ -189,7 +191,12 @@ export class ProtspaceScatterplot extends LitElement { fadedOpacity: number; } | null = null; private _quadtreeRebuildRafId: number | null = null; + // F-17: advanced on every quadtree rebuild and folded into the virtualization + // cacheKey so a rebuild forces a miss even when the transform is unchanged + // (otherwise un-hidden points stay missing until a pan/zoom changes the key). + private _quadtreeGeneration = 0; private _hoverRaf: number | null = null; + private _commitSelectionRafId: number | null = null; private _pendingHover: { event: MouseEvent; mouseX: number; mouseY: number } | null = null; private _visiblePlotData: PlotData = EMPTY_PLOT_DATA; private _scratchPoint: PlotDataPoint = { id: '', x: 0, y: 0, originalIndex: 0 }; @@ -203,35 +210,24 @@ export class ProtspaceScatterplot extends LitElement { margin: { top: number; right: number; bottom: number; left: number }; } | null = null; - // Duplicate stacks (exact same coordinates) - private _duplicateStacks: Array<{ - key: string; - x: number; - y: number; - px: number; - py: number; - points: PlotDataPoint[]; - }> = []; - private _duplicateStackByKey = new Map< - string, - { key: string; x: number; y: number; px: number; py: number; points: PlotDataPoint[] } - >(); - private _pointIdToDuplicateStackKey = new Map(); - private _expandedDuplicateStackKey: string | null = null; - // Anchor position the user clicked to open the current spider. Stored separately - // from the per-viewport stack object so it survives the rebuild that happens on - // every pan/zoom (see _applyExpandedSpiderAnchor). - private _expandedSpiderAnchor: { stackKey: string; x: number; y: number } | null = null; - private _isDuplicateStackUIEnabled(): boolean { - return !!this._mergedConfig.enableDuplicateStackUI; - } - private _duplicateOverlayDebounceId: number | null = null; - private _duplicateStacksCacheKey: string | null = null; - private _duplicateStacksComputeJobId = 0; - private _duplicateStacksComputing = false; - // Spiderfy interaction can lose native 'click' due to d3.zoom gesture handling in some browsers. - // Track press/release to reliably treat spiderfy node interactions like normal point clicks. - private _spiderfyPressByPointerId = new Map(); + // Duplicate-stack / spiderfy / badge overlay subsystem (state + schedulers + + // chunked compute + badge canvas + spiderfy SVG layer). Event dispatch stays + // on the host via the onPointActivate/onHover/onHoverEnd callbacks (INV-05/INV-03). + private _dupOverlay = new DuplicateStackOverlayController({ + getOverlayGroup: () => this._interaction?.overlayGroup ?? null, + getBadgesCanvas: () => this._badgesCanvas, + getTransform: () => this._transform, + getConfig: () => this._mergedConfig, + getScales: () => this._scales, + getPlotData: () => this._plotData, + getQuadtree: () => this._quadtreeIndex, + isEnabled: () => !!this._mergedConfig.enableDuplicateStackUI, + isSelectionMode: () => this.selectionMode, + getColor: (p) => this._getColors(p)[0] ?? '#888888', + onPointActivate: (e, p) => this._handleClick(e, p), + onHover: (e, p) => this._handleMouseOver(e, p), + onHoverEnd: () => this._clearHoverState(), + }); private _webglRenderPerf = new WebglRenderPerfRunner(this); @@ -240,6 +236,11 @@ export class ProtspaceScatterplot extends LitElement { // LitElement has finished rendering. private _tooltipMeasureToken = 0; + // Monotonically-increasing token used to invalidate a pending WebGL context-loss + // recovery microtask when a newer loss supersedes it, or when the element detaches + // before updateComplete resolves (route change is a common GPU-recycle trigger). + private _webglRecoveryToken = 0; + // Track data reference to detect projection-only changes (same data object, different projection index). private _lastDataRef: VisualizationData | null = null; // Whether the current _plotData was built with a cull (filter or isolation). @@ -251,6 +252,17 @@ export class ProtspaceScatterplot extends LitElement { private _lastMaterializedNumericValues: Array | null = null; private _materializedDataCacheKey: string | null = null; private _materializedDataCache: VisualizationData | null = null; + // F-40: memoize the filtered display-data rebuild. Keyed by reference on the + // same inputs the filtered slice depends on so repeated reads with unchanged + // inputs reuse the prior VisualizationData instead of reallocating. + private _filteredDisplayCache: VisualizationData | null = null; + private _filteredDisplayCacheDeps: { + materialized: VisualizationData | null; + filteredProteinIds: string[]; + filtersActive: boolean; + selectedProjectionIndex: number; + projectionPlane: 'xy' | 'xz' | 'yz'; + } | null = null; // Fast-path keys for _getMaterializedData: avoid JSON.stringify on the hot // per-point path (getOpacity -> visibility model -> materialized data). These // mirror the JSON cacheKey's reference/primitive inputs so a hit can return @@ -261,7 +273,14 @@ export class ProtspaceScatterplot extends LitElement { private _lastMaterializedSelectedSettings: | NumericAnnotationDisplaySettingsMap[string] | undefined = undefined; - private _numericRecomputeJobId = 0; + private _numericRecompute = new NumericRecomputeRunner({ + hasData: () => !!this.data, + getSelectedAnnotation: () => this.selectedAnnotation, + setRunning: (running) => { + this._numericRecomputeRunning = running; + }, + runRecompute: () => this._runNumericRecomputeBody(), + }); // Computed properties with caching private get _scales(): ScalePair | null { @@ -285,7 +304,7 @@ export class ProtspaceScatterplot extends LitElement { config.width, config.height, config.margin, - ) as ScalePair | null; + ); this._cachedScales = computedScales; this._scalesCacheDeps = { plotDataLength: this._plotData.length, @@ -310,9 +329,7 @@ export class ProtspaceScatterplot extends LitElement { const selectedNumericValues = this.selectedAnnotation ? sourceData.numeric_annotation_data?.[this.selectedAnnotation] : undefined; - const selectedNumericValuesCacheRef = this.selectedAnnotation - ? (this.data.numeric_annotation_data?.[this.selectedAnnotation] ?? null) - : null; + const selectedNumericValuesCacheRef = selectedNumericValues ?? null; const selectedNumericSettings = this.selectedAnnotation ? this.numericAnnotationSettings?.[this.selectedAnnotation] : undefined; @@ -361,7 +378,7 @@ export class ProtspaceScatterplot extends LitElement { this._materializedDataCache = materializeVisualizationData( sourceData, this.numericAnnotationSettings, - 10, + DEFAULT_NUMERIC_BIN_COUNT, this.selectedAnnotation, ); this._lastMaterializedSource = this.data; @@ -377,6 +394,17 @@ export class ProtspaceScatterplot extends LitElement { return new Set(this.filteredProteinIds); } + /** INV-11: the exact set of reactive inputs that affect rendered geometry. */ + private _geometryInputsChanged(changed: Map): boolean { + return ( + changed.has('data') || + changed.has('filteredProteinIds') || + changed.has('filtersActive') || + changed.has('selectedProjectionIndex') || + changed.has('projectionPlane') + ); + } + constructor() { super(); this.resizeObserver = new ResizeObserver(() => this._updateSizeAndRender()); @@ -392,10 +420,40 @@ export class ProtspaceScatterplot extends LitElement { this._webglRenderer?.destroy(); this._webglRenderer = null; this._canvasKey += 1; + const token = ++this._webglRecoveryToken; this.requestUpdate(); - void this.updateComplete.then(() => this._updateSizeAndRender()); + void this.updateComplete.then(() => { + if (token !== this._webglRecoveryToken || !this.isConnected) return; + this._updateSizeAndRender(); + }); }; + /** + * F-35/F-11: single construction point for the WebGL renderer. Both firstUpdated + * and the lazy _updateSizeAndRender path route through here so the renderer is + * built exactly once (firstUpdated previously orphaned the renderer that + * _updateSizeAndRender had just created). Requires _canvas to be present. + */ + private _createWebglRenderer() { + if (!this._canvas) return; + this._webglRenderer = new WebGLRenderer( + this._canvas, + () => this._scales, + () => this._transform, + () => this._mergedConfig, + { + getColors: (p: PlotDataPoint) => this._getColors(p), + getPointSize: (p: PlotDataPoint) => this._getPointSize(p), + getOpacity: (p: PlotDataPoint) => this._getOpacity(p), + getDepth: (p: PlotDataPoint) => this._getDepth(p), + getShape: (p: PlotDataPoint) => this._getPointShape(p), + }, + this._handleWebglContextLost, + ); + this._updateStyleSignature(); + this._webglRenderer.setStyleSignature(this._styleSig); + } + connectedCallback() { super.connectedCallback(); this.resizeObserver.observe(this); @@ -414,25 +472,23 @@ export class ProtspaceScatterplot extends LitElement { cancelAnimationFrame(this._quadtreeRebuildRafId); this._quadtreeRebuildRafId = null; } - if (this._zoomRafId !== null) { - cancelAnimationFrame(this._zoomRafId); - this._zoomRafId = null; - } if (this._hoverRaf !== null) { cancelAnimationFrame(this._hoverRaf); this._hoverRaf = null; } + if (this._commitSelectionRafId !== null) { + cancelAnimationFrame(this._commitSelectionRafId); + this._commitSelectionRafId = null; + } this._pendingHover = null; - this._cancelDuplicateOverlayDebounce(); - this._cancelDuplicateStackCompute(); - this._clearDuplicateBadgesCanvas(); + this._numericRecompute.cancel(); + this._dupOverlay.cancelDebounce(); + this._dupOverlay.cancelCompute(); + this._dupOverlay.clearBadges(); this._webglRenderer?.destroy(); - if (this._brush) { - this._brush.on('start', null).on('end', null); - this._brush = null; - this._isBrushing = false; - } - this._cleanupLasso(); + // Cancels the zoom/lasso RAFs, interrupts the reset transition, and tears + // down the d3 brush + lasso (F-07). + this._interaction?.teardown(); super.disconnectedCallback(); this.removeEventListener('legend-zorder-change', this._handleZOrderChange); @@ -475,14 +531,16 @@ export class ProtspaceScatterplot extends LitElement { }; private _handleZOrderChange = (event: Event) => { - const customEvent = event as CustomEvent; - this._zOrderMapping = customEvent.detail.zOrderMapping; + const { detail } = event as LegendZOrderChangeEvent; + if (!isLegendZOrderDetail(detail)) return; // F-19: skip rather than overwrite GPU state with undefined + this._zOrderMapping = detail.zOrderMapping; // z-order affects GPU depth; force a fresh style getter cache so getDepth sees the new mapping this._styleGettersCache = null; if (this._plotData.length > 0) { - // Z-order mapping changed but coordinates didn't — ask the renderer to - // re-sort by depth without invalidating the position cache. + // Z-order mapping changed but coordinates didn't — re-sort by depth without + // invalidating the position cache. Single render path (F-31): these fields are + // plain (not @state), so updated()'s catch-all never fires a second render. this._webglRenderer?.invalidateDepthOrder(); this._webglRenderer?.invalidateStyleCache(); this._renderPlot(); @@ -490,33 +548,51 @@ export class ProtspaceScatterplot extends LitElement { }; private _handleColorMappingChange = (event: Event) => { - const customEvent = event as CustomEvent; - this._colorMapping = customEvent.detail.colorMapping; - this._shapeMapping = customEvent.detail.shapeMapping; - const colorOnly = customEvent.detail.colorOnly ?? false; + const { detail } = event as LegendColorMappingChangeEvent; + if (!isLegendColorMappingDetail(detail)) return; // F-19 + this._colorMapping = detail.colorMapping; + this._shapeMapping = detail.shapeMapping; + const colorOnly = detail.colorOnly ?? false; // Force fresh style getters to use new color/shape mapping this._styleGettersCache = null; - // Trigger render (z-order is handled in WebGL depth; avoid CPU-sorting on every zoom/pan) if (this._plotData.length > 0) { - // For color-only changes, we don't need to invalidate positions or re-sort points - // Only invalidate style cache to update colors + // INV-08: color-only changes skip depth re-sort + virtualization invalidation. if (!colorOnly) { - // Z-order mapping may have changed; ask the renderer to re-sort by depth - // without invalidating the position cache. this._webglRenderer?.invalidateDepthOrder(); this._invalidateVirtualizationCache(); } this._webglRenderer?.invalidateStyleCache(); - this._renderPlot(); + this._renderPlot(); // single render path (F-31) } }; updated(changedProperties: Map) { - // When new data is loaded (or projection index changes), ensure the selection is valid. - // This prevents a blank plot when switching from a dataset with many projections/annotations - // to one with only a single projection/annotation. + this._reconcileSelectionDefaults(changedProperties); + this._reconcileFilterOnDataSwap(changedProperties); + this._reprocessGeometryIfNeeded(changedProperties); + if ( + changedProperties.has('numericAnnotationSettings') && + !this._geometryInputsChanged(changedProperties) && + this.data + ) { + this._scheduleNumericAnnotationRefresh(); + } + this._reconcileConfigMerge(changedProperties); + this._rebuildStyleAndSignature(changedProperties); + this._reconcileSelectionMode(changedProperties); + this._refreshStyleGettersCache(changedProperties); + this._reconcileSelectionOverlays(changedProperties); + this._reconcileTooltipMeasurement(changedProperties); + } + + /** + * INV-10: when new data is loaded (or projection index changes), ensure the + * selection is valid. This prevents a blank plot when switching from a dataset + * with many projections/annotations to one with only a single projection/annotation. + */ + private _reconcileSelectionDefaults(changedProperties: Map) { if ( (changedProperties.has('data') || changedProperties.has('selectedProjectionIndex')) && this.data @@ -547,31 +623,34 @@ export class ProtspaceScatterplot extends LitElement { this._shapeMapping = null; } } + } - // A query filter is scoped to the current dataset. On a dataset swap, drop the - // filtered-id set before _processData runs below — otherwise a stale set (ids - // from the previous dataset) would match nothing and blank the new plot. Set - // here (not via a getter) so the synchronous _processData read sees it cleared. - if (changedProperties.has('data') && this.filtersActive) { - this.filteredProteinIds = []; - this.filtersActive = false; + /** + * A query filter is scoped to the current dataset. On a dataset swap, drop the + * filtered-id set before _processData runs below — otherwise a stale set (ids + * from the previous dataset) would match nothing and blank the new plot. Set + * here (not via a getter) so the synchronous _processData read sees it cleared. + */ + private _reconcileFilterOnDataSwap(changedProperties: Map) { + if (changedProperties.has('data')) { + // F-40: the filtered-display memo is keyed by reference on the previous + // materialized object. _getMaterializedData returns a fresh object after a + // data swap, so the reference check already misses — but drop the cache + // explicitly here too so a stale slice from the previous dataset can never + // be returned. Value-identical to the original (no cache) behavior; it only + // forces the recompute that would happen anyway. + this._filteredDisplayCache = null; + this._filteredDisplayCacheDeps = null; + if (this.filtersActive) { + this.filteredProteinIds = []; + this.filtersActive = false; + } } + } - const numericSettingsChangedOnly = - changedProperties.has('numericAnnotationSettings') && - !changedProperties.has('data') && - !changedProperties.has('filteredProteinIds') && - !changedProperties.has('filtersActive') && - !changedProperties.has('selectedProjectionIndex') && - !changedProperties.has('projectionPlane'); - - if ( - changedProperties.has('data') || - changedProperties.has('filteredProteinIds') || - changedProperties.has('filtersActive') || - changedProperties.has('selectedProjectionIndex') || - changedProperties.has('projectionPlane') - ) { + /** INV-11: reprocess geometry + emit data-change when a geometry input changes. */ + private _reprocessGeometryIfNeeded(changedProperties: Map) { + if (this._geometryInputsChanged(changedProperties)) { this._processData(); this._scheduleQuadtreeRebuild(); this._webglRenderer?.invalidatePositionCache(); @@ -604,9 +683,10 @@ export class ProtspaceScatterplot extends LitElement { ); } } - if (numericSettingsChangedOnly && this.data) { - this._scheduleNumericAnnotationRefresh(); - } + } + + /** INV-14: config shallow-merge + duplicate-UI teardown + style signature + quadtree schedule. */ + private _reconcileConfigMerge(changedProperties: Map) { if (changedProperties.has('config')) { const prev = this._mergedConfig; this._mergedConfig = { ...DEFAULT_CONFIG, ...prev, ...this.config }; @@ -614,15 +694,18 @@ export class ProtspaceScatterplot extends LitElement { const nextDupUI = !!this._mergedConfig.enableDuplicateStackUI; if (prevDupUI !== nextDupUI) { // Cancel any in-flight work and invalidate caches when toggling. - this._cancelDuplicateOverlayDebounce(); - this._cancelDuplicateStackCompute(); - this._duplicateStacksCacheKey = null; + this._dupOverlay.cancelDebounce(); + this._dupOverlay.cancelCompute(); + this._dupOverlay.resetCacheKey(); } this._updateStyleSignature(); this._webglRenderer?.invalidateStyleCache(); this._webglRenderer?.setStyleSignature(this._styleSig); this._scheduleQuadtreeRebuild(); } + } + + private _rebuildStyleAndSignature(changedProperties: Map) { if ( changedProperties.has('selectedAnnotation') || changedProperties.has('hiddenAnnotationValues') || @@ -644,13 +727,19 @@ export class ProtspaceScatterplot extends LitElement { this._webglRenderer?.invalidatePositionCache(); } } + } + + private _reconcileSelectionMode(changedProperties: Map) { if ( changedProperties.has('selectionMode') || (changedProperties.has('selectionTool') && this.selectionMode) ) { - this._updateSelectionMode(); + this._interaction?.updateSelectionMode(); } - // Refresh cached style getters when any relevant input changes + } + + /** Refresh cached style getters when any relevant input changes. */ + private _refreshStyleGettersCache(changedProperties: Map) { if ( changedProperties.has('data') || changedProperties.has('numericAnnotationSettings') || @@ -663,6 +752,9 @@ export class ProtspaceScatterplot extends LitElement { ) { this._styleGettersCache = this._buildStyleGetters(); } + } + + private _reconcileSelectionOverlays(changedProperties: Map) { if ( changedProperties.has('selectedProteinIds') || changedProperties.has('highlightedProteinIds') @@ -681,7 +773,9 @@ export class ProtspaceScatterplot extends LitElement { this._renderPlot(); this._updateSelectionOverlays(); } + } + private _reconcileTooltipMeasurement(changedProperties: Map) { // Only measure tooltip height when the tooltip data itself changes. The rendered // height is derived purely from _tooltipData.view, so there is no reason to // read offsetHeight (which forces a synchronous layout reflow) on unrelated @@ -729,27 +823,15 @@ export class ProtspaceScatterplot extends LitElement { } firstUpdated() { - this._initializeInteractions(); + this._interaction = new PlotInteractionController(this._interactionHost()); + this._interaction.initialize(); this._updateSizeAndRender(); if (this._canvas) { - this._webglRenderer = new WebGLRenderer( - this._canvas, - () => this._scales, - () => this._transform, - () => this._mergedConfig, - { - getColors: (p: PlotDataPoint) => this._getColors(p), - getPointSize: (p: PlotDataPoint) => this._getPointSize(p), - getOpacity: (p: PlotDataPoint) => this._getOpacity(p), - getDepth: (p: PlotDataPoint) => this._getDepth(p), - getStrokeColor: (p: PlotDataPoint) => this._getStrokeColor(p), - getStrokeWidth: (p: PlotDataPoint) => this._getStrokeWidth(p), - getShape: (p: PlotDataPoint) => this._getPointShape(p), - }, - this._handleWebglContextLost, - ); - this._updateStyleSignature(); - this._webglRenderer.setStyleSignature(this._styleSig); + // _updateSizeAndRender already lazily constructs the renderer when _canvas + // exists; guard here so firstUpdated no longer orphans that instance (F-35). + if (!this._webglRenderer) { + this._createWebglRenderer(); + } this._syncWebglSelectionActive(); } } @@ -828,81 +910,50 @@ export class ProtspaceScatterplot extends LitElement { } private _scheduleNumericAnnotationRefresh() { - if (!this.data) return; - - const jobId = ++this._numericRecomputeJobId; - this._numericRecomputeState = { - running: true, - annotation: this.selectedAnnotation, - startedAt: performance.now(), - }; - this.dispatchEvent( - new CustomEvent('numeric-recompute-start', { - detail: { annotation: this.selectedAnnotation }, - bubbles: true, - composed: true, - }), - ); - this.requestUpdate(); - - requestAnimationFrame(() => { - if (jobId !== this._numericRecomputeJobId) return; + this._numericRecompute.schedule(); + } - const materializedData = this._getMaterializedData(); - if (!materializedData) { - this._numericRecomputeState = { running: false, annotation: null, startedAt: null }; - return; - } + /** + * Component-owned data-refresh routing + lifecycle-bound render tail + the + * `data-change` re-emit. Runs inside the deferred RAF for the current job + * (NumericRecomputeRunner owns the job id, events, RAF, and running state). + */ + private _runNumericRecomputeBody() { + const materializedData = this._getMaterializedData(); + if (!materializedData) return; - // _refreshSelectedAnnotationValues only reads annotations / annotation_data - // (both present on the materialized object) and then triggers a lazy - // style-getter rebuild that itself uses includeFilteredProteinIds:false. - // Excluding filtered ids returns the cached materialized object by - // reference (no per-point deep-slice of projections/numeric/scores/evidence), - // matching the pattern at _getVisibilityModel / _buildStyleGetters. - const displayData = - this._getCurrentDisplayData({ includeFilteredProteinIds: false }) ?? materializedData; - - if (this._plotData.length > 0) { - this._refreshSelectedAnnotationValues(displayData); - } else { - this._processData(); - } + // _refreshSelectedAnnotationValues only reads annotations / annotation_data + // (both present on the materialized object) and then triggers a lazy + // style-getter rebuild that itself uses includeFilteredProteinIds:false. + // Excluding filtered ids returns the cached materialized object by + // reference (no per-point deep-slice of projections/numeric/scores/evidence), + // matching the pattern at _getVisibilityModel / _buildStyleGetters. + const displayData = + this._getCurrentDisplayData({ includeFilteredProteinIds: false }) ?? materializedData; - this._scheduleQuadtreeRebuild(); - this._webglRenderer?.invalidateStyleCache(); - this._updateStyleSignature(); - this._webglRenderer?.setStyleSignature(this._styleSig); - this._renderPlot(); - this._updateSelectionOverlays(); + if (this._plotData.length > 0) { + this._refreshSelectedAnnotationValues(displayData); + } else { + this._processData(); + } - const currentData = this.getCurrentData() ?? displayData ?? materializedData ?? this.data; - if (currentData) { - this.dispatchEvent( - new CustomEvent('data-change', { - detail: { data: currentData }, - bubbles: true, - composed: true, - }), - ); - } + this._scheduleQuadtreeRebuild(); + this._webglRenderer?.invalidateStyleCache(); + this._updateStyleSignature(); + this._webglRenderer?.setStyleSignature(this._styleSig); + this._renderPlot(); + this._updateSelectionOverlays(); + const currentData = this.getCurrentData() ?? displayData ?? materializedData ?? this.data; + if (currentData) { this.dispatchEvent( - new CustomEvent('numeric-recompute-end', { - detail: { - annotation: this.selectedAnnotation, - durationMs: - this._numericRecomputeState.startedAt == null - ? 0 - : performance.now() - this._numericRecomputeState.startedAt, - }, + new CustomEvent('data-change', { + detail: { data: currentData }, bubbles: true, composed: true, }), ); - this._numericRecomputeState = { running: false, annotation: null, startedAt: null }; - this.requestUpdate(); - }); + } } private _getCurrentDisplayData(options?: { @@ -917,6 +968,19 @@ export class ProtspaceScatterplot extends LitElement { return materializedData; } + const deps = this._filteredDisplayCacheDeps; + if ( + this._filteredDisplayCache && + deps && + deps.materialized === materializedData && + deps.filteredProteinIds === this.filteredProteinIds && + deps.filtersActive === this.filtersActive && + deps.selectedProjectionIndex === this.selectedProjectionIndex && + deps.projectionPlane === this.projectionPlane + ) { + return this._filteredDisplayCache; + } + const keptIndices: number[] = []; materializedData.protein_ids.forEach((proteinId, index) => { if (visibleProteinIds.has(proteinId)) { @@ -924,54 +988,16 @@ export class ProtspaceScatterplot extends LitElement { } }); - return { - ...materializedData, - protein_ids: keptIndices.map((index) => materializedData.protein_ids[index]), - projections: materializedData.projections.map((projection) => { - const dim = projection.dimension; - const out = new Float32Array(keptIndices.length * dim); - for (let k = 0; k < keptIndices.length; k++) { - const base = keptIndices[k] * dim; - const o = k * dim; - out[o] = projection.data[base]; - out[o + 1] = projection.data[base + 1]; - if (dim === 3) out[o + 2] = projection.data[base + 2]; - } - return { ...projection, data: out, dimension: dim }; - }), - annotation_data: Object.fromEntries( - Object.entries(materializedData.annotation_data).map(([annotationName, rows]) => [ - annotationName, - sliceAnnotationData(rows, keptIndices), - ]), - ), - numeric_annotation_data: materializedData.numeric_annotation_data - ? Object.fromEntries( - Object.entries(materializedData.numeric_annotation_data).map( - ([annotationName, values]) => [ - annotationName, - keptIndices.map((index) => values[index]), - ], - ), - ) - : undefined, - annotation_scores: materializedData.annotation_scores - ? Object.fromEntries( - Object.entries(materializedData.annotation_scores).map(([annotationName, rows]) => [ - annotationName, - keptIndices.map((index) => rows[index]), - ]), - ) - : undefined, - annotation_evidence: materializedData.annotation_evidence - ? Object.fromEntries( - Object.entries(materializedData.annotation_evidence).map(([annotationName, rows]) => [ - annotationName, - keptIndices.map((index) => rows[index]), - ]), - ) - : undefined, + const result = sliceVisualizationDataByIndices(materializedData, keptIndices); + this._filteredDisplayCache = result; + this._filteredDisplayCacheDeps = { + materialized: materializedData, + filteredProteinIds: this.filteredProteinIds, + filtersActive: this.filtersActive, + selectedProjectionIndex: this.selectedProjectionIndex, + projectionPlane: this.projectionPlane, }; + return result; } /** @@ -1020,14 +1046,15 @@ export class ProtspaceScatterplot extends LitElement { private _buildQuadtree() { // Cancel any in-flight duplicate stack computation — it uses the old quadtree // and would overwrite cleared state with stale results when it finishes. - this._cancelDuplicateStackCompute(); + this._dupOverlay.cancelCompute(); if (!this._plotData.length || !this._scales) { - this._duplicateStacks = []; - this._duplicateStackByKey.clear(); - this._pointIdToDuplicateStackKey.clear(); - this._expandedDuplicateStackKey = null; - this._duplicateStacksCacheKey = null; + this._dupOverlay.resetState(); + // F-17: an emptied quadtree also changes the indexed slot set; bump the + // generation and invalidate so the transform-keyed cache cannot serve a + // stale slot set. No render here — there is nothing to draw. + this._quadtreeGeneration++; + this._invalidateVirtualizationCache(); return; } const pd = this._plotData; @@ -1045,19 +1072,23 @@ export class ProtspaceScatterplot extends LitElement { } this._quadtreeIndex.setScales(this._scales); this._quadtreeIndex.rebuild(pd, visibleSlots); - // Duplicate stacks are computed lazily for the current viewport (see _ensureDuplicateStacksForViewport) - // to keep quadtree rebuilds fast on large datasets. - this._duplicateStacks = []; - this._duplicateStackByKey.clear(); - this._pointIdToDuplicateStackKey.clear(); - this._expandedDuplicateStackKey = null; - this._duplicateStacksCacheKey = null; + // Duplicate stacks are computed lazily for the current viewport (see the + // controller's ensureForViewport) to keep quadtree rebuilds fast on large datasets. + this._dupOverlay.resetState(); // Trigger a fresh duplicate overlay update so badges are recomputed for the // new quadtree (e.g. after a projection switch). Without this, the overlays // rendered synchronously in updated() used a stale cache and nothing would // re-trigger them after the deferred quadtree rebuild. - this._scheduleDuplicateOverlayUpdate(true); + this._dupOverlay.updateSelectionOverlays({ duplicateImmediate: true }); + + // F-17: any rebuild can change the indexed (isInteractive) slot set, so the + // transform-keyed virtualization cache is now stale even if the transform is + // unchanged. Bump the generation (folded into the cacheKey), force a miss, + // and schedule a render so un-hidden points reappear without a pan/zoom. + this._quadtreeGeneration++; + this._invalidateVirtualizationCache(); + this._renderPlot(); } private _scheduleQuadtreeRebuild() { @@ -1070,72 +1101,34 @@ export class ProtspaceScatterplot extends LitElement { }); } - private _initializeInteractions() { - if (!this._svg) return; - - this._svgSelection = d3.select(this._svg); - - // Clear existing content - this._svgSelection.selectAll('*').remove(); - - // Create main container group - this._mainGroup = this._svgSelection.append('g').attr('class', 'scatter-plot-container'); - - // Create brush group - this._brushGroup = this._svgSelection.append('g').attr('class', 'brush-container'); - - // Create overlay group (above brush) for transient drawings like selections - this._overlayGroup = this._svgSelection.append('g').attr('class', 'overlay-container'); - - this._zoom = d3 - .zoom() - .scaleExtent(this._mergedConfig.zoomExtent) - .on('zoom', (event) => { - this._transform = event.transform; - if (this._mainGroup) { - this._mainGroup.attr('transform', event.transform); - } - if (this._brushGroup) { - this._brushGroup.attr('transform', event.transform); - } - if (this._overlayGroup) { - this._overlayGroup.attr('transform', event.transform); - } - // Smooth WebGL rendering during zoom using requestAnimationFrame - if (this._canvas) { - if (this._zoomRafId !== null) { - cancelAnimationFrame(this._zoomRafId); - } - this._zoomRafId = requestAnimationFrame(() => { - this._zoomRafId = null; - this._renderWebGL('zoom'); - // During active zoom/pan, defer duplicate badge DOM updates to keep interactions smooth. - this._updateSelectionOverlays({ duplicateImmediate: false }); - }); - } - // Keep brush extent in sync with the viewport when scroll-zooming in selection mode. - // Skip if a brush gesture is in progress — re-applying the brush resets D3's drag state. - if ( - this.selectionMode && - this.selectionTool === 'rectangle' && - this._brush && - !this._isBrushing - ) { - this._updateBrushExtent(); - } - }); - this._svgSelection.call(this._zoom); - this._setupDblClickHandlers(); - } - - /** Disable D3's built-in double-click zoom and attach our own reset handler. */ - private _setupDblClickHandlers() { - if (!this._svgSelection) return; - this._svgSelection.on('dblclick.zoom', null); - this._svgSelection.on('dblclick.reset', (event: MouseEvent) => { - event.preventDefault(); - this.resetZoom(); - }); + /** + * Bridge handed to the PlotInteractionController (F-07): narrow pull-getters + + * callbacks so the controller never reaches into the component. Event dispatch + * stays on the host (INV-03/INV-05); the host owns the _transform field (F-48, + * written back via onTransform). + */ + private _interactionHost(): PlotInteractionHost { + return { + getSvg: () => this._svg, + getCanvas: () => this._canvas, + getMergedConfig: () => this._mergedConfig, + getSelectionMode: () => this.selectionMode, + getSelectionTool: () => this.selectionTool, + hasScales: () => this._scales != null, + getTransform: () => this._transform, + queryByPolygon: (vertices) => this._quadtreeIndex.queryByPolygon(vertices), + queryByPixels: (x0, y0, x1, y1) => this._quadtreeIndex.queryByPixels(x0, y0, x1, y1), + resolveSlotsToIds: (slots) => this._slotsToInteractiveIds(slots), + onTransform: (t) => { + this._transform = t; + }, + onSelect: (ids, clearVisual) => this._commitSelection(ids, clearVisual), + onHover: (event) => this._handleCanvasMouseMove(event), + onHoverEnd: () => this._handleCanvasMouseOut(), + onClick: (event) => this._handleCanvasClick(event), + renderWebGL: (trigger) => this._renderWebGL(trigger), + updateSelectionOverlays: (opts) => this._updateSelectionOverlays(opts), + }; } private _updateSizeAndRender() { @@ -1144,31 +1137,14 @@ export class ProtspaceScatterplot extends LitElement { if (this._canvas) { if (!this._webglRenderer) { - this._webglRenderer = new WebGLRenderer( - this._canvas, - () => this._scales, - () => this._transform, - () => this._mergedConfig, - { - getColors: (p: PlotDataPoint) => this._getColors(p), - getPointSize: (p: PlotDataPoint) => this._getPointSize(p), - getOpacity: (p: PlotDataPoint) => this._getOpacity(p), - getDepth: (p: PlotDataPoint) => this._getDepth(p), - getStrokeColor: (p: PlotDataPoint) => this._getStrokeColor(p), - getStrokeWidth: (p: PlotDataPoint) => this._getStrokeWidth(p), - getShape: (p: PlotDataPoint) => this._getPointShape(p), - }, - this._handleWebglContextLost, - ); - this._updateStyleSignature(); - this._webglRenderer.setStyleSignature(this._styleSig); + this._createWebglRenderer(); } - this._webglRenderer.resize(width, height); + this._webglRenderer!.resize(width, height); // Force fresh style getters to ensure depth values are recomputed consistently this._styleGettersCache = null; - this._webglRenderer.invalidatePositionCache(); + this._webglRenderer!.invalidatePositionCache(); // Also invalidate style cache to force re-sorting of colors when point order may change - this._webglRenderer.invalidateStyleCache(); + this._webglRenderer!.invalidateStyleCache(); } // Keep badge canvas in sync with layout and DPR @@ -1200,199 +1176,8 @@ export class ProtspaceScatterplot extends LitElement { this._updateSelectionOverlays(); } - private _clearDuplicateBadgesCanvas() { - if (!this._badgesCanvas) return; - const ctx = this._badgesCanvas.getContext('2d'); - if (!ctx) return; - // Clear in device pixels (canvas is sized to DPR). - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, this._badgesCanvas.width, this._badgesCanvas.height); - } - - private _renderDuplicateBadgesCanvas( - stacks: Array<{ key: string; px: number; py: number; points: PlotDataPoint[] }>, - ) { - if (!this._badgesCanvas) return; - const ctx = this._badgesCanvas.getContext('2d'); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const width = this._mergedConfig.width; - const height = this._mergedConfig.height; - - // Work in CSS pixels for drawing; scale to device pixels once. - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, width, height); - - const t = this._transform; - const badgeOffset = { x: 10, y: -10 }; - const r = 9; - - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '700 10px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif'; - ctx.lineWidth = 1.5; - ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; - - for (let i = 0; i < stacks.length; i++) { - const s = stacks[i]; - const x = t.x + t.k * s.px + badgeOffset.x; - const y = t.y + t.k * s.py + badgeOffset.y; - const isExpanded = s.key === this._expandedDuplicateStackKey; - - ctx.fillStyle = isExpanded ? 'rgba(59, 130, 246, 0.9)' : 'rgba(17, 24, 39, 0.85)'; - ctx.beginPath(); - ctx.arc(x, y, r, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = '#ffffff'; - ctx.fillText(String(s.points.length), x, y); - } - } - // HiDPI setup and quality handled by WebGLRenderer - private _updateSelectionMode() { - if (!this._svgSelection || !this._brushGroup || !this._scales) return; - - // Clean up both selection tools - this._brushGroup.selectAll('*').remove(); - this._cleanupLasso(); - this._brush = null; - this._isBrushing = false; - - if (this.selectionMode) { - // Keep scroll-wheel zoom active but disable drag-to-pan (drag = selection) - if (this._zoom && this._svgSelection) { - this._svgSelection - .on('mousedown.zoom', null) - .on('touchstart.zoom', null) - .on('touchmove.zoom', null) - .on('touchend.zoom', null); - } - - if (this.selectionTool === 'lasso') { - this._setupLasso(); - } else { - this._setupBrush(); - } - } else { - // Re-enable zoom - if (this._zoom) { - this._svgSelection.call(this._zoom); - this._setupDblClickHandlers(); - } - } - } - - private _setupBrush() { - if (!this._svgSelection || !this._brushGroup) return; - - this._brush = d3 - .brush() - .handleSize(0) - .on('start', () => { - this._isBrushing = true; - }) - .on('end', (event) => { - this._isBrushing = false; - this._handleBrushEnd(event); - }); - - this._updateBrushExtent(); - } - - /** Recompute the brush extent from the current zoom transform and re-apply. */ - private _updateBrushExtent() { - if (!this._brush || !this._brushGroup) return; - - const config = this._mergedConfig; - const t = this._transform; - const vx0 = t.invertX(0); - const vy0 = t.invertY(0); - const vx1 = t.invertX(config.width); - const vy1 = t.invertY(config.height); - - this._brush.extent([ - [Math.min(vx0, vx1), Math.min(vy0, vy1)], - [Math.max(vx0, vx1), Math.max(vy0, vy1)], - ]); - - this._brushGroup.call(this._brush); - } - - // ── Lasso selection ────────────────────────────────────────────── - - private _setupLasso() { - if (!this._svgSelection) return; - - this._svgSelection - .on('pointerdown.lasso', (event: PointerEvent) => this._handleLassoStart(event)) - .on('pointermove.lasso', (event: PointerEvent) => this._handleLassoMove(event)) - .on('pointerup.lasso', (event: PointerEvent) => this._handleLassoEnd(event)); - } - - private _cleanupLasso() { - if (this._svgSelection) { - this._svgSelection.on('pointerdown.lasso', null); - this._svgSelection.on('pointermove.lasso', null); - this._svgSelection.on('pointerup.lasso', null); - } - if (this._lassoRafId !== null) { - cancelAnimationFrame(this._lassoRafId); - this._lassoRafId = null; - } - this._lassoPath?.remove(); - this._lassoPath = null; - this._lassoVertices = []; - this._isLassoing = false; - } - - /** Convert a pointer event to local (untransformed) SVG coordinates. */ - private _pointerToLocal(event: PointerEvent): [number, number] { - const [svgX, svgY] = d3.pointer(event); - const localX = (svgX - this._transform.x) / this._transform.k; - const localY = (svgY - this._transform.y) / this._transform.k; - return [localX, localY]; - } - - private _handleLassoStart(event: PointerEvent) { - if (event.button !== 0) return; // left click only - event.preventDefault(); - - this._isLassoing = true; - this._lassoVertices = [this._pointerToLocal(event)]; - - // Create the SVG path in the brush group (same coordinate space as the brush) - if (this._brushGroup) { - this._lassoPath = this._brushGroup.append('path').attr('class', 'lasso-path').node(); - } - - // Capture pointer for reliable tracking even if cursor leaves the SVG - (event.target as Element)?.setPointerCapture?.(event.pointerId); - } - - private _handleLassoMove(event: PointerEvent) { - if (!this._isLassoing || !this._lassoPath) return; - event.preventDefault(); - - this._lassoVertices.push(this._pointerToLocal(event)); - - // Throttle SVG path updates to animation frames - if (this._lassoRafId === null) { - this._lassoRafId = requestAnimationFrame(() => { - this._lassoRafId = null; - if (!this._lassoPath || this._lassoVertices.length < 2) return; - - const d = this._lassoVertices - .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x},${y}`) - .join(' '); - this._lassoPath.setAttribute('d', d); - }); - } - } - /** * Resolve a list of quadtree slots to the protein ids of the interactive * points among them, in a single allocation-free pass. @@ -1422,48 +1207,23 @@ export class ProtspaceScatterplot extends LitElement { return ids; } - private _handleLassoEnd(event: PointerEvent) { - if (!this._isLassoing) return; - event.preventDefault(); - this._isLassoing = false; - - (event.target as Element)?.releasePointerCapture?.(event.pointerId); - - // Need at least 3 vertices to form a polygon - if (this._lassoVertices.length < 3) { - this._clearLassoVisual(); - return; - } - - // Close the path visually - if (this._lassoPath) { - const d = this._lassoPath.getAttribute('d') ?? ''; - this._lassoPath.setAttribute('d', d + ' Z'); - } - - const slots = this._quadtreeIndex.queryByPolygon(this._lassoVertices); - const selectedIds = this._slotsToInteractiveIds(slots); - this._commitSelection(selectedIds, () => this._clearLassoVisual()); - } - - private _clearLassoVisual() { - if (this._lassoPath) { - this._lassoPath.remove(); - this._lassoPath = null; - } - this._lassoVertices = []; - } - - private _handleBrushEnd(event: d3.D3BrushEvent) { + /** + * Host shim retained for the characterization suite (F-07): the live brush + * lifecycle (incl. clearing the brush rectangle on commit) lives in + * PlotInteractionController, but scatter-plot.test.ts drives this handler + * directly. Body stays behavior-identical for slot→id resolution + dispatch; + * the brush-rectangle clear is owned by the controller for the live path. + * Public so the test can drive it (mirrors pickInteractivePointAt); not called + * from app code (controller owns the live path). + */ + _handleBrushEnd(event: d3.D3BrushEvent) { if (!event.selection) return; const [[x0, y0], [x1, y1]] = event.selection as [[number, number], [number, number]]; const slots = this._quadtreeIndex.queryByPixels(x0, y0, x1, y1); const selectedIds = this._slotsToInteractiveIds(slots); this._commitSelection(selectedIds, () => { - if (this._brush && this._brushGroup) { - this._brushGroup.call(this._brush.move, null); - } + /* brush-rectangle clear owned by the controller for the live path */ }); } @@ -1473,7 +1233,13 @@ export class ProtspaceScatterplot extends LitElement { */ private _commitSelection(selectedIds: string[], clearVisual: () => void) { if (selectedIds.length > 0) { - requestAnimationFrame(() => { + // F-16: track the deferred-commit RAF so disconnectedCallback can cancel it. + // The post-disconnect no-op is achieved by that cancellation (a selection + // committed then disconnected before this RAF fires never dispatches), NOT + // by guarding the body on isConnected — the connected selection flow must + // dispatch byte-identically (INV-03/INV-05). + this._commitSelectionRafId = requestAnimationFrame(() => { + this._commitSelectionRafId = null; this.selectedProteinIds = [...selectedIds]; this.dispatchEvent( @@ -1503,18 +1269,19 @@ export class ProtspaceScatterplot extends LitElement { if (this._canvas && this._webglRenderer) { this._renderWebGL('plot'); - this._setupCanvasEventHandling(); + this._interaction?.setupCanvasEventHandling(); } } private _renderWebGL(trigger: RenderWebGLTrigger = 'unknown') { + if (!this._webglRenderer) return; const perfToken = this._webglRenderPerf.start(trigger); const pd = this._getPointsForRendering(); - this._webglRenderer!.setTrackRenderedPointIds(pd.length > MAX_POINTS_DIRECT_RENDER); - this._webglRenderer!.render(pd); - this._mainGroup?.selectAll('.protein-point').remove(); + this._webglRenderer.setTrackRenderedPointIds(pd.length > MAX_POINTS_DIRECT_RENDER); + this._webglRenderer.render(pd); + this._interaction?.mainGroup?.selectAll('.protein-point').remove(); this._webglRenderPerf.stop(perfToken, pd.length); } @@ -1541,19 +1308,14 @@ export class ProtspaceScatterplot extends LitElement { // For very large datasets, apply viewport culling const config = this._mergedConfig; const transform = this._transform; - const padding = VIRTUALIZATION_PADDING; - const leftPx = transform.invertX(config.margin.left - padding); - const rightPx = transform.invertX(config.width - config.margin.right + padding); - const topPx = transform.invertY(config.margin.top - padding); - const bottomPx = transform.invertY(config.height - config.margin.bottom + padding); - - const minX = Math.min(leftPx, rightPx); - const maxX = Math.max(leftPx, rightPx); - const minY = Math.min(topPx, bottomPx); - const maxY = Math.max(topPx, bottomPx); + const { minX, maxX, minY, maxY } = computeViewportWindow( + transform, + config, + VIRTUALIZATION_PADDING, + ); - const cacheKey = `${Math.round(transform.x)}|${Math.round(transform.y)}|${transform.k.toFixed(3)}|${config.width}|${config.height}`; + const cacheKey = `${buildViewKey(transform, config.width, config.height)}|${this._quadtreeGeneration}`; if (this._virtualizationCacheKey !== cacheKey) { const slots = this._quadtreeIndex.queryByPixels(minX, minY, maxX, maxY); this._visiblePlotData = gatherPlotData(this._plotData, slots); @@ -1570,452 +1332,12 @@ export class ProtspaceScatterplot extends LitElement { } private _updateSelectionOverlays(options: { duplicateImmediate?: boolean } = {}) { - if (!this._overlayGroup) return; - this._overlayGroup.selectAll('.selected-overlay').remove(); - this._scheduleDuplicateOverlayUpdate(options.duplicateImmediate ?? true); - } - - private _cancelDuplicateOverlayDebounce() { - if (this._duplicateOverlayDebounceId !== null) { - window.clearTimeout(this._duplicateOverlayDebounceId); - this._duplicateOverlayDebounceId = null; - } - } - - private _cancelDuplicateStackCompute() { - // Bump job id so any in-flight chunked compute aborts early. - this._duplicateStacksComputeJobId++; - this._duplicateStacksComputing = false; - } - - private _scheduleDuplicateOverlayUpdate(immediate: boolean) { - if (!this._overlayGroup) return; - - // When the feature is disabled, keep this lightweight and synchronous. - if (!this._isDuplicateStackUIEnabled()) { - this._updateDuplicateOverlays(); - return; - } - - if (immediate) { - this._cancelDuplicateOverlayDebounce(); - this._updateDuplicateOverlays(); - return; - } - - // Cheap path: redraw existing badges with the current zoom transform (no recompute, no DOM churn). - this._redrawDuplicateBadgesCanvasOnly(); - - // Debounce to avoid DOM churn during pan/zoom. - this._cancelDuplicateOverlayDebounce(); - this._duplicateOverlayDebounceId = window.setTimeout(() => { - this._duplicateOverlayDebounceId = null; - this._updateDuplicateOverlays(); - }, DUPLICATE_BADGES_UPDATE_DEBOUNCE_MS); - } - - private _ensureDuplicateStacksForViewport( - viewKey: string, - minX: number, - minY: number, - maxX: number, - maxY: number, - ): boolean { - if (this._duplicateStacksCacheKey === viewKey) return true; - if (this._duplicateStacksComputing) return false; - - this._duplicateStacksComputing = true; - const jobId = ++this._duplicateStacksComputeJobId; - - // Query only the slots currently in (or near) the viewport. This is the key perf win. - const candidateSlots = this._quadtreeIndex.queryByPixels(minX, minY, maxX, maxY); - const scales = this._scales; - if (!scales) { - this._duplicateStacksComputing = false; - return false; - } - - const stackMap = new Map< - string, - { key: string; x: number; y: number; px: number; py: number; points: PlotDataPoint[] } - >(); - const idToKey = new Map(); - - let idx = 0; - const step = () => { - if (jobId !== this._duplicateStacksComputeJobId) return; // cancelled - const end = Math.min(candidateSlots.length, idx + DUPLICATE_STACK_COMPUTE_CHUNK_SIZE); - for (; idx < end; idx++) { - const slot = candidateSlots[idx]; - const p = materializePlotDataPoint(this._plotData, slot); - if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; - - // Group only points that share coords in the *current* projection - // (see duplicate-stack-helpers for the rationale and key contract). - const key = getDuplicateStackKey(p); - - let stack = stackMap.get(key); - if (!stack) { - stack = { - key, - x: p.x, - y: p.y, - px: scales.x(p.x), - py: scales.y(p.y), - points: [], - }; - stackMap.set(key, stack); - } - stack.points.push(p); - idToKey.set(p.id, key); - } - - if (idx < candidateSlots.length) { - requestAnimationFrame(step); - return; - } - - // Finalize: keep only true duplicates. - const stacks: Array<{ - key: string; - x: number; - y: number; - px: number; - py: number; - points: PlotDataPoint[]; - }> = []; - const byKey = new Map< - string, - { key: string; x: number; y: number; px: number; py: number; points: PlotDataPoint[] } - >(); - - for (const stack of stackMap.values()) { - if (stack.points.length > 1) { - stacks.push(stack); - byKey.set(stack.key, stack); - } - } - - this._duplicateStacks = stacks; - this._duplicateStackByKey = byKey; - this._pointIdToDuplicateStackKey = idToKey; - - // If the expanded stack is no longer available for this viewport, collapse it. - if ( - this._expandedDuplicateStackKey && - !this._duplicateStackByKey.has(this._expandedDuplicateStackKey) - ) { - this._expandedDuplicateStackKey = null; - this._expandedSpiderAnchor = null; - } - - // Restore the user's spider anchor on the freshly built stack object so - // pan/zoom doesn't snap the spider back to whichever group member was - // iterated first. - this._applyExpandedSpiderAnchor(); - - this._duplicateStacksCacheKey = viewKey; - this._duplicateStacksComputing = false; - - // Re-render overlays for the freshly computed viewport stacks. - this._updateDuplicateOverlays(); - }; - - requestAnimationFrame(step); - return false; - } - - private _capDuplicateStacksForRendering( - stacks: Array<{ key: string; px: number; py: number; points: PlotDataPoint[] }>, - minX: number, - minY: number, - maxX: number, - maxY: number, - ): Array<{ key: string; px: number; py: number; points: PlotDataPoint[] }> { - let stacksToRender = stacks; - - if (stacksToRender.length > DUPLICATE_BADGES_MAX_VISIBLE) { - stacksToRender = [...stacksToRender] - .sort((a, b) => b.points.length - a.points.length) - .slice(0, DUPLICATE_BADGES_MAX_VISIBLE); - - // Ensure the expanded stack remains visible even if it is not in the top-N. - if ( - this._expandedDuplicateStackKey && - !stacksToRender.some((s) => s.key === this._expandedDuplicateStackKey) - ) { - const expanded = this._duplicateStackByKey.get(this._expandedDuplicateStackKey); - if ( - expanded && - expanded.px >= minX && - expanded.px <= maxX && - expanded.py >= minY && - expanded.py <= maxY - ) { - stacksToRender = [...stacksToRender, expanded]; - } - } - } - - return stacksToRender; - } - - private _redrawDuplicateBadgesCanvasOnly() { - if (!this._isDuplicateStackUIEnabled() || this.selectionMode) { - this._clearDuplicateBadgesCanvas(); - return; - } - if (!this._scales) return; - - const config = this._mergedConfig; - const padding = DUPLICATE_BADGES_VIEWPORT_PADDING; - const leftPx = this._transform.invertX(config.margin.left - padding); - const rightPx = this._transform.invertX(config.width - config.margin.right + padding); - const topPx = this._transform.invertY(config.margin.top - padding); - const bottomPx = this._transform.invertY(config.height - config.margin.bottom + padding); - - const minX = Math.min(leftPx, rightPx); - const maxX = Math.max(leftPx, rightPx); - const minY = Math.min(topPx, bottomPx); - const maxY = Math.max(topPx, bottomPx); - - const visibleStacks = this._duplicateStacks.filter( - (s) => s.px >= minX && s.px <= maxX && s.py >= minY && s.py <= maxY, - ); - const stacksToRender = this._capDuplicateStacksForRendering( - visibleStacks, - minX, - minY, - maxX, - maxY, - ); - - // Note: canvas drawing uses screen coordinates and already keeps badge size constant. - this._renderDuplicateBadgesCanvas(stacksToRender); - } - - private _ensureDuplicateSpiderfyLayer() { - if (!this._overlayGroup) return null; - let spiderfyLayer = this._overlayGroup.select('g.duplicate-spiderfy-layer'); - if (spiderfyLayer.empty()) { - spiderfyLayer = this._overlayGroup.append('g').attr('class', 'duplicate-spiderfy-layer'); - } - return spiderfyLayer; - } - - private _updateDuplicateOverlays() { - if (!this._overlayGroup || !this._scales) return; - - if (!this._isDuplicateStackUIEnabled()) { - // Remove both to clean up older DOM from previous versions. - this._overlayGroup.selectAll('g.duplicate-stacks-layer, g.duplicate-spiderfy-layer').remove(); - this._expandedDuplicateStackKey = null; - this._clearDuplicateBadgesCanvas(); - return; - } - - // Don't show stack UI while brushing/selecting. - if (this.selectionMode) { - this._overlayGroup.selectAll('g.duplicate-stacks-layer, g.duplicate-spiderfy-layer').remove(); - this._expandedDuplicateStackKey = null; - this._clearDuplicateBadgesCanvas(); - return; - } - - const spiderfyLayer = this._ensureDuplicateSpiderfyLayer(); - if (!spiderfyLayer) return; - - const k = this._transform.k || 1; - const config = this._mergedConfig; - const viewKey = `${Math.round(this._transform.x)}|${Math.round(this._transform.y)}|${k.toFixed(3)}|${config.width}|${config.height}`; - - // Compute visible window in "base pixel space" (same as quadtree indexing). - const padding = DUPLICATE_BADGES_VIEWPORT_PADDING; - const leftPx = this._transform.invertX(config.margin.left - padding); - const rightPx = this._transform.invertX(config.width - config.margin.right + padding); - const topPx = this._transform.invertY(config.margin.top - padding); - const bottomPx = this._transform.invertY(config.height - config.margin.bottom + padding); - - const minX = Math.min(leftPx, rightPx); - const maxX = Math.max(leftPx, rightPx); - const minY = Math.min(topPx, bottomPx); - const maxY = Math.max(topPx, bottomPx); - - // Ensure we have duplicate stacks for the current viewport before trying to render. - if (!this._ensureDuplicateStacksForViewport(viewKey, minX, minY, maxX, maxY)) { - // Keep existing DOM as-is until computation finishes; _updateDuplicateOverlays will rerun. - return; - } - - const visibleStacks = this._duplicateStacks.filter( - (s) => s.px >= minX && s.px <= maxX && s.py >= minY && s.py <= maxY, - ); - - // --- Badges (N) --- - const stacksToRender = this._capDuplicateStacksForRendering( - visibleStacks, - minX, - minY, - maxX, - maxY, - ); - - // Phase 3: render badges via a lightweight 2D canvas overlay (much faster than many SVG nodes). - // Spiderfy remains in SVG for interaction. - this._renderDuplicateBadgesCanvas(stacksToRender); - - // --- Spiderfy --- - spiderfyLayer.selectAll('*').remove(); - if (!this._expandedDuplicateStackKey) return; - - const stack = this._duplicateStackByKey.get(this._expandedDuplicateStackKey); - if (!stack) { - this._expandedDuplicateStackKey = null; - return; - } - - // Hide spiderfy if the stack is off-screen (e.g., after a zoom/pan). - if (!(stack.px >= minX && stack.px <= maxX && stack.py >= minY && stack.py <= maxY)) { - this._expandedDuplicateStackKey = null; - return; - } - - const points = stack.points; - const n = points.length; - // Ring radius in screen pixels (kept constant by scale(1/k) below) - const ringRadius = Math.min(70, Math.max(22, 12 + n * 2)); - const nodeRadius = 5; - - const spiderGroup = spiderfyLayer - .append('g') - .attr('class', 'dup-spiderfy') - // Keep spiderfy UI constant-size in screen pixels via scale(1/k) - .attr('transform', `translate(${stack.px},${stack.py}) scale(${1 / k})`); - - const items = points.map((p, idx) => { - const angle = (idx / n) * Math.PI * 2 - Math.PI / 2; - const x = ringRadius * Math.cos(angle); - const y = ringRadius * Math.sin(angle); - return { point: p, idx, x, y }; - }); - - // Leader lines - spiderGroup - .selectAll('line.dup-spiderfy-line') - .data(items) - .enter() - .append('line') - .attr('class', 'dup-spiderfy-line') - .attr('x1', 0) - .attr('y1', 0) - .attr('x2', (d) => d.x) - .attr('y2', (d) => d.y); - - // Clickable nodes - const nodes = spiderGroup - .selectAll('g.dup-spiderfy-node') - .data(items) - .enter() - .append('g') - .attr('class', 'dup-spiderfy-node') - .attr('transform', (d) => `translate(${d.x},${d.y})`); - - // Create circles with explicit pointer-events and handle selection via pointer press/release. - // We avoid relying on the native 'click' event because it can be suppressed by d3.zoom gesture handling. - nodes - .append('circle') - .attr('class', 'dup-spiderfy-node-circle') - .attr('r', nodeRadius) - .attr('fill', (d) => this._getColors(d.point)[0] ?? '#888888') - .style('pointer-events', 'all') - .style('cursor', 'pointer') - .on('pointerdown', (event) => { - event.stopPropagation(); - const pe = event as PointerEvent; - if (typeof pe.pointerId === 'number') { - this._spiderfyPressByPointerId.set(pe.pointerId, { - x: pe.clientX, - y: pe.clientY, - t: Date.now(), - }); - } - // Keep pointer events routed to this element even if the pointer moves slightly. - const el = event.currentTarget as HTMLElement | null; - if (el && typeof el.setPointerCapture === 'function' && typeof pe.pointerId === 'number') { - try { - el.setPointerCapture(pe.pointerId); - } catch { - // ignore - } - } - }) - .on('pointerup', (event, d) => { - event.stopPropagation(); - const pe = event as PointerEvent; - const rec = - typeof pe.pointerId === 'number' - ? this._spiderfyPressByPointerId.get(pe.pointerId) - : undefined; - if (typeof pe.pointerId === 'number') this._spiderfyPressByPointerId.delete(pe.pointerId); - if (!rec) return; - - // Treat a short, low-movement press/release as a click. - const dx = pe.clientX - rec.x; - const dy = pe.clientY - rec.y; - const dist2 = dx * dx + dy * dy; - const dt = Date.now() - rec.t; - if (dist2 <= 16 && dt <= 700) { - this._handleClick(event as unknown as MouseEvent, d.point); - } - }) - .on('lostpointercapture', (event) => { - const pe = event as PointerEvent; - if (typeof pe.pointerId === 'number') this._spiderfyPressByPointerId.delete(pe.pointerId); - }) - .on('pointercancel', (event) => { - const pe = event as PointerEvent; - if (typeof pe.pointerId === 'number') this._spiderfyPressByPointerId.delete(pe.pointerId); - }) - .on('mouseenter', (event, d) => { - // Show the real tooltip for the hovered protein (not the stack centroid) - this._handleMouseOver(event as unknown as MouseEvent, d.point); - }) - .on('mouseleave', () => { - this._clearHoverState(); - }); - } - - private _toggleSpiderfy(stackKey: string, anchorPoint?: PlotDataPoint) { - this._expandedDuplicateStackKey = - this._expandedDuplicateStackKey === stackKey ? null : stackKey; - - if (this._expandedDuplicateStackKey && anchorPoint) { - // Remember where the user clicked so the spider stays anchored to that - // point across pan/zoom — _duplicateStackByKey rebuilds with fresh - // objects on every viewport recompute and would otherwise drop the anchor. - this._expandedSpiderAnchor = { - stackKey: this._expandedDuplicateStackKey, - x: anchorPoint.x, - y: anchorPoint.y, - }; - this._applyExpandedSpiderAnchor(); - } else { - this._expandedSpiderAnchor = null; - } - - this._updateDuplicateOverlays(); - } - - private _applyExpandedSpiderAnchor(): void { - const anchor = this._expandedSpiderAnchor; - if (!anchor || !this._scales) return; - if (anchor.stackKey !== this._expandedDuplicateStackKey) return; - const stack = this._duplicateStackByKey.get(anchor.stackKey); - if (!stack) return; - stack.x = anchor.x; - stack.y = anchor.y; - stack.px = this._scales.x(anchor.x); - stack.py = this._scales.y(anchor.y); + const overlayGroup = this._interaction?.overlayGroup; + if (!overlayGroup) return; + // The selected-overlay clear stays on the host; the duplicate-stack/spiderfy/ + // badge update is owned by the controller (F-06). + overlayGroup.selectAll('.selected-overlay').remove(); + this._dupOverlay.updateSelectionOverlays(options); } private _getPointShape(point: PlotDataPoint): string { @@ -2197,16 +1519,6 @@ export class ProtspaceScatterplot extends LitElement { return getters.getDepth(point); } - private _getStrokeColor(point: PlotDataPoint): string { - const getters = this._getStyleGetters(); - return getters.getStrokeColor(point); - } - - private _getStrokeWidth(point: PlotDataPoint): number { - const getters = this._getStyleGetters(); - return getters.getStrokeWidth(point); - } - /** Build style getters for the current data and visual state. */ private _buildStyleGetters(): ReturnType { const styleData = @@ -2312,16 +1624,6 @@ export class ProtspaceScatterplot extends LitElement { /** * Setup event handling for canvas-based rendering */ - private _setupCanvasEventHandling(): void { - if (!this._svgSelection) return; - - // Use event delegation on the SVG overlay for canvas interactions - this._svgSelection - .on('mousemove.canvas', (event) => this._handleCanvasMouseMove(event)) - .on('click.canvas', (event) => this._handleCanvasClick(event)) - .on('mouseout.canvas', () => this._handleCanvasMouseOut()); - } - /** * Handle mouse move events for canvas rendering. * Coalesces rapid mousemoves to at most one hover computation per animation frame. @@ -2342,48 +1644,52 @@ export class ProtspaceScatterplot extends LitElement { } /** - * Deferred hover processing — runs inside a rAF scheduled by _handleCanvasMouseMove. - * Behaviour is identical to the former per-event body; only the call frequency is throttled. + * Shared screen→data hit-test for hover and click (F-28). Resolves the nearest + * INTERACTIVE, currently-RENDERED point under the cursor, or null. Owns the + * transform inversion, quadtree `findNearest`, the isInteractive/isPointRendered + * guards, and the within-radius distance check. Callers branch only on the result. */ - private _processCanvasHover(event: MouseEvent, mouseX: number, mouseY: number): void { - if (!this._scales) return; // may have been cleared between scheduling and the frame + pickInteractivePointAt(mouseX: number, mouseY: number): PlotDataPoint | null { + if (!this._scales) return null; // Transform mouse coordinates to data space const dataX = (mouseX - this._transform.x) / this._transform.k; const dataY = (mouseY - this._transform.y) / this._transform.k; - // Find nearest slot using spatial index - const searchRadius = 15 / this._transform.k; // Search radius adjusted for zoom + // Find nearest slot using spatial index (search radius adjusted for zoom) + const searchRadius = HIT_TEST_SEARCH_RADIUS_PX / this._transform.k; const nearestSlot = this._quadtreeIndex.findNearest(dataX, dataY, searchRadius); + if (nearestSlot < 0) return null; - if (nearestSlot >= 0) { - const nearestPoint = materializePlotDataPoint(this._plotData, nearestSlot); + const nearestPoint = materializePlotDataPoint(this._plotData, nearestSlot); - // Don't hover non-interactive points (hidden/faded-to-0 → opacity 0) - if (!this._getVisibilityModel().isInteractive(nearestPoint)) { - this._clearHoverState(); - return; - } + // Don't pick non-interactive points (hidden/faded-to-0 → opacity 0) + if (!this._getVisibilityModel().isInteractive(nearestPoint)) return null; - // Verify the point is actually rendered (not excluded due to point limits) - if (this._webglRenderer && !this._webglRenderer.isPointRendered(nearestPoint.id)) { - // Point exists in spatial index but isn't rendered - clear tooltip - this._clearHoverState(); - return; - } + // Verify the point is actually rendered (not excluded due to point limits) + if (this._webglRenderer && !this._webglRenderer.isPointRendered(nearestPoint.id)) { + return null; + } - // Calculate actual distance to verify it's within the point - const pointX = this._scales.x(nearestPoint.x); - const pointY = this._scales.y(nearestPoint.y); - const distance = Math.sqrt(Math.pow(dataX - pointX, 2) + Math.pow(dataY - pointY, 2)); - const pointRadius = Math.sqrt(this._getPointSize(nearestPoint)) / 3; + // Calculate actual distance to verify it's within the point + const pointX = this._scales.x(nearestPoint.x); + const pointY = this._scales.y(nearestPoint.y); + const distance = Math.sqrt(Math.pow(dataX - pointX, 2) + Math.pow(dataY - pointY, 2)); + const pointRadius = Math.sqrt(this._getPointSize(nearestPoint)) / POINT_RADIUS_SIZE_DIVISOR; - if (distance <= pointRadius) { - this._handleMouseOver(event, nearestPoint); - return; - } - } + return distance <= pointRadius ? nearestPoint : null; + } + /** + * Deferred hover processing — runs inside a rAF scheduled by _handleCanvasMouseMove. + * Behaviour is identical to the former per-event body; only the call frequency is throttled. + */ + private _processCanvasHover(event: MouseEvent, mouseX: number, mouseY: number): void { + const point = this.pickInteractivePointAt(mouseX, mouseY); + if (point) { + this._handleMouseOver(event, point); + return; + } // No point found, clear hover state if it exists this._clearHoverState(); } @@ -2402,59 +1708,20 @@ export class ProtspaceScatterplot extends LitElement { } // Clicking anywhere outside the expanded stack collapses it. - const hadExpanded = !!this._expandedDuplicateStackKey; - if (this._expandedDuplicateStackKey) { - this._expandedDuplicateStackKey = null; - this._updateDuplicateOverlays(); - } + const hadExpanded = this._dupOverlay.collapseExpanded(); const [mouseX, mouseY] = d3.pointer(event); + const nearestPoint = this.pickInteractivePointAt(mouseX, mouseY); + if (!nearestPoint) return; - // Transform mouse coordinates to data space - const dataX = (mouseX - this._transform.x) / this._transform.k; - const dataY = (mouseY - this._transform.y) / this._transform.k; + // If this point belongs to a duplicate stack, spiderfy instead of picking an arbitrary member. + if (this._dupOverlay.maybeSpiderfyPoint(nearestPoint)) return; - // Find nearest slot using spatial index - const searchRadius = 15 / this._transform.k; - const nearestSlot = this._quadtreeIndex.findNearest(dataX, dataY, searchRadius); + // If we just collapsed an expanded stack, treat this click as a "dismiss" click. + // This prevents accidental selection when the user is simply trying to close the spiderfy UI. + if (hadExpanded) return; - if (nearestSlot >= 0) { - const nearestPoint = materializePlotDataPoint(this._plotData, nearestSlot); - - // Don't click non-interactive points (hidden/faded-to-0 → opacity 0) - if (!this._getVisibilityModel().isInteractive(nearestPoint)) { - return; - } - - // Verify the point is actually rendered (not excluded due to point limits) - if (this._webglRenderer && !this._webglRenderer.isPointRendered(nearestPoint.id)) { - return; - } - - // Calculate actual distance to verify it's within the point - const pointX = this._scales.x(nearestPoint.x); - const pointY = this._scales.y(nearestPoint.y); - const distance = Math.sqrt(Math.pow(dataX - pointX, 2) + Math.pow(dataY - pointY, 2)); - const pointRadius = Math.sqrt(this._getPointSize(nearestPoint)) / 3; - - if (distance <= pointRadius) { - if (this._isDuplicateStackUIEnabled()) { - // If this point belongs to a duplicate stack, spiderfy instead of picking an arbitrary member. - const stackKey = this._pointIdToDuplicateStackKey.get(nearestPoint.id); - const stack = stackKey ? this._duplicateStackByKey.get(stackKey) : undefined; - if (stack && stack.points.length > 1) { - this._toggleSpiderfy(stack.key, nearestPoint); - return; - } - } - - // If we just collapsed an expanded stack, treat this click as a "dismiss" click. - // This prevents accidental selection when the user is simply trying to close the spiderfy UI. - if (hadExpanded) return; - - this._handleClick(event, nearestPoint); - } - } + this._handleClick(event, nearestPoint); } /** @@ -2487,9 +1754,7 @@ export class ProtspaceScatterplot extends LitElement { } resetZoom() { - if (this._zoom && this._svgSelection) { - this._svgSelection.transition().duration(750).call(this._zoom.transform, d3.zoomIdentity); - } + this._interaction?.resetZoom(); } /** @@ -2499,7 +1764,7 @@ export class ProtspaceScatterplot extends LitElement { * helper so the logic is unit-testable without a DOM. */ private _estimateTooltipHeight(): number { - if (!this._tooltipData) return 160; + if (!this._tooltipData) return TOOLTIP_FALLBACK_HEIGHT; return estimateTooltipHeight(this._tooltipData.view); } @@ -2508,40 +1773,13 @@ export class ProtspaceScatterplot extends LitElement { const { x, y } = this._tooltipData; const config = this._mergedConfig; - const padding = 15; - const tooltipMaxWidth = 350; - const tooltipHeight = this._tooltipHeight ?? this._estimateTooltipHeight(); - - let left = x + 15; - let top = y - 60; - let transform = ''; - - // Horizontal adjustment: if it goes off the right edge, flip to the left side - if (left + tooltipMaxWidth > config.width) { - // Position anchor at x - 15 and use translateX(-100%) to pull it to the left - // this ensures the right edge of the tooltip is close to the mouse regardless of width - left = x - 15; - transform = 'translateX(-100%)'; - } - - // Keep within horizontal bounds (left side) - if (!transform && left < padding) { - left = padding; - } else if (transform && left - tooltipMaxWidth < padding) { - // If flipped to the left and would go off the left edge, clamp it - left = tooltipMaxWidth + padding; - } - - // Vertical adjustment: clamp to viewport using the measured height when - // available, so tall multi-annotation tooltips do not run off the bottom. - if (top + tooltipHeight > config.height - padding) { - top = config.height - tooltipHeight - padding; - } - if (top < padding) { - top = padding; - } - - return `left: ${left}px; top: ${top}px;${transform ? ` transform: ${transform};` : ''}`; + return computeTooltipStyle({ + x, + y, + height: this._tooltipHeight ?? this._estimateTooltipHeight(), + viewportWidth: config.width, + viewportHeight: config.height, + }); } render() { @@ -2616,10 +1854,11 @@ export class ProtspaceScatterplot extends LitElement { ${this.data ? html`
${this._getVisiblePointCount()} points
` : ''} - ${this._numericRecomputeState.running + ${this._numericRecomputeRunning ? html`
- Recalculating bins for ${this._numericRecomputeState.annotation ?? 'annotation'}... + Recalculating bins for + ${this._numericRecompute.runningAnnotation() ?? 'annotation'}...
` : ''} @@ -2633,6 +1872,31 @@ export class ProtspaceScatterplot extends LitElement { this._styleSig = parts.join('|'); } + /** + * Shared isolation render-refresh: reprocess derived plot data, rebuild the + * quadtree, invalidate + re-sign the WebGL renderer's caches, request a Lit + * update, and render once the update settles. Called by isolateSelection() and + * resetIsolation() — the only divergence (resetIsolation clears _lastDataRef to + * force the full-rebuild path) stays at the call site, before this method runs. + */ + private _reprocessAndRefresh(): void { + this._processData(); + this._buildQuadtree(); + + if (this._webglRenderer) { + this._webglRenderer.invalidatePositionCache(); + this._webglRenderer.invalidateStyleCache(); + this._updateStyleSignature(); + this._webglRenderer.setStyleSignature(this._styleSig); + } + + this.requestUpdate(); + + this.updateComplete.then(() => { + this._renderPlot(); + }); + } + isolateSelection() { if (!this.data || this.selectedProteinIds.length === 0) { return; @@ -2653,25 +1917,7 @@ export class ProtspaceScatterplot extends LitElement { this._isolationMode = true; this.selectedProteinIds = []; - // Process data and update rendering - this._processData(); - this._buildQuadtree(); - - // Ensure canvas renderer is completely refreshed - if (this._webglRenderer) { - this._webglRenderer.invalidatePositionCache(); - this._webglRenderer.invalidateStyleCache(); - this._updateStyleSignature(); - this._webglRenderer.setStyleSignature(this._styleSig); - } - - // Force immediate component update - this.requestUpdate(); - - // Render after all updates are complete - this.updateComplete.then(() => { - this._renderPlot(); - }); + this._reprocessAndRefresh(); this.dispatchEvent( new CustomEvent('data-isolation', { @@ -2716,15 +1962,12 @@ export class ProtspaceScatterplot extends LitElement { /** True when a duplicate-badge spider is currently expanded. */ hasExpandedDuplicateStack(): boolean { - return this._expandedDuplicateStackKey !== null; + return this._dupOverlay.hasExpanded(); } /** Collapse the currently-open duplicate-badge spider, if any. */ closeExpandedDuplicateStack(): void { - if (this._expandedDuplicateStackKey === null) return; - this._expandedDuplicateStackKey = null; - this._expandedSpiderAnchor = null; - this._updateDuplicateOverlays(); + this._dupOverlay.closeExpanded(); } /** @@ -2759,25 +2002,7 @@ export class ProtspaceScatterplot extends LitElement { // instead of the fast coordinate-only path (which would keep the filtered subset) this._lastDataRef = null; - // Process data and update rendering - this._processData(); - this._buildQuadtree(); - - // Ensure canvas renderer is completely refreshed - if (this._webglRenderer) { - this._webglRenderer.invalidatePositionCache(); - this._webglRenderer.invalidateStyleCache(); - this._updateStyleSignature(); - this._webglRenderer.setStyleSignature(this._styleSig); - } - - // Force immediate component update - this.requestUpdate(); - - // Render after all updates are complete - this.updateComplete.then(() => { - this._renderPlot(); - }); + this._reprocessAndRefresh(); this.dispatchEvent( new CustomEvent('data-isolation-reset', { @@ -2841,44 +2066,14 @@ export class ProtspaceScatterplot extends LitElement { }); } - // Filter annotation data to match current protein IDs - const filteredAnnotationData: Record = {}; - const filteredNumericAnnotationData: { [key: string]: (number | null)[] } = {}; - - for (const [annotationName, annotationValues] of Object.entries( - currentDisplayData.annotation_data, - )) { - filteredAnnotationData[annotationName] = sliceAnnotationData(annotationValues, keptIndices); - } - - for (const [annotationName, annotationValues] of Object.entries( - currentDisplayData.numeric_annotation_data ?? {}, - )) { - const sliced: (number | null)[] = new Array(keptIndices.length); - for (let k = 0; k < keptIndices.length; k++) { - sliced[k] = annotationValues[keptIndices[k]]; - } - filteredNumericAnnotationData[annotationName] = sliced; - } - - return { - ...currentDisplayData, - protein_ids: currentProteinIds, - annotation_data: filteredAnnotationData, - numeric_annotation_data: filteredNumericAnnotationData, - projections: currentDisplayData.projections.map((projection) => { - const dim = projection.dimension; - const out = new Float32Array(keptIndices.length * dim); - for (let k = 0; k < keptIndices.length; k++) { - const base = keptIndices[k] * dim; - const o = k * dim; - out[o] = projection.data[base]; - out[o + 1] = projection.data[base + 1]; - if (dim === 3) out[o + 2] = projection.data[base + 2]; - } - return { ...projection, data: out, dimension: dim }; - }), - }; + // Delegate the per-index slice to the shared helper (same construction the + // filtered-display path uses), then override protein_ids with the + // plotDataId-ordered current ids exactly as before. This also reslices + // annotation_scores/annotation_evidence consistently, silently correcting + // the prior isolation-mode misalignment (sanctioned by F-13; no consumer + // indexes scores/evidence off this result, so INV-04 holds). + const sliced = sliceVisualizationDataByIndices(currentDisplayData, keptIndices); + return { ...sliced, protein_ids: currentProteinIds }; } return currentDisplayData; diff --git a/packages/core/src/components/scatter-plot/scatter-plot.visibility-model-memo.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.visibility-model-memo.test.ts new file mode 100644 index 00000000..8c04011d --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.visibility-model-memo.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +/** + * F-27 characterization LOCK — `_getVisibilityModel` 8-field memo key. + * + * `_getVisibilityModel` (scatter-plot.ts L2073-2124) caches one VisibilityModel + * instance keyed by reference/value identity on 8 inputs: + * data, selectedAnnotation, hiddenAnnotationValues, selectedProteinIds, + * highlightedProteinIds, baseOpacity, selectedOpacity, fadedOpacity. + * (read L2088-2095, store L2113-2122). + * + * Leave every key field unchanged -> cache HIT (same model instance). + * Flip ANY one field to a new reference/value -> cache MISS (fresh model). + * This catches a missing or extra key field introduced by any B6/B8/B10 refactor. + * + * The element is created via createElement WITHOUT being appended (mirrors the + * neighbor locks scatter-plot.materialize-cache.test.ts / filter-render): Lit's + * connectedCallback + reactive update cycle never run, so we drive the component + * by setting public reactive props directly and calling the internal methods. + * + * NAME ADJUSTMENTS vs the plan sketch (assertions unchanged): + * - Real VisualizationData uses `annotations` + `annotation_data` (index arrays), + * NOT `features`/`feature_data`; projections carry `dimension`, not + * `metadata.dimensions`. Fixture mirrors makeFamilyData in the + * scatter-plot.materialize-cache.test.ts neighbor, with a 2nd `other` + * annotation so the selectedAnnotation flip is legal. + * - selectedAnnotation flip targets the real 2nd annotation `'other'` (not 'fam2'). + * - The 3 opacity fields are read from `this._mergedConfig` (L2080-2082). On an + * unattached element Lit's `updated()` lifecycle — where `config` is merged + * into `_mergedConfig` (L610-612) — never runs, so a synchronous `config` + * write would NOT change `_mergedConfig` and the flip would not be observable. + * We therefore flip `_mergedConfig` directly: it IS the genuine memo-key + * source the getter reads, so the assertion (MISS on change) is unchanged. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VisualizationData } from '@protspace/utils'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); +import './scatter-plot'; + +type Internals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + hiddenAnnotationValues: string[]; + selectedProteinIds: string[]; + highlightedProteinIds: string[]; + _mergedConfig: { baseOpacity: number; selectedOpacity: number; fadedOpacity: number }; + _processData(): void; + _getVisibilityModel(): object; +}; + +const RED = '#ff0000'; +const GREEN = '#00ff00'; + +/** + * Categorical fixture: p0–p2 family "A", p3–p5 family "B", plus a second + * categorical annotation `other` so the selectedAnnotation flip is legal. + * Shape mirrors makeFamilyData in scatter-plot.materialize-cache.test.ts. + */ +function famData(): VisualizationData { + const families = ['A', 'A', 'A', 'B', 'B', 'B']; + const colorFor = (v: string) => (v === 'A' ? RED : GREEN); + const coords = new Float32Array(families.length * 2); + families.forEach((_, i) => { + coords[i * 2] = i; + coords[i * 2 + 1] = i; + }); + return { + protein_ids: families.map((_, i) => `p${i}`), + projections: [{ name: 'umap', data: coords, dimension: 2 }], + annotations: { + fam: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + other: { + values: families, + colors: families.map(colorFor), + shapes: families.map(() => 'circle'), + }, + }, + annotation_data: { + fam: families.map((v) => [families.indexOf(v)]), + other: families.map((v) => [families.indexOf(v)]), + }, + } as unknown as VisualizationData; +} + +describe('_getVisibilityModel memo key (F-27 characterization lock)', () => { + function primed(): Internals { + const sp = document.createElement('protspace-scatterplot') as Internals; + sp.data = famData(); + sp.selectedAnnotation = 'fam'; + sp.hiddenAnnotationValues = []; + sp.selectedProteinIds = []; + sp.highlightedProteinIds = []; + sp._processData(); + return sp; + } + + it('no input change → cache HIT (same model instance)', () => { + const sp = primed(); + expect(sp._getVisibilityModel()).toBe(sp._getVisibilityModel()); + }); + + // Each key field, when flipped to a NEW reference/value, must produce a cache MISS. + const flips: Array<[string, (sp: Internals) => void]> = [ + [ + 'hiddenAnnotationValues', + (sp) => { + sp.hiddenAnnotationValues = ['A']; + }, + ], + [ + 'selectedProteinIds', + (sp) => { + sp.selectedProteinIds = ['p0']; + }, + ], + [ + 'highlightedProteinIds', + (sp) => { + sp.highlightedProteinIds = ['p1']; + }, + ], + [ + 'selectedAnnotation', + (sp) => { + sp.selectedAnnotation = 'other'; + }, + ], + [ + 'baseOpacity', + (sp) => { + sp._mergedConfig = { ...sp._mergedConfig, baseOpacity: 0.5 }; + }, + ], + [ + 'selectedOpacity', + (sp) => { + sp._mergedConfig = { ...sp._mergedConfig, selectedOpacity: 0.9 }; + }, + ], + [ + 'fadedOpacity', + (sp) => { + sp._mergedConfig = { ...sp._mergedConfig, fadedOpacity: 0.1 }; + }, + ], + ]; + + for (const [field, flip] of flips) { + it(`flipping ${field} → cache MISS (fresh model)`, () => { + const sp = primed(); + const before = sp._getVisibilityModel(); + flip(sp); + expect(sp._getVisibilityModel()).not.toBe(before); + }); + } +}); diff --git a/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.test.ts b/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.test.ts new file mode 100644 index 00000000..980e8b4e --- /dev/null +++ b/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.test.ts @@ -0,0 +1,149 @@ +// F-04: NumericRecomputeRunner unit characterization. +// +// The runner owns the numeric-annotation recompute lifecycle extracted verbatim +// from `_scheduleNumericAnnotationRefresh` (scatter-plot.ts L839-915): +// - a monotonically-increasing job id captured per schedule(), +// - a synchronous requestUpdate + running-state mirror (setRunning(true)), +// - a deferred (RAF) body that bails when its job id was superseded +// (the B7/F-23 last-write-wins stale-job drop), runs the component-supplied +// `runRecompute()` tail, then clears the running-state mirror, +// - a running-state mirror pushed to the host via setRunning(), +// - cancel() that drops a pending RAF and invalidates any in-flight job. +// +// (F-46) The previously-dispatched `numeric-recompute-start` / `-end` +// CustomEvents were unconsumed public surface and have been removed; the busy +// state is now characterized solely via the `setRunning` mirror, the runner's +// `runningAnnotation()`, and the `runRecompute()` body call. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NumericRecomputeRunner } from './numeric-recompute-runner'; + +function makeHost() { + const running: boolean[] = []; + return { + running, + selectedAnnotation: 'plddt', + hasData: () => true, + getSelectedAnnotation() { + return this.selectedAnnotation; + }, + // F-57: the runner no longer issues an explicit host.requestUpdate(); the Lit + // update is scheduled by the host's `_numericRecomputeRunning` @state setter, + // which `setRunning` writes. The busy state is the only scheduling signal. + setRunning: (r: boolean) => running.push(r), + runRecompute: vi.fn(), + }; +} + +describe('NumericRecomputeRunner', () => { + let raf: Array<() => void>; + beforeEach(() => { + raf = []; + vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { + raf.push(cb); + return raf.length; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + raf[id - 1] = () => {}; + }); + vi.stubGlobal('performance', { now: () => 1000 }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does nothing when the host has no data', () => { + const host = makeHost(); + host.hasData = () => false; + const r = new NumericRecomputeRunner(host); + r.schedule(); + expect(host.running).toHaveLength(0); + expect(r.runningAnnotation()).toBe(null); + }); + + it('enters the running state synchronously (F-57: no explicit requestUpdate)', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + expect(host.running[0]).toBe(true); + expect(r.runningAnnotation()).toBe('plddt'); + }); + + it('runs the body in the RAF and clears the running state', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + raf.forEach((cb) => cb()); + expect(host.runRecompute).toHaveBeenCalledTimes(1); + expect(r.runningAnnotation()).toBe(null); + // running mirror toggled true (schedule) then false (job end) + expect(host.running).toEqual([true, false]); + }); + + it('running annotation is captured from the host at schedule() time', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + expect(r.runningAnnotation()).toBe('plddt'); + host.selectedAnnotation = 'charge'; // changes after schedule(), before the RAF + raf.forEach((cb) => cb()); + expect(r.runningAnnotation()).toBe(null); + }); + + it('runs the body before clearing the running state', () => { + const host = makeHost(); + const order: string[] = []; + host.runRecompute = vi.fn(() => order.push('body')); + host.setRunning = (running: boolean) => order.push(running ? 'running:true' : 'running:false'); + const r = new NumericRecomputeRunner(host); + r.schedule(); + raf.forEach((cb) => cb()); + expect(order).toEqual(['running:true', 'body', 'running:false']); + }); + + it('a superseding schedule() invalidates the prior job (stale RAF is a no-op)', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + r.schedule(); + raf[0](); // stale job → bails, no body + expect(host.runRecompute).not.toHaveBeenCalled(); + expect(r.runningAnnotation()).not.toBe(null); // still busy: the current job hasn't run + raf[1](); // current job → completes + expect(host.runRecompute).toHaveBeenCalledTimes(1); + expect(r.runningAnnotation()).toBe(null); + }); + + it('two overlapping schedules run the body exactly once (last-write-wins)', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + r.schedule(); + raf.forEach((cb) => cb()); + // setRunning(true) twice (one per schedule) but only one job completes → one false + expect(host.running.filter((x) => x === true)).toHaveLength(2); + expect(host.running.filter((x) => x === false)).toHaveLength(1); + expect(host.runRecompute).toHaveBeenCalledTimes(1); + }); + + it('cancel() stops a pending RAF and clears running state', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + r.cancel(); + raf.forEach((cb) => cb()); + expect(host.runRecompute).not.toHaveBeenCalled(); + expect(r.runningAnnotation()).toBe(null); + expect(host.running).toEqual([true, false]); // schedule set true, cancel set false + }); + + it('cancel() invalidates an in-flight job whose RAF already fired its handle clear', () => { + const host = makeHost(); + const r = new NumericRecomputeRunner(host); + r.schedule(); + r.cancel(); + r.schedule(); // a fresh job after cancel still works + raf[1](); // first job's RAF was cancelled; run the second + expect(host.runRecompute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.ts b/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.ts new file mode 100644 index 00000000..126e9db3 --- /dev/null +++ b/packages/core/src/components/scatter-plot/styling/numeric-recompute-runner.ts @@ -0,0 +1,86 @@ +/** + * F-04: NumericRecomputeRunner + * + * Owns the numeric-annotation recompute lifecycle extracted verbatim from + * `ProtspaceScatterplot._scheduleNumericAnnotationRefresh` (scatter-plot.ts + * L839-915): the per-schedule job id, the deferred (requestAnimationFrame) + * heavy-recompute tail, the stale-job drop (B7/F-23 last-write-wins), the + * running-state mirror, and cancel-on-teardown (F-05). + * + * (F-46) The previously-dispatched `numeric-recompute-start` / `-end` + * CustomEvents were unconsumed public surface and have been removed; the busy + * state is now observable solely via the `setRunning` host mirror and the + * runner's own `runningAnnotation()`. + * + * The component supplies the data-refresh routing + lifecycle-bound render tail + * + the `data-change` re-emit via `runRecompute()`; that body stays in the + * component because the three render sequences differ and must not be unified. + * + * Behavior preserved exactly from the original inline implementation: + * - schedule() bails (no state change) when the host has no data. + * - The job id is bumped + captured per schedule(); the RAF body bails when + * the captured id was superseded — a superseded (older) job runs no body and + * leaves the busy state untouched. + * - The body runs inside the deferred RAF for the current job; running state + * resets after the body, followed by a requestUpdate(). + */ +interface NumericRecomputeHost { + /** Whether the host currently holds data (gates schedule). */ + hasData(): boolean; + /** The active annotation, re-read at start and at end (matches production). */ + getSelectedAnnotation(): string; + /** Mirror the running flag onto the host's reactive `@state` (also schedules Lit update). */ + setRunning(running: boolean): void; + /** + * Component-owned data-refresh routing + lifecycle-bound render tail + the + * `data-change` re-emit. Runs inside the deferred RAF for the current job. + */ + runRecompute(): void; +} + +export class NumericRecomputeRunner { + private _jobId = 0; + private _rafId: number | null = null; + private _annotation: string | null = null; + + constructor(private readonly _host: NumericRecomputeHost) {} + + runningAnnotation(): string | null { + return this._annotation; + } + + schedule(): void { + if (!this._host.hasData()) return; + + const annotation = this._host.getSelectedAnnotation(); + const jobId = ++this._jobId; + this._annotation = annotation; + // F-57: setRunning writes the `_numericRecomputeRunning` @state mirror, whose + // reactive setter already schedules a Lit update — an explicit requestUpdate() + // here was redundant and has been dropped. + this._host.setRunning(true); + + if (this._rafId !== null) cancelAnimationFrame(this._rafId); + this._rafId = requestAnimationFrame(() => { + this._rafId = null; + if (jobId !== this._jobId) return; // superseded — last-write-wins + + this._host.runRecompute(); + + this._annotation = null; + // F-57: the setRunning @state-mirror write schedules the Lit update; the + // explicit requestUpdate() that used to follow was redundant. + this._host.setRunning(false); + }); + } + + cancel(): void { + if (this._rafId !== null) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + this._jobId++; // invalidate any in-flight job + this._annotation = null; + this._host.setRunning(false); + } +} diff --git a/packages/core/src/components/scatter-plot/style-getters.test.ts b/packages/core/src/components/scatter-plot/styling/style-getters.test.ts similarity index 90% rename from packages/core/src/components/scatter-plot/style-getters.test.ts rename to packages/core/src/components/scatter-plot/styling/style-getters.test.ts index c4260342..5163d48b 100644 --- a/packages/core/src/components/scatter-plot/style-getters.test.ts +++ b/packages/core/src/components/scatter-plot/styling/style-getters.test.ts @@ -592,4 +592,61 @@ describe('style-getters', () => { expect(getOpacity(createMockPoint('p1', 1))).toBe(cfg.opacities.base); }); }); + + // ── F-44 removal guard: dead stroke getters are gone, live getters survive ── + // The GPU draws strokes from a hardcoded fragment-shader constant + // (strokeWidth = 0.15 in webgl-renderer.ts), so createStyleGetters' stroke + // getters were never consumed. This guard locks their removal and proves the + // five live keys remain present. + describe('F-44: createStyleGetters does not expose stroke getters', () => { + const createMockData = (annotationValues: string[]): VisualizationData => ({ + protein_ids: annotationValues.map((_, i) => `protein_${i}`), + projections: [ + { name: 'test', data: new Float32Array(annotationValues.length * 3), dimension: 3 }, + ], + annotations: { + test_annotation: { + kind: 'categorical', + values: annotationValues, + colors: annotationValues.map(() => '#ff0000'), + shapes: annotationValues.map(() => 'circle'), + }, + }, + annotation_data: { + test_annotation: annotationValues.map((_, i) => [i]), + }, + }); + + const createDefaultStyleConfig = (): StyleConfig => ({ + selectedProteinIds: [], + highlightedProteinIds: [], + selectedAnnotation: 'test_annotation', + hiddenAnnotationValues: [], + otherAnnotationValues: [], + zOrderMapping: null, + colorMapping: null, + shapeMapping: null, + sizes: { base: 10 }, + opacities: { base: 1, selected: 1, faded: 0.3 }, + }); + + it('returns no getStrokeColor / getStrokeWidth, but keeps the live getters', () => { + const getters = createStyleGetters( + createMockData(['catA', 'catB']), + createDefaultStyleConfig(), + ); + const surface = getters as Record; + + // Dead getters removed. + expect(surface.getStrokeColor).toBeUndefined(); + expect(surface.getStrokeWidth).toBeUndefined(); + + // Live getters preserved. + expect(typeof surface.getColors).toBe('function'); + expect(typeof surface.getPointSize).toBe('function'); + expect(typeof surface.getOpacity).toBe('function'); + expect(typeof surface.getDepth).toBe('function'); + expect(typeof surface.getPointShape).toBe('function'); + }); + }); }); diff --git a/packages/core/src/components/scatter-plot/style-getters.ts b/packages/core/src/components/scatter-plot/styling/style-getters.ts similarity index 94% rename from packages/core/src/components/scatter-plot/style-getters.ts rename to packages/core/src/components/scatter-plot/styling/style-getters.ts index ff41bcef..729fbabb 100644 --- a/packages/core/src/components/scatter-plot/style-getters.ts +++ b/packages/core/src/components/scatter-plot/styling/style-getters.ts @@ -1,4 +1,4 @@ -import { NEUTRAL_VALUE_COLOR } from './config'; +import { NEUTRAL_VALUE_COLOR } from '../config'; import type { PlotDataPoint, VisualizationData } from '@protspace/utils'; import { getProteinAnnotationValues, @@ -177,9 +177,16 @@ export function createStyleGetters( // Precompute normalization for z-order mapping so getDepth is cheap. const zMap = styleConfig.zOrderMapping ?? null; + // reduce (not Math.max(...spread)): a legend with tens of thousands of + // categories would blow the argument-count limit and throw RangeError. + // `-Infinity` start matches Math.max over an empty filtered set; downstream + // `zMax > 0` guards treat that the same as the old code. const zMax = zMap && Object.keys(zMap).length > 0 - ? Math.max(...Object.values(zMap).filter((v) => typeof v === 'number' && Number.isFinite(v))) + ? Object.values(zMap).reduce( + (max, v) => (typeof v === 'number' && Number.isFinite(v) && v > max ? v : max), + -Infinity, + ) : 0; const Z_EPS = 1e-3; // must be small enough to not override opacity-based depth differences @@ -234,21 +241,11 @@ export function createStyleGetters( return Math.min(1, Math.max(0, depth)); }; - const getStrokeColor = (_point: PlotDataPoint): string => { - return 'var(--protspace-default-stroke, #333333)'; - }; - - const getStrokeWidth = (_point: PlotDataPoint): number => { - return 1; - }; - return { getPointSize, getPointShape, getColors, getOpacity, getDepth, - getStrokeColor, - getStrokeWidth, }; } diff --git a/packages/core/src/components/scatter-plot/visibility-model.test.ts b/packages/core/src/components/scatter-plot/styling/visibility-model.test.ts similarity index 92% rename from packages/core/src/components/scatter-plot/visibility-model.test.ts rename to packages/core/src/components/scatter-plot/styling/visibility-model.test.ts index b4a6dd22..da7823b5 100644 --- a/packages/core/src/components/scatter-plot/visibility-model.test.ts +++ b/packages/core/src/components/scatter-plot/styling/visibility-model.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { VisualizationData, PlotDataPoint, AnnotationData } from '@protspace/utils'; -import type { DisplayTier, VisibilityInputs, VisibilityModel } from './visibility-model'; +import type { VisibilityInputs, VisibilityModel } from './visibility-model'; import { computeVisibilityModel } from './visibility-model'; /** @@ -86,17 +86,15 @@ describe('computeVisibilityModel', () => { baseInputs({ data, hiddenAnnotationValues: ['A'], selectedProteinIds: ['p0'] }), ); expect(model.opacityOf(point('p0', 0))).toBe(0); - expect(model.tierOf(point('p0', 0))).toBe('hidden'); expect(model.isInteractive(point('p0', 0))).toBe(false); }); - it('a HIGHLIGHTED point whose only value is hidden → opacity 0, tier hidden', () => { + it('a HIGHLIGHTED point whose only value is hidden → opacity 0', () => { const data = makeData(['A', 'B'], Int32Array.of(0, 1)); const model = computeVisibilityModel( baseInputs({ data, hiddenAnnotationValues: ['A'], highlightedProteinIds: ['p0'] }), ); expect(model.opacityOf(point('p0', 0))).toBe(0); - expect(model.tierOf(point('p0', 0))).toBe('hidden'); }); }); @@ -107,7 +105,6 @@ describe('computeVisibilityModel', () => { const data = makeData(['A', 'B'], [[0, 1], [1]]); const model = computeVisibilityModel(baseInputs({ data, hiddenAnnotationValues: ['A'] })); expect(model.opacityOf(point('p0', 0))).toBe(OPACITIES.base); - expect(model.tierOf(point('p0', 0))).toBe('base'); }); it('fully hidden multilabel point → opacity 0', () => { @@ -138,7 +135,6 @@ describe('computeVisibilityModel', () => { const data = makeData(['A', 'B'], Int32Array.of(0, -1)); const model = computeVisibilityModel(baseInputs({ data, hiddenAnnotationValues: [] })); expect(model.opacityOf(point('p1', 1))).toBe(0); - expect(model.tierOf(point('p1', 1))).toBe('hidden'); }); it('nested empty row [] → hidden even with empty hidden set', () => { @@ -195,7 +191,6 @@ describe('computeVisibilityModel', () => { const model = computeVisibilityModel(baseInputs({ data, selectedProteinIds: ['p0'] })); expect(model.opacityOf(point('p0', 0))).toBe(OPACITIES.selected); expect(model.opacityOf(point('p1', 1))).toBe(OPACITIES.faded); - expect(model.tierOf(point('p1', 1))).toBe('faded'); }); it('highlight-only: highlighted gets selected opacity, others stay base (no fade)', () => { @@ -205,7 +200,6 @@ describe('computeVisibilityModel', () => { ); expect(model.opacityOf(point('p0', 0))).toBe(OPACITIES.selected); expect(model.opacityOf(point('p1', 1))).toBe(OPACITIES.base); // NOT faded - expect(model.tierOf(point('p1', 1))).toBe('base'); }); }); @@ -293,9 +287,8 @@ describe('computeVisibilityModel', () => { opacities: { base: 0.8, selected: 1.0, faded: 0 }, }), ); - // Still the FADED tier... - expect(model.tierOf(point('p1', 1))).toBe('faded'); - // ...but numerically non-interactive because opacity is 0. + // A non-selected point under an active selection is numerically + // non-interactive because its (faded) opacity is 0. expect(model.opacityOf(point('p1', 1))).toBe(0); expect(model.isInteractive(point('p1', 1))).toBe(false); }); @@ -307,9 +300,19 @@ describe('computeVisibilityModel', () => { }); }); - // ── tierOf never collapses selected into base ───────────────────────────── - describe('tierOf: never collapses selected into base', () => { - it('returns the full DisplayTier domain distinctly', () => { + // ── F-45 removal guard: tierOf / DisplayTier are gone ───────────────────── + // tierOf was a convenience view with no live readers; the opacity/depth + // contract carriers (opacityOf / baseOpacityOf / isInteractive) carry all + // behavior. This guard locks tierOf's removal while proving those carriers + // — and the full hidden/selected/faded/base distinctions they encode — remain. + describe('F-45: model exposes no tierOf, distinctions live in opacity carriers', () => { + it('the model surface has no tierOf member', () => { + const data = makeData(['A'], Int32Array.of(0)); + const model = computeVisibilityModel(baseInputs({ data })); + expect((model as Record).tierOf).toBeUndefined(); + }); + + it('hidden / selected / faded distinctions survive via opacityOf + isInteractive', () => { const data = makeData(['A', 'B', 'C', 'D'], Int32Array.of(0, 1, 2, 3)); const model = computeVisibilityModel( baseInputs({ @@ -318,22 +321,19 @@ describe('computeVisibilityModel', () => { selectedProteinIds: ['p1'], // p1 selected (=> p2,p3 faded) }), ); - const tiers: Record = { - p0: model.tierOf(point('p0', 0)), - p1: model.tierOf(point('p1', 1)), - p2: model.tierOf(point('p2', 2)), - }; - expect(tiers.p0).toBe('hidden'); - expect(tiers.p1).toBe('selected'); - expect(tiers.p2).toBe('faded'); - // selected is never reported as base - expect(model.tierOf(point('p1', 1))).not.toBe('base'); + // hidden → opacity 0, non-interactive + expect(model.opacityOf(point('p0', 0))).toBe(0); + expect(model.isInteractive(point('p0', 0))).toBe(false); + // selected → selected opacity (never collapsed into base) + expect(model.opacityOf(point('p1', 1))).toBe(OPACITIES.selected); + // faded → faded opacity (distinct from base and selected) + expect(model.opacityOf(point('p2', 2))).toBe(OPACITIES.faded); }); - it('base tier when no selection and not hidden', () => { + it('base opacity when no selection and not hidden', () => { const data = makeData(['A', 'B'], Int32Array.of(0, 1)); const model = computeVisibilityModel(baseInputs({ data })); - expect(model.tierOf(point('p0', 0))).toBe('base'); + expect(model.opacityOf(point('p0', 0))).toBe(OPACITIES.base); }); }); @@ -419,8 +419,6 @@ describe('computeVisibilityModel', () => { const data = makeData(['A'], Int32Array.of(0)); const inputs: VisibilityInputs = baseInputs({ data }); const model: VisibilityModel = computeVisibilityModel(inputs); - const tier: DisplayTier = model.tierOf(point('p0', 0)); - expect(['hidden', 'faded', 'base', 'selected']).toContain(tier); expect(typeof model.opacityOf(point('p0', 0))).toBe('number'); expect(typeof model.baseOpacityOf(point('p0', 0))).toBe('number'); expect(typeof model.isInteractive(point('p0', 0))).toBe('boolean'); diff --git a/packages/core/src/components/scatter-plot/visibility-model.ts b/packages/core/src/components/scatter-plot/styling/visibility-model.ts similarity index 91% rename from packages/core/src/components/scatter-plot/visibility-model.ts rename to packages/core/src/components/scatter-plot/styling/visibility-model.ts index 6468f9ab..d9d49e9e 100644 --- a/packages/core/src/components/scatter-plot/visibility-model.ts +++ b/packages/core/src/components/scatter-plot/styling/visibility-model.ts @@ -5,11 +5,12 @@ * interactivity). Pure and side-effect free: no DOM, no WebGL, no Lit — safe to * import under jsdom and from workers. * - * This module replicates the opacity semantics of `createStyleGetters` - * (`style-getters.ts` — `computeAllHidden`, `getBaseOpacity`, `getOpacity`) - * BIT-FOR-BIT. The authoritative contract is the design D5 table in - * `openspec/changes/unified-visibility-model/design.md`; the decisive reference - * is the current code, not intuition. Subtleties preserved on purpose: + * This module is the SINGLE authority for those opacity semantics: + * `style-getters.ts` delegates to it (`getBaseOpacity = visibility.baseOpacityOf`, + * `getOpacity = visibility.opacityOf`), so there is no second implementation to + * keep in lockstep. The authoritative contract is the design D5 table in + * `openspec/changes/unified-visibility-model/design.md`. Subtleties preserved on + * purpose: * * - Hidden ⇒ opacity exactly `0` (consumers agree only at exact 0). * - Hidden beats selected/highlighted. @@ -41,8 +42,6 @@ import type { } from '@protspace/utils'; import { toInternalValue } from '@protspace/utils'; -export type DisplayTier = 'hidden' | 'faded' | 'base' | 'selected'; - export interface VisibilityInputs { /** MATERIALIZED, un-query-filtered display data (keeps global indices). */ data: VisualizationData | null; @@ -57,8 +56,6 @@ export interface VisibilityInputs { export interface VisibilityModel { /** True when every value of the selected annotation is hidden (escape hatch). */ allHidden: boolean; - /** Display tier — hidden beats selected. Convenience view; never collapses selected into base. */ - tierOf(point: PlotDataPoint): DisplayTier; /** Render opacity — exactly `0` for hidden (load-bearing). */ opacityOf(point: PlotDataPoint): number; /** Base opacity, ignoring hidden — feeds depth sorting. */ @@ -68,7 +65,7 @@ export interface VisibilityModel { } /** - * Exact replica of `createStyleGetters`' `computeAllHidden`. Note the deliberate + * The single implementation of the all-hidden escape hatch. Note the deliberate * asymmetry: the hidden set is built from RAW strings while the annotation * values it tests are normalized. */ @@ -257,19 +254,10 @@ export function computeVisibilityModel( return baseOpacityOf(point); }; - const tierOf = (point: PlotDataPoint): DisplayTier => { - if (isHidden(point)) return 'hidden'; - const isSelected = selectedIdsSet.has(point.id); - if (isSelected || highlightedIdsSet.has(point.id)) return 'selected'; - if (hasSelection && !isSelected) return 'faded'; - return 'base'; - }; - const isInteractive = (point: PlotDataPoint): boolean => opacityOf(point) > 0; const model: VisibilityModel = { allHidden, - tierOf, opacityOf, baseOpacityOf, isInteractive, diff --git a/packages/core/src/components/scatter-plot/protein-tooltip-helpers.test.ts b/packages/core/src/components/scatter-plot/tooltips/protein-tooltip-helpers.test.ts similarity index 100% rename from packages/core/src/components/scatter-plot/protein-tooltip-helpers.test.ts rename to packages/core/src/components/scatter-plot/tooltips/protein-tooltip-helpers.test.ts diff --git a/packages/core/src/components/scatter-plot/protein-tooltip-helpers.ts b/packages/core/src/components/scatter-plot/tooltips/protein-tooltip-helpers.ts similarity index 100% rename from packages/core/src/components/scatter-plot/protein-tooltip-helpers.ts rename to packages/core/src/components/scatter-plot/tooltips/protein-tooltip-helpers.ts diff --git a/packages/core/src/components/scatter-plot/protein-tooltip.styles.ts b/packages/core/src/components/scatter-plot/tooltips/protein-tooltip.styles.ts similarity index 100% rename from packages/core/src/components/scatter-plot/protein-tooltip.styles.ts rename to packages/core/src/components/scatter-plot/tooltips/protein-tooltip.styles.ts diff --git a/packages/core/src/components/scatter-plot/protein-tooltip.ts b/packages/core/src/components/scatter-plot/tooltips/protein-tooltip.ts similarity index 98% rename from packages/core/src/components/scatter-plot/protein-tooltip.ts rename to packages/core/src/components/scatter-plot/tooltips/protein-tooltip.ts index 6306c7e1..53fd3ca5 100644 --- a/packages/core/src/components/scatter-plot/protein-tooltip.ts +++ b/packages/core/src/components/scatter-plot/tooltips/protein-tooltip.ts @@ -1,6 +1,6 @@ import { LitElement, html, type TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; -import { customElement } from '../../utils/safe-custom-element'; +import { customElement } from '../../../utils/safe-custom-element'; import { toDisplayValue, toInternalValue } from '@protspace/utils'; import type { AnnotationBlock, NumericAnnotationType, TooltipView } from '@protspace/utils'; import { proteinTooltipStyles } from './protein-tooltip.styles'; diff --git a/packages/core/src/components/scatter-plot/protspace-tips.styles.ts b/packages/core/src/components/scatter-plot/tooltips/protspace-tips.styles.ts similarity index 100% rename from packages/core/src/components/scatter-plot/protspace-tips.styles.ts rename to packages/core/src/components/scatter-plot/tooltips/protspace-tips.styles.ts diff --git a/packages/core/src/components/scatter-plot/protspace-tips.ts b/packages/core/src/components/scatter-plot/tooltips/protspace-tips.ts similarity index 98% rename from packages/core/src/components/scatter-plot/protspace-tips.ts rename to packages/core/src/components/scatter-plot/tooltips/protspace-tips.ts index 72d94e75..055855c8 100644 --- a/packages/core/src/components/scatter-plot/protspace-tips.ts +++ b/packages/core/src/components/scatter-plot/tooltips/protspace-tips.ts @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; -import { customElement } from '../../utils/safe-custom-element'; +import { customElement } from '../../../utils/safe-custom-element'; import { isMacOrIos } from '@protspace/utils'; import { protspaceTipsStyles } from './protspace-tips.styles'; diff --git a/packages/core/src/components/scatter-plot/tooltip-height-estimate.test.ts b/packages/core/src/components/scatter-plot/tooltips/tooltip-height-estimate.test.ts similarity index 100% rename from packages/core/src/components/scatter-plot/tooltip-height-estimate.test.ts rename to packages/core/src/components/scatter-plot/tooltips/tooltip-height-estimate.test.ts diff --git a/packages/core/src/components/scatter-plot/tooltip-height-estimate.ts b/packages/core/src/components/scatter-plot/tooltips/tooltip-height-estimate.ts similarity index 100% rename from packages/core/src/components/scatter-plot/tooltip-height-estimate.ts rename to packages/core/src/components/scatter-plot/tooltips/tooltip-height-estimate.ts diff --git a/packages/core/src/components/scatter-plot/tooltips/tooltip-position.test.ts b/packages/core/src/components/scatter-plot/tooltips/tooltip-position.test.ts new file mode 100644 index 00000000..3a8579cf --- /dev/null +++ b/packages/core/src/components/scatter-plot/tooltips/tooltip-position.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { + computeTooltipStyle, + TOOLTIP_EDGE_PADDING, + TOOLTIP_MAX_WIDTH, + TOOLTIP_ANCHOR_OFFSET_X, + TOOLTIP_ANCHOR_OFFSET_Y, + TOOLTIP_FALLBACK_HEIGHT, + type TooltipStyleInput, +} from './tooltip-position'; + +const base: TooltipStyleInput = { + x: 200, + y: 200, + height: 120, // measured/estimated tooltip height + viewportWidth: 800, + viewportHeight: 600, +}; + +describe('computeTooltipStyle', () => { + it('places the tooltip to the lower-right of the cursor in the open interior', () => { + // left = x + ANCHOR_OFFSET_X (no flip); top = y - ANCHOR_OFFSET_Y (no clamp) + expect(computeTooltipStyle(base)).toBe('left: 215px; top: 140px;'); + }); + + it('flips to the left side (translateX(-100%)) when it would overflow the right edge', () => { + // x + ANCHOR_OFFSET_X + MAX_WIDTH > viewportWidth → flip + const input = { ...base, x: 700, viewportWidth: 800 }; + // left = x - ANCHOR_OFFSET_X = 685; transform appended + expect(computeTooltipStyle(input)).toBe( + 'left: 685px; top: 140px; transform: translateX(-100%);', + ); + }); + + it('clamps the left edge to padding when the un-flipped anchor goes off the left', () => { + // not flipped, left = x + ANCHOR_OFFSET_X < EDGE_PADDING → left = EDGE_PADDING + const input = { ...base, x: -100 }; + expect(computeTooltipStyle(input)).toBe(`left: ${TOOLTIP_EDGE_PADDING}px; top: 140px;`); + }); + + it('clamps the flipped tooltip so its left edge stays on-screen', () => { + // flipped AND left - MAX_WIDTH < EDGE_PADDING → left = MAX_WIDTH + EDGE_PADDING + const input = { ...base, x: 360, viewportWidth: 360 }; + expect(computeTooltipStyle(input)).toBe( + `left: ${TOOLTIP_MAX_WIDTH + TOOLTIP_EDGE_PADDING}px; top: 140px; transform: translateX(-100%);`, + ); + }); + + it('lifts the top up when the tooltip would run off the bottom edge', () => { + // top + height > viewportHeight - padding → top = viewportHeight - height - padding + const input = { ...base, y: 580, height: 120, viewportHeight: 600 }; + // top = 600 - 120 - 15 = 465 + expect(computeTooltipStyle(input)).toBe('left: 215px; top: 465px;'); + }); + + it('clamps the top down to padding when the cursor is near the top edge', () => { + const input = { ...base, y: 30 }; // y - 60 = -30 < padding + expect(computeTooltipStyle(input)).toBe(`left: 215px; top: ${TOOLTIP_EDGE_PADDING}px;`); + }); + + it('exposes the calibrated constants used by the component', () => { + expect(TOOLTIP_EDGE_PADDING).toBe(15); + expect(TOOLTIP_MAX_WIDTH).toBe(350); + expect(TOOLTIP_ANCHOR_OFFSET_X).toBe(15); + expect(TOOLTIP_ANCHOR_OFFSET_Y).toBe(60); + expect(TOOLTIP_FALLBACK_HEIGHT).toBe(160); + }); +}); diff --git a/packages/core/src/components/scatter-plot/tooltips/tooltip-position.ts b/packages/core/src/components/scatter-plot/tooltips/tooltip-position.ts new file mode 100644 index 00000000..b34b7ebb --- /dev/null +++ b/packages/core/src/components/scatter-plot/tooltips/tooltip-position.ts @@ -0,0 +1,64 @@ +/** + * Pure tooltip-positioning math, extracted from ProtspaceScatterplot._getTooltipStyle. + * + * Given the cursor anchor (x, y), the tooltip's resolved height, and the + * viewport (config.width/height), it returns the inline CSS `left/top[/transform]` + * string that keeps the tooltip on-screen: anchored lower-right of the cursor, + * flipped to the left when it would overflow the right edge, and clamped to the + * viewport on all four sides. No DOM access — unit-testable in isolation. + */ + +/** Min gap (px) kept between the tooltip and any viewport edge. */ +export const TOOLTIP_EDGE_PADDING = 15; +/** Assumed max tooltip width (px) used for right/left overflow decisions. */ +export const TOOLTIP_MAX_WIDTH = 350; +/** Horizontal offset (px) of the tooltip anchor from the cursor. */ +export const TOOLTIP_ANCHOR_OFFSET_X = 15; +/** Vertical offset (px): tooltip top sits this far ABOVE the cursor. */ +export const TOOLTIP_ANCHOR_OFFSET_Y = 60; +/** Fallback height (px) when no measured/estimated height is available. */ +export const TOOLTIP_FALLBACK_HEIGHT = 160; + +export interface TooltipStyleInput { + /** Cursor x in viewport (local) px. */ + x: number; + /** Cursor y in viewport (local) px. */ + y: number; + /** Resolved tooltip height (measured, else estimated) in px. */ + height: number; + /** Viewport width (config.width) in px. */ + viewportWidth: number; + /** Viewport height (config.height) in px. */ + viewportHeight: number; +} + +export function computeTooltipStyle(input: TooltipStyleInput): string { + const { x, y, height: tooltipHeight, viewportWidth, viewportHeight } = input; + + let left = x + TOOLTIP_ANCHOR_OFFSET_X; + let top = y - TOOLTIP_ANCHOR_OFFSET_Y; + let transform = ''; + + // Horizontal: if it would overflow the right edge, flip to the left side. + if (left + TOOLTIP_MAX_WIDTH > viewportWidth) { + left = x - TOOLTIP_ANCHOR_OFFSET_X; + transform = 'translateX(-100%)'; + } + + // Keep within horizontal bounds. + if (!transform && left < TOOLTIP_EDGE_PADDING) { + left = TOOLTIP_EDGE_PADDING; + } else if (transform && left - TOOLTIP_MAX_WIDTH < TOOLTIP_EDGE_PADDING) { + left = TOOLTIP_MAX_WIDTH + TOOLTIP_EDGE_PADDING; + } + + // Vertical: clamp to the viewport using the resolved height. + if (top + tooltipHeight > viewportHeight - TOOLTIP_EDGE_PADDING) { + top = viewportHeight - tooltipHeight - TOOLTIP_EDGE_PADDING; + } + if (top < TOOLTIP_EDGE_PADDING) { + top = TOOLTIP_EDGE_PADDING; + } + + return `left: ${left}px; top: ${top}px;${transform ? ` transform: ${transform};` : ''}`; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.test.ts new file mode 100644 index 00000000..c3204c6d --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ContextLossController } from './context-loss-controller'; + +function makeCanvas() { + const listeners: Record = {}; + return { + addEventListener: vi.fn((t: string, cb: EventListener) => { + (listeners[t] ??= []).push(cb); + }), + removeEventListener: vi.fn((t: string, cb: EventListener) => { + listeners[t] = (listeners[t] ?? []).filter((c) => c !== cb); + }), + _fire: (t: string, ev: Event) => (listeners[t] ?? []).forEach((c) => c(ev)), + _count: (t: string) => (listeners[t] ?? []).length, + }; +} + +describe('ContextLossController', () => { + // POST-B1 reality (R2 / F-39): the restore path was deleted, so the controller + // registers ONLY `webglcontextlost` — never `webglcontextrestored`. + it('registers webglcontextlost (and NOT webglcontextrestored) on construction', () => { + const canvas = makeCanvas(); + new ContextLossController(canvas as unknown as HTMLCanvasElement, vi.fn(), vi.fn()); + expect(canvas._count('webglcontextlost')).toBe(1); + expect(canvas._count('webglcontextrestored')).toBe(0); + }); + + it('on lost: preventDefault + sets flag + invokes onLost', () => { + const canvas = makeCanvas(); + const onLost = vi.fn(); + const ctrl = new ContextLossController(canvas as unknown as HTMLCanvasElement, onLost, vi.fn()); + const ev = { preventDefault: vi.fn() } as unknown as Event; + canvas._fire('webglcontextlost', ev); + expect( + (ev as unknown as { preventDefault: ReturnType }).preventDefault, + ).toHaveBeenCalled(); + expect(ctrl.isLost).toBe(true); + expect(onLost).toHaveBeenCalledTimes(1); + }); + + it('markLost is idempotent (onLost fired once)', () => { + const canvas = makeCanvas(); + const onLost = vi.fn(); + const ctrl = new ContextLossController(canvas as unknown as HTMLCanvasElement, onLost, vi.fn()); + ctrl.markLost(); + ctrl.markLost(); + expect(onLost).toHaveBeenCalledTimes(1); + expect(ctrl.isLost).toBe(true); + }); + + it('destroy removes the webglcontextlost listener', () => { + const canvas = makeCanvas(); + const ctrl = new ContextLossController( + canvas as unknown as HTMLCanvasElement, + vi.fn(), + vi.fn(), + ); + ctrl.destroy(); + expect(canvas._count('webglcontextlost')).toBe(0); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.ts b/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.ts new file mode 100644 index 00000000..4c42d78a --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/context-loss-controller.ts @@ -0,0 +1,56 @@ +/** + * ContextLossController + * + * Owns the WebGL2 context-loss lifecycle for {@link WebGLRenderer}: it registers + * the `webglcontextlost` listener, tracks the idempotent "lost" flag, and fires + * the renderer-supplied `onLost` callback exactly once. + * + * Recovery semantics (post-B1 / F-39): the renderer no longer attempts an + * in-place context *restore*. B1 deleted the `webglcontextrestored` listener and + * `handleContextRestored`, so this controller registers ONLY `webglcontextlost`. + * The `onRestored` constructor parameter is accepted for call-site compatibility + * but is intentionally never wired to a `webglcontextrestored` listener. + * + * This is a behavior-preserving extraction of the renderer's + * `handleContextLost` / `markContextLost` logic — see webgl-renderer.ts. + */ +export class ContextLossController { + private lost = false; + + private readonly handleContextLost = (event: Event) => { + event.preventDefault(); + this.markLost(); + }; + + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly onLost: () => void, + // Accepted for call-site compatibility; the post-B1 tree has no restore path, + // so no `webglcontextrestored` listener is registered (F-39). + _onRestored?: () => void, + ) { + this.canvas.addEventListener('webglcontextlost', this.handleContextLost, { + passive: false, + }); + } + + /** + * Mark the context as lost. Idempotent: the first call sets the flag and fires + * `onLost` once; subsequent calls are no-ops. + */ + markLost(): void { + if (this.lost) return; + this.lost = true; + this.onLost(); + } + + /** Whether the context has been observed as lost. */ + get isLost(): boolean { + return this.lost; + } + + /** Remove the `webglcontextlost` listener. */ + destroy(): void { + this.canvas.removeEventListener('webglcontextlost', this.handleContextLost); + } +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.test.ts new file mode 100644 index 00000000..fe4acba7 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { computeExtent, computePaddedExtent, DATA_EXTENT_PADDING } from './data-extent'; + +describe('computeExtent', () => { + it('returns min/max over the first `length` entries', () => { + const xs = new Float32Array([1, 5, -3, 100]); + const ys = new Float32Array([2, 2, 9, -1]); + expect(computeExtent(xs, ys, 3)).toEqual({ + xMin: -3, + xMax: 5, + yMin: 2, + yMax: 9, + }); + }); +}); + +describe('computePaddedExtent', () => { + it('adds 5% of the span on each axis', () => { + expect(DATA_EXTENT_PADDING).toBe(0.05); + const xs = new Float32Array([0, 10]); + const ys = new Float32Array([0, 20]); + const e = computePaddedExtent(xs, ys, 2); + expect(e.xMin).toBeCloseTo(-0.5); + expect(e.xMax).toBeCloseTo(10.5); + expect(e.yMin).toBeCloseTo(-1); + expect(e.yMax).toBeCloseTo(21); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.ts b/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.ts new file mode 100644 index 00000000..087f18f5 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/data-extent.ts @@ -0,0 +1,46 @@ +import { DATA_EXTENT_PADDING } from '@protspace/utils'; + +interface DataExtent { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +// Re-exported so the export domain shares the single live-scale padding constant +// (defined in @protspace/utils) — on-screen and exported framing stay in lockstep. +export { DATA_EXTENT_PADDING }; + +export function computeExtent( + xs: ArrayLike, + ys: ArrayLike, + length: number, +): DataExtent { + let xMin = Infinity, + xMax = -Infinity, + yMin = Infinity, + yMax = -Infinity; + for (let i = 0; i < length; i++) { + if (xs[i] < xMin) xMin = xs[i]; + if (xs[i] > xMax) xMax = xs[i]; + if (ys[i] < yMin) yMin = ys[i]; + if (ys[i] > yMax) yMax = ys[i]; + } + return { xMin, xMax, yMin, yMax }; +} + +export function computePaddedExtent( + xs: ArrayLike, + ys: ArrayLike, + length: number, +): DataExtent { + const { xMin, xMax, yMin, yMax } = computeExtent(xs, ys, length); + const xPad = Math.abs(xMax - xMin) * DATA_EXTENT_PADDING; + const yPad = Math.abs(yMax - yMin) * DATA_EXTENT_PADDING; + return { + xMin: xMin - xPad, + xMax: xMax + xPad, + yMin: yMin - yPad, + yMax: yMax + yPad, + }; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts new file mode 100644 index 00000000..5172d8ab --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import type { PlotData, ScatterplotConfig } from '@protspace/utils'; +import { ExportRenderer } from './export-renderer'; +import { computePaddedExtent, computeExtent } from './data-extent'; + +/** + * These tests pin the pure-math seams of the off-screen export pipeline. They + * are authored against the behavior the methods carried inline on + * `WebGLRenderer` before extraction, proving the move is equal (not redefined). + * + * The off-screen GL pipeline (`renderToCanvas`/`initializeOffscreenContext`) is + * exercised by the renderer-level characterization harness; here we cover only + * the math that needs no live context. + */ + +function makePlotData(xs: number[], ys: number[]): PlotData { + return { + length: xs.length, + xs: new Float32Array(xs), + ys: new Float32Array(ys), + zs: null, + originalIndices: null, + proteinIds: xs.map((_, i) => `p${i}`), + }; +} + +// Fixed reference dims the export margin scaling anchors to (mirrors the impl). +const REF_W = 800; +const REF_H = 600; + +describe('computePaddedExtent (export domain helper)', () => { + it('returns the padded min/max over the first `length` columns', () => { + const xs = new Float32Array([0, 10]); + const ys = new Float32Array([0, 20]); + const ext = computePaddedExtent(xs, ys, 2); + // Padded extent widens past the raw data on every side. + expect(ext.xMin).toBeLessThanOrEqual(0); + expect(ext.xMax).toBeGreaterThanOrEqual(10); + expect(ext.yMin).toBeLessThanOrEqual(0); + expect(ext.yMax).toBeGreaterThanOrEqual(20); + }); +}); + +describe('ExportRenderer.createExportScales (static)', () => { + const config: ScatterplotConfig = { + width: 1000, + height: 800, + margin: { top: 20, right: 20, bottom: 20, left: 20 }, + }; + + it('returns null for empty data', () => { + const empty = makePlotData([], []); + expect(ExportRenderer.createExportScales(config, empty, 400, 300)).toBeNull(); + }); + + it('default path: pads the data extent and insets the range by the scaled margins', () => { + const pd = makePlotData([0, 10], [0, 20]); + const exportWidth = 800; + const exportHeight = 600; + const scales = ExportRenderer.createExportScales(config, pd, exportWidth, exportHeight); + expect(scales).not.toBeNull(); + const { x, y } = scales!; + + // Domain is the 5%-padded data extent. + const padded = computePaddedExtent(pd.xs, pd.ys, pd.length); + expect(x.domain()).toEqual([padded.xMin, padded.xMax]); + expect(y.domain()).toEqual([padded.yMin, padded.yMax]); + + // Range is inset by margins scaled from the fixed reference. + const scaleX = exportWidth / REF_W; + const scaleY = exportHeight / REF_H; + expect(x.range()[0]).toBeCloseTo(20 * scaleX); // left + expect(x.range()[1]).toBeCloseTo(exportWidth - 20 * scaleX); // right + expect(y.range()[0]).toBeCloseTo(exportHeight - 20 * scaleY); // bottom + expect(y.range()[1]).toBeCloseTo(20 * scaleY); // top + }); + + it('dataDomain path: full-bleed range with no padding and no margins', () => { + const pd = makePlotData([0, 10], [0, 20]); + const exportWidth = 500; + const exportHeight = 400; + const domain = { xMin: 2, xMax: 8, yMin: 3, yMax: 7 }; + const scales = ExportRenderer.createExportScales(config, pd, exportWidth, exportHeight, domain); + const { x, y } = scales!; + + // Domain is exactly the supplied viewport (no 5% padding). + expect(x.domain()).toEqual([domain.xMin, domain.xMax]); + expect(y.domain()).toEqual([domain.yMin, domain.yMax]); + + // Range fills the canvas edge-to-edge (x: 0->W, y: H->0). + expect(x.range()).toEqual([0, exportWidth]); + expect(y.range()).toEqual([exportHeight, 0]); + }); + + it('falls back to a 20px default margin when config.margin is absent', () => { + const noMargin: ScatterplotConfig = { width: 1000, height: 800 }; + const pd = makePlotData([0, 10], [0, 20]); + const scales = ExportRenderer.createExportScales(noMargin, pd, REF_W, REF_H); + const { x } = scales!; + // At the reference size the scale factor is 1, so the inset equals 20px. + expect(x.range()[0]).toBeCloseTo(20); + expect(x.range()[1]).toBeCloseTo(REF_W - 20); + }); +}); + +describe('ExportRenderer.getRenderInfo (static)', () => { + it('returns margins in export-pixel space, scaled from the fixed reference', () => { + const config: ScatterplotConfig = { + margin: { top: 10, right: 30, bottom: 40, left: 50 }, + }; + const exportWidth = 1600; // 2x reference width + const exportHeight = 1200; // 2x reference height + const info = ExportRenderer.getRenderInfo(config, exportWidth, exportHeight); + expect(info.marginLeft).toBeCloseTo(50 * (exportWidth / REF_W)); + expect(info.marginRight).toBeCloseTo(30 * (exportWidth / REF_W)); + expect(info.marginTop).toBeCloseTo(10 * (exportHeight / REF_H)); + expect(info.marginBottom).toBeCloseTo(40 * (exportHeight / REF_H)); + }); + + it('uses the 20px default margin when config.margin is absent', () => { + const info = ExportRenderer.getRenderInfo({}, REF_W, REF_H); + expect(info.marginLeft).toBeCloseTo(20); + expect(info.marginRight).toBeCloseTo(20); + expect(info.marginTop).toBeCloseTo(20); + expect(info.marginBottom).toBeCloseTo(20); + }); +}); + +describe('ExportRenderer.getDataExtent (instance)', () => { + const renderer = new ExportRenderer(); + + it('returns null when there is nothing rendered', () => { + expect(renderer.getDataExtent(null)).toBeNull(); + expect(renderer.getDataExtent(makePlotData([], []))).toBeNull(); + }); + + it('returns the UNPADDED extent (matches the inline getDataExtent)', () => { + const pd = makePlotData([1, 5, -3], [2, 9, 0]); + expect(renderer.getDataExtent(pd)).toEqual(computeExtent(pd.xs, pd.ys, pd.length)); + }); +}); + +describe('ExportRenderer.renderToCanvas (guards)', () => { + const renderer = new ExportRenderer(); + const config: ScatterplotConfig = { width: 800, height: 600 }; + const style = {} as never; + const baseOptions = { + selectionActive: false, + transform: { x: 0, y: 0, k: 1 } as never, + gamma: 2.2, + }; + + it('throws when there is no data to render', () => { + expect(() => + renderer.renderToCanvas(null, config, style, { + width: 100, + height: 100, + ...baseOptions, + }), + ).toThrow(/No points available/); + }); + + it('throws when a single export dimension exceeds the browser limit', () => { + const pd = makePlotData([0, 1], [0, 1]); + expect(() => + renderer.renderToCanvas(pd, config, style, { + width: 9000, + height: 100, + ...baseOptions, + }), + ).toThrow(/exceed browser limit/); + }); + + it('throws when the export area exceeds the pixel-count limit', () => { + const pd = makePlotData([0, 1], [0, 1]); + // 8000 x 8000 = 64M (within MAX_DIMENSION) but with dpr 3 -> 24000 hits dimension first; + // pick dims under MAX_DIMENSION each yet over MAX_AREA: 8000 x 8000 = 64M < 268M, so + // use values just under the per-dimension cap whose product exceeds the area cap is + // impossible (8192^2 = 67M < 268M). The area guard is unreachable via two valid dims, + // so we only assert the dimension guard fires for the larger-than-limit case above. + expect(() => + renderer.renderToCanvas(pd, config, style, { + width: 8193, + height: 1, + ...baseOptions, + }), + ).toThrow(/exceed browser limit/); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts new file mode 100644 index 00000000..308382c9 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts @@ -0,0 +1,682 @@ +/** + * Off-screen export rendering for the scatter plot. + * + * This is a behavior-preserving extraction of the off-screen subsystem that + * previously lived inline on `WebGLRenderer`. It owns the export pipeline: + * create a throwaway WebGL2 context sized to the requested export dimensions, + * stage the points (painter's-algorithm depth sort + F-15 two-pass selection + * blend), render through the gamma-correct pipeline when the float extensions + * are available (falling back to direct rendering otherwise), and copy the + * result into a 2D canvas for safe export. + * + * It consumes the B3 substrate (`resolvePointLocations`, `setupAttributes`, + * `createLinearFramebuffer`, `destroyFramebuffer`, `bindAndClearTarget`, + * `setPointBlendState`, `drawGammaQuad`, `QUAD_VERTICES`, `stagePoint`, + * `drawPoints`) and the live point/gamma shader sources, so it shares no + * resource state with the live render pipeline. + * + * `ExportRenderer` is stateless apart from the ephemeral off-screen `gl` it + * creates per call: every input (source data, scales, config, style getters, + * transform, gamma, selection state) is passed in as a method argument. The + * pure-math seams (`getDataExtent`, `createExportScales`, `getRenderInfo`) are + * exposed as `static` so they can be unit-tested without instantiation. + */ + +import * as d3 from 'd3'; +import type { PlotData, PlotDataPoint, ScatterplotConfig } from '@protspace/utils'; +import { + type WebGLStyleGetters, + type ScalePair, + type FramebufferResources, + type PointUniformLocations, + MAX_POINTS_DIRECT_RENDER, +} from '../types'; +import { createProgramFromSources } from '../shader-utils'; +import { resolvePointLocations } from './point-locations'; +import { setupAttributes } from './point-attributes'; +import { createLinearFramebuffer, destroyFramebuffer } from './framebuffer'; +import { + bindAndClearTarget, + setPointBlendState, + drawPoints, + bindPointDrawState, +} from './render-target'; +import { QUAD_VERTICES, drawGammaQuad } from './gamma-quad'; +import { computeExtent, computePaddedExtent } from './data-extent'; +import { DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT } from './viewport-defaults'; +import { stagePoint, type StagePointArrays, MAX_LABELS } from './stage-point'; +import { buildPaintOrder } from './point-staging'; +import { + POINT_VERTEX_SHADER, + POINT_FRAGMENT_SHADER, + GAMMA_VERTEX_SHADER, + GAMMA_FRAGMENT_SHADER, +} from './export-shaders'; + +// Constants (moved verbatim from webgl-renderer.ts). +const MIN_CAPACITY = 1024; +const LABEL_TEXTURE_WIDTH = 2048; + +// Stable reference dimensions for margin scaling at export time. Tying margin +// scaling to the live display canvas (via `config.width/height`, which track +// `clientWidth/clientHeight`) made captured plots window-size dependent — same +// data lands at slightly different pixel positions when the browser is resized, +// causing publish-modal overlays to drift relative to clusters across sessions. +// Anchoring to a fixed reference makes the export render reproducible. +const EXPORT_MARGIN_REFERENCE_WIDTH = 800; +const EXPORT_MARGIN_REFERENCE_HEIGHT = 600; + +const MAX_DIMENSION = 8192; +const MAX_AREA = 268435456; // ~268M pixels + +/** A data-coordinate viewport rectangle. */ +interface DataDomain { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +/** Options for a single off-screen export render. */ +interface ExportRenderOptions { + width: number; + height: number; + dpr?: number; + dataDomain?: DataDomain; + pointSizeReference?: { width: number; height: number }; + /** Live selection state, forwarded to preserve the F-15 two-pass blend. */ + selectionActive: boolean; + /** Current live transform; scaled to the export dimensions internally. */ + transform: d3.ZoomTransform; + /** Live gamma value; downgraded to 1.0 when the gamma pipeline is unavailable. */ + gamma: number; +} + +export class ExportRenderer { + /** + * Create scales appropriate for export dimensions. + * Scales the margin proportionally to maintain visual consistency. + * + * Replicates the inline `WebGLRenderer.createExportScales` exactly: when a + * `dataDomain` is supplied (inset / geometric-zoom render) the domain fills + * the canvas edge-to-edge (full bleed, no 5% padding, no margins); otherwise + * the data extent gets 5% padding and the margins are scaled from a fixed + * reference. The config + optional dataDomain are passed in (replacing the + * former `this.getConfig()` / inline `pd` reads). + */ + static createExportScales( + config: ScatterplotConfig, + pd: PlotData, + exportWidth: number, + exportHeight: number, + dataDomain?: DataDomain, + ): ScalePair | null { + if (pd.length === 0) return null; + + // Default margin if not specified + const margin = config.margin ?? { top: 20, right: 20, bottom: 20, left: 20 }; + + // Scale margins from a fixed reference instead of the live display size, + // so the export render is reproducible across browser-window resizes. + const scaleX = exportWidth / EXPORT_MARGIN_REFERENCE_WIDTH; + const scaleY = exportHeight / EXPORT_MARGIN_REFERENCE_HEIGHT; + + const scaledMargin = { + top: margin.top * scaleY, + right: margin.right * scaleX, + bottom: margin.bottom * scaleY, + left: margin.left * scaleX, + }; + + let xDomMin: number; + let xDomMax: number; + let yDomMin: number; + let yDomMax: number; + let useFullBleed = false; + if (dataDomain) { + // Caller supplied an exact viewport — used by inset (geometric zoom) + // rendering. Skip the 5% padding AND skip margins so the data domain + // fills the canvas edge-to-edge. The caller is responsible for picking + // a domain that already accounts for the source plot's margins, so the + // inset's data fills aligns 1:1 with the source rect's data region. + xDomMin = dataDomain.xMin; + xDomMax = dataDomain.xMax; + yDomMin = dataDomain.yMin; + yDomMax = dataDomain.yMax; + useFullBleed = true; + } else { + // Compute data extent + 5% padding (ScaleManager.createScales convention). + const e = computePaddedExtent(pd.xs, pd.ys, pd.length); + xDomMin = e.xMin; + xDomMax = e.xMax; + yDomMin = e.yMin; + yDomMax = e.yMax; + } + + const xRangeStart = useFullBleed ? 0 : scaledMargin.left; + const xRangeEnd = useFullBleed ? exportWidth : exportWidth - scaledMargin.right; + const yRangeStart = useFullBleed ? exportHeight : exportHeight - scaledMargin.bottom; + const yRangeEnd = useFullBleed ? 0 : scaledMargin.top; + + const xScale = d3.scaleLinear().domain([xDomMin, xDomMax]).range([xRangeStart, xRangeEnd]); + const yScale = d3.scaleLinear().domain([yDomMin, yDomMax]).range([yRangeStart, yRangeEnd]); + + return { x: xScale, y: yScale }; + } + + /** + * Display configuration the renderer would apply to a render at the given + * export dimensions. Returned values are in *export pixel space* (i.e. + * `marginLeft` is the pixel offset of the data area's left edge inside an + * `exportWidth × exportHeight` canvas). Used by the publish modal to + * translate inset source rects (canvas-norm) into data-coord viewports + * with margin-aware accuracy. Static so it is unit-testable. + */ + static getRenderInfo( + config: ScatterplotConfig, + exportWidth: number, + exportHeight: number, + ): { marginLeft: number; marginRight: number; marginTop: number; marginBottom: number } { + const margin = config.margin ?? { top: 20, right: 20, bottom: 20, left: 20 }; + // Match createExportScales: anchor to the same fixed reference so insets' + // data-domain inversion stays consistent with the export render. + const scaleX = exportWidth / EXPORT_MARGIN_REFERENCE_WIDTH; + const scaleY = exportHeight / EXPORT_MARGIN_REFERENCE_HEIGHT; + return { + marginLeft: margin.left * scaleX, + marginRight: margin.right * scaleX, + marginTop: margin.top * scaleY, + marginBottom: margin.bottom * scaleY, + }; + } + + /** + * Data extent of the supplied points, or null when there is nothing to + * measure. Mirrors the inline `WebGLRenderer.getDataExtent` exactly + * (unpadded `computeExtent`). Used by the publish modal to translate inset + * source rects (normalized canvas coords) into data-coordinate viewports. + */ + getDataExtent(pd: PlotData | null): DataDomain | null { + if (!pd || pd.length === 0) return null; + return computeExtent(pd.xs, pd.ys, pd.length); + } + + /** + * Render visualization at arbitrary dimensions to a new off-screen canvas. + * Creates a temporary WebGL context, renders at requested size, returns 2D canvas. + * + * Behavior-preserving move of the inline `WebGLRenderer.renderToCanvas`: the + * source data, scales-providing config + style getters, and live render state + * (selection, transform, gamma) are now passed in as method args. + */ + renderToCanvas( + pd: PlotData | null, + config: ScatterplotConfig, + style: WebGLStyleGetters, + options: ExportRenderOptions, + ): HTMLCanvasElement { + const { width, height, dataDomain, pointSizeReference } = options; + const dpr = options.dpr ?? 1; + + // Validate dimensions + const physicalWidth = Math.floor(width * dpr); + const physicalHeight = Math.floor(height * dpr); + + if (physicalWidth > MAX_DIMENSION || physicalHeight > MAX_DIMENSION) { + throw new Error( + `Export dimensions ${physicalWidth}×${physicalHeight} exceed browser limit of ${MAX_DIMENSION}px`, + ); + } + if (physicalWidth * physicalHeight > MAX_AREA) { + throw new Error( + `Export area ${(physicalWidth * physicalHeight).toLocaleString()} exceeds limit of ${MAX_AREA.toLocaleString()} pixels`, + ); + } + + // Ensure we have data to render + if (!pd || pd.length === 0) { + throw new Error('No points available to render. Call render() first.'); + } + + // Create scales for export dimensions + const exportScales = ExportRenderer.createExportScales( + config, + pd, + physicalWidth, + physicalHeight, + dataDomain, + ); + if (!exportScales) { + throw new Error('Could not create scales for export rendering'); + } + + // Create off-screen WebGL canvas + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = physicalWidth; + offscreenCanvas.height = physicalHeight; + + // Get WebGL2 context with same options as main canvas + const gl = offscreenCanvas.getContext('webgl2', { + antialias: true, + preserveDrawingBuffer: true, + premultipliedAlpha: false, + alpha: true, + powerPreference: 'high-performance', + }); + + if (!gl) { + throw new Error('Failed to create WebGL2 context for export'); + } + + try { + // Initialize WebGL state for the off-screen context + this.initializeOffscreenContext( + gl, + physicalWidth, + physicalHeight, + pd, + exportScales, + dpr, + config, + style, + options, + pointSizeReference, + ); + + // Copy WebGL canvas to 2D canvas for safe export + const outputCanvas = document.createElement('canvas'); + outputCanvas.width = physicalWidth; + outputCanvas.height = physicalHeight; + + const ctx = outputCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to create 2D context for export'); + } + + ctx.drawImage(offscreenCanvas, 0, 0); + + return outputCanvas; + } finally { + // Clean up off-screen context + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (loseContext) { + loseContext.loseContext(); + } + } + } + + /** + * Initialize and render to an off-screen WebGL context. + */ + private initializeOffscreenContext( + gl: WebGL2RenderingContext, + width: number, + height: number, + pd: PlotData, + scales: ScalePair, + dpr: number, + config: ScatterplotConfig, + style: WebGLStyleGetters, + options: ExportRenderOptions, + pointSizeReference?: { width: number; height: number }, + ): void { + // Calculate size scale factor based on export vs display dimensions. + // For inset (zoom) renders, callers pass `pointSizeReference` set to the + // source plot's render size so points stay visually the same size as in + // the main plot — instead of shrinking when the inset target is small. + const displayWidth = config.width ?? DEFAULT_VIEWPORT_WIDTH; + const displayHeight = config.height ?? DEFAULT_VIEWPORT_HEIGHT; + const refW = pointSizeReference?.width ?? width; + const refH = pointSizeReference?.height ?? height; + const sizeScaleFactor = Math.sqrt((refW * refH) / (displayWidth * displayHeight)); + // Enable extensions for float textures (needed for gamma pipeline) + const colorBufferFloatExt = gl.getExtension('EXT_color_buffer_float'); + const floatBlendExt = gl.getExtension('EXT_float_blend'); + gl.getExtension('OES_texture_float_linear'); + + const useGammaPipeline = !!colorBufferFloatExt && !!floatBlendExt; + + // Create shader programs + const pointProgram = createProgramFromSources(gl, POINT_VERTEX_SHADER, POINT_FRAGMENT_SHADER); + if (!pointProgram) { + throw new Error('Failed to create point shader program for export'); + } + + let gammaCorrectionProgram: WebGLProgram | null = null; + if (useGammaPipeline) { + gammaCorrectionProgram = createProgramFromSources( + gl, + GAMMA_VERTEX_SHADER, + GAMMA_FRAGMENT_SHADER, + ); + } + + // Get attribute and uniform locations + const { attribs, uniforms } = resolvePointLocations(gl, pointProgram); + + // Prepare point data using existing CPU arrays (reuse from main renderer) + const maxPoints = Math.min(pd.length, MAX_POINTS_DIRECT_RENDER); + + // Populate buffers for off-screen rendering + const { + dataPositions, + sizes, + colors, + depths, + labelCounts, + shapes, + labelColorData, + pointCount, + selectedStartIndex, + } = this.prepareOffscreenBufferData( + pd, + scales, + maxPoints, + dpr, + style, + options.selectionActive, + sizeScaleFactor, + ); + + // Create and upload buffers + const dataPositionBuffer = gl.createBuffer(); + const sizeBuffer = gl.createBuffer(); + const colorBuffer = gl.createBuffer(); + const depthBuffer = gl.createBuffer(); + const labelCountBuffer = gl.createBuffer(); + const shapeBuffer = gl.createBuffer(); + const labelColorTexture = gl.createTexture(); + + gl.bindBuffer(gl.ARRAY_BUFFER, dataPositionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, dataPositions.subarray(0, pointCount * 2), gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuffer); + gl.bufferData(gl.ARRAY_BUFFER, sizes.subarray(0, pointCount), gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); + gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, pointCount * 4), gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, depthBuffer); + gl.bufferData(gl.ARRAY_BUFFER, depths.subarray(0, pointCount), gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, labelCountBuffer); + gl.bufferData(gl.ARRAY_BUFFER, labelCounts.subarray(0, pointCount), gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, shapeBuffer); + gl.bufferData(gl.ARRAY_BUFFER, shapes.subarray(0, pointCount), gl.STATIC_DRAW); + + // Setup label color texture + gl.bindTexture(gl.TEXTURE_2D, labelColorTexture); + const texHeight = labelColorData.length / 4 / LABEL_TEXTURE_WIDTH; + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + LABEL_TEXTURE_WIDTH, + texHeight, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + labelColorData, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + // Create VAO + const pointVao = gl.createVertexArray(); + gl.bindVertexArray(pointVao); + + setupAttributes( + gl, + { + dataPosition: dataPositionBuffer, + size: sizeBuffer, + color: colorBuffer, + depth: depthBuffer, + labelCount: labelCountBuffer, + shape: shapeBuffer, + }, + attribs, + ); + + gl.bindVertexArray(null); + + // Get current transform and scale it for export dimensions + const displayTransform = options.transform; + // Scale transform's translation to export dimensions + const scaleFactorX = width / displayWidth; + const scaleFactorY = height / displayHeight; + // Create a scaled transform that preserves the current view at export resolution + const exportTransform = { + x: displayTransform.x * scaleFactorX, + y: displayTransform.y * scaleFactorY, + k: displayTransform.k, // Zoom level stays the same + } as d3.ZoomTransform; + const gamma = useGammaPipeline ? options.gamma : 1.0; + + // Setup linear framebuffer if using gamma pipeline + let linearFramebuffer: FramebufferResources | null = null; + if (useGammaPipeline && gammaCorrectionProgram) { + linearFramebuffer = createLinearFramebuffer(gl, width, height); + } + + // Render + if (linearFramebuffer && gammaCorrectionProgram) { + // Gamma-correct pipeline + bindAndClearTarget(gl, linearFramebuffer.framebuffer, width, height); + setPointBlendState(gl); + + this.renderOffscreenPoints( + gl, + pointProgram, + pointVao, + uniforms, + width, + height, + dpr, + gamma, + exportTransform, + labelColorTexture, + labelColorData.length, + pointCount, + options.selectionActive, + selectedStartIndex, + ); + + // Apply gamma correction + bindAndClearTarget(gl, null, width, height); + gl.disable(gl.BLEND); + + const quadBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW); + // One-shot export pass: resolve the gamma uniforms inline (no per-frame cost here). + drawGammaQuad(gl, gammaCorrectionProgram, linearFramebuffer.texture, gamma, quadBuffer, { + linearTexture: gl.getUniformLocation(gammaCorrectionProgram, 'u_linearTexture'), + gamma: gl.getUniformLocation(gammaCorrectionProgram, 'u_gamma'), + }); + gl.deleteBuffer(quadBuffer); + } else { + // Direct rendering + bindAndClearTarget(gl, null, width, height); + setPointBlendState(gl); + + this.renderOffscreenPoints( + gl, + pointProgram, + pointVao, + uniforms, + width, + height, + dpr, + gamma, + exportTransform, + labelColorTexture, + labelColorData.length, + pointCount, + options.selectionActive, + selectedStartIndex, + ); + } + + // Cleanup + gl.deleteVertexArray(pointVao); + gl.deleteBuffer(dataPositionBuffer); + gl.deleteBuffer(sizeBuffer); + gl.deleteBuffer(colorBuffer); + gl.deleteBuffer(depthBuffer); + gl.deleteBuffer(labelCountBuffer); + gl.deleteBuffer(shapeBuffer); + gl.deleteTexture(labelColorTexture); + gl.deleteProgram(pointProgram); + if (gammaCorrectionProgram) gl.deleteProgram(gammaCorrectionProgram); + if (linearFramebuffer) { + destroyFramebuffer(gl, linearFramebuffer); + } + } + + /** + * Prepare buffer data for off-screen rendering. + */ + private prepareOffscreenBufferData( + pd: PlotData, + scales: ScalePair, + maxPoints: number, + dpr: number, + style: WebGLStyleGetters, + selectionActive: boolean, + sizeScaleFactor: number = 1, + ): { + dataPositions: Float32Array; + sizes: Float32Array; + colors: Float32Array; + depths: Float32Array; + labelCounts: Float32Array; + shapes: Float32Array; + labelColorData: Uint8Array; + pointCount: number; + selectedStartIndex: number; + } { + const capacity = Math.max(MIN_CAPACITY, maxPoints); + const dataPositions = new Float32Array(capacity * 2); + const sizes = new Float32Array(capacity); + const colors = new Float32Array(capacity * 4); + const depths = new Float32Array(capacity); + const labelCounts = new Float32Array(capacity); + const shapes = new Float32Array(capacity); + const requiredPixels = capacity * MAX_LABELS; + const texHeight = Math.ceil(requiredPixels / LABEL_TEXTURE_WIDTH); + const labelColorData = new Uint8Array(LABEL_TEXTURE_WIDTH * texHeight * 4); + + // Stage slots by depth using the SAME canonical painter-order plan as the + // live path (buildPaintOrder): the live path is canonical, so the export + // includes opacity-0 slots (invisible — F-15 pixels unchanged) and uses the + // identical stable far->near sort and the same sorted-k selectedStartIndex. + const { xs, ys } = pd; + const oi = pd.originalIndices; + const sp: PlotDataPoint = { id: '', x: 0, y: 0, originalIndex: 0 }; + const count = maxPoints; + + const target: StagePointArrays = { + dataPositions, + sizes, + colors, + depths, + labelCounts, + shapes, + labelColorData, + }; + + // Per-slot depth scratch indexed by ORIGINAL slot index, then the index order + // sorted far->near in place. Sized to the staged count (export has no persistent + // scratch, so allocate locally per call). + const order = new Uint32Array(count); + const depthScratch = new Float32Array(count); + for (let i = 0; i < count; i++) { + const origIdx = oi ? oi[i] : i; + sp.id = pd.proteinIds[origIdx]; + sp.x = xs[i]; + sp.y = ys[i]; + sp.originalIndex = origIdx; + depthScratch[i] = style.getDepth(sp); + } + + const { selectedStartIndex } = buildPaintOrder( + order, + depthScratch, + count, + selectionActive, + (k, srcSlot) => { + const origIdx = oi ? oi[srcSlot] : srcSlot; + sp.id = pd.proteinIds[origIdx]; + sp.x = xs[srcSlot]; + sp.y = ys[srcSlot]; + sp.originalIndex = origIdx; + const opacity = style.getOpacity(sp); + + // Depth uses depthScratch[srcSlot] (indexed by original slot), NOT + // depthScratch[k], matching the live path. + stagePoint( + target, + k, + sp, + scales.x(xs[srcSlot]), + scales.y(ys[srcSlot]), + opacity, + depthScratch[srcSlot], + style, + dpr, + sizeScaleFactor, + ); + + return opacity; + }, + ); + + return { + dataPositions, + sizes, + colors, + depths, + labelCounts, + shapes, + labelColorData, + pointCount: count, + selectedStartIndex, + }; + } + + /** + * Render points in off-screen context. + */ + private renderOffscreenPoints( + gl: WebGL2RenderingContext, + program: WebGLProgram, + vao: WebGLVertexArrayObject, + uniforms: PointUniformLocations, + width: number, + height: number, + dpr: number, + gamma: number, + transform: d3.ZoomTransform, + labelColorTexture: WebGLTexture | null, + labelColorDataLength: number, + pointCount: number, + selectionActive: boolean, + selectedStartIndex: number, + ): void { + bindPointDrawState(gl, program, uniforms, vao, labelColorTexture, { + width, + height, + transform: { x: transform.x, y: transform.y, k: transform.k }, + dpr, + gamma, + maxLabels: MAX_LABELS, + labelTextureWidth: LABEL_TEXTURE_WIDTH, + labelColorDataLength, + }); + + drawPoints(gl, pointCount, selectionActive, selectedStartIndex); + gl.bindVertexArray(null); + } +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts new file mode 100644 index 00000000..ebdd149a --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts @@ -0,0 +1,177 @@ +/** + * Point + gamma-correction shader sources for the off-screen export pipeline. + * + * These are byte-identical copies of the live shader sources that previously + * lived as module-level constants in `webgl-renderer.ts`. They are factored out + * here so the extracted `ExportRenderer` can build its throwaway programs + * without depending on the live renderer module. The Wire phase re-points the + * live renderer to consume these same constants, keeping a single source of + * truth for the shader text. + */ + +export const POINT_VERTEX_SHADER = `#version 300 es +precision highp float; + +in vec2 a_dataPosition; +in float a_pointSize; +in vec4 a_color; +in float a_depth; +in float a_labelCount; +in float a_shape; + +uniform vec2 u_resolution; +uniform vec3 u_transform; +uniform float u_dpr; +uniform float u_gamma; + +out vec4 v_color; +out float v_labelCount; +flat out float v_shape; +flat out int v_pointIndex; + +void main() { + vec2 cssTransformed = a_dataPosition * u_transform.z + u_transform.xy; + vec2 physicalPos = cssTransformed * u_dpr; + vec2 clipSpace = (physicalPos / u_resolution) * 2.0 - 1.0; + + // Depth is computed per-point on the CPU (opacity + legend z-order tie-break) + gl_Position = vec4(clipSpace.x, -clipSpace.y, a_depth, 1.0); + gl_PointSize = max(1.0, a_pointSize); + + // Convert sRGB input to linear RGB for proper blending + vec3 linearColor = pow(max(a_color.rgb, vec3(0.0)), vec3(u_gamma)); + v_color = vec4(linearColor, a_color.a); + v_labelCount = a_labelCount; + v_shape = a_shape; + v_pointIndex = gl_VertexID; +}`; + +export const POINT_FRAGMENT_SHADER = `#version 300 es +precision highp float; + +in vec4 v_color; +in float v_labelCount; +flat in float v_shape; +flat in int v_pointIndex; + +uniform sampler2D u_labelColors; +uniform vec2 u_labelTextureSize; +uniform int u_maxLabels; +uniform float u_gamma; + +out vec4 fragColor; + +const float PI = 3.14159265359; +const float SQRT3 = 1.73205080757; + +void main() { + vec2 coord = gl_PointCoord * 2.0 - 1.0; + + // Compute signed edge distance for each shape. + // Positive = inside, zero = on boundary, negative = outside. + // This single computation drives both anti-aliasing and the outline effect. + float edgeDist; + + if (v_shape < 0.5) { // Circle + edgeDist = 1.0 - length(coord); + } else if (v_shape < 1.5) { // Square + edgeDist = 1.0 - max(abs(coord.x), abs(coord.y)); + } else if (v_shape < 2.5) { // Diamond + // Match d3.symbolDiamond proportions (same mapping as D3's "tan30" constant, i.e. sqrt(1/3)) + edgeDist = 1.0 - (abs(coord.x) * SQRT3 + abs(coord.y)); + } else if (v_shape < 3.5) { // Triangle Up + // Inside region: abs(x)*SQRT3 <= 1 + y, clipped to point quad [-1,1]^2. + float eSides = (1.0 + coord.y - abs(coord.x) * SQRT3) / 2.0; + float eBottom = 1.0 - coord.y; + float eLR = 1.0 - abs(coord.x); + edgeDist = min(eSides, min(eBottom, eLR)); + } else if (v_shape < 4.5) { // Triangle Down + // Inside region: abs(x)*SQRT3 <= 1 - y, clipped to point quad [-1,1]^2. + float eSides = (1.0 - coord.y - abs(coord.x) * SQRT3) / 2.0; + float eTop = 1.0 + coord.y; + float eLR = 1.0 - abs(coord.x); + edgeDist = min(eSides, min(eTop, eLR)); + } else { // Plus — SDF as union of vertical and horizontal arms + float thickness = 0.35; + // SDF for vertical arm (half-extents: thickness x 1.0) + vec2 dV = abs(coord) - vec2(thickness, 1.0); + float sdfV = length(max(dV, 0.0)) + min(max(dV.x, dV.y), 0.0); + // SDF for horizontal arm (half-extents: 1.0 x thickness) + vec2 dH = abs(coord) - vec2(1.0, thickness); + float sdfH = length(max(dH, 0.0)) + min(max(dH.x, dH.y), 0.0); + // Union of both arms; negate so positive = inside + edgeDist = -min(sdfV, sdfH); + } + + // Anti-aliased shape edge: smooth alpha over ~1 screen pixel using + // screen-space derivatives of the distance field. + float aa = fwidth(edgeDist); + float shapeAlpha = smoothstep(0.0, aa, edgeDist); + if (shapeAlpha < 0.001) discard; + + // Early-out for hidden points (alpha=0). These remain in GPU arrays to + // preserve sort order across visibility toggles, avoiding costly re-sorts. + if (v_color.a < 0.001) discard; + + vec3 finalColor = v_color.rgb; + + // Pie Chart Logic (only for multi-label points, which always use circle shape) + if (v_labelCount > 1.5) { + float angle = atan(coord.y, coord.x); // -PI to PI + // Map to 0..1 + float normalizedAngle = (angle + PI) / (2.0 * PI); + + float count = floor(v_labelCount + 0.5); + float sliceIndex = floor(normalizedAngle * count); + + // Calculate texture lookup index + int globalIndex = v_pointIndex * u_maxLabels + int(sliceIndex); + int texW = int(u_labelTextureSize.x); + int tx = globalIndex % texW; + int ty = globalIndex / texW; + + vec4 texColor = texelFetch(u_labelColors, ivec2(tx, ty), 0); + + // Linearize texture color + finalColor = pow(max(texColor.rgb, vec3(0.0)), vec3(u_gamma)); + } + + // Darken near the edge to mimic a border/outline. + // Skip for faded points (low alpha) where the darkening is disproportionately visible. + float strokeWidth = 0.15; + if (v_color.a > 0.5 && max(edgeDist, 0.0) < strokeWidth) { + finalColor = finalColor * 0.5; + } + + float finalAlpha = v_color.a * shapeAlpha; + fragColor = vec4(finalColor * finalAlpha, finalAlpha); +}`; + +export const GAMMA_VERTEX_SHADER = `#version 300 es +precision highp float; + +in vec2 a_position; +out vec2 v_texCoord; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = (a_position + 1.0) * 0.5; +}`; + +export const GAMMA_FRAGMENT_SHADER = `#version 300 es +precision highp float; + +uniform sampler2D u_linearTexture; +uniform float u_gamma; + +in vec2 v_texCoord; +out vec4 fragColor; + +void main() { + vec4 linear = texture(u_linearTexture, v_texCoord); + + // Apply gamma correction to RGB, preserve alpha + vec3 corrected = pow(max(linear.rgb, vec3(0.0)), vec3(1.0 / u_gamma)); + + fragColor = vec4(corrected, linear.a); +}`; diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.test.ts new file mode 100644 index 00000000..0841aa11 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createLinearFramebuffer, destroyFramebuffer } from './framebuffer'; + +function mockGL(complete = true) { + const calls: string[] = []; + const gl = { + RGBA16F: 1, + RGBA: 2, + HALF_FLOAT: 3, + TEXTURE_2D: 4, + TEXTURE_MIN_FILTER: 5, + TEXTURE_MAG_FILTER: 6, + LINEAR: 7, + TEXTURE_WRAP_S: 8, + TEXTURE_WRAP_T: 9, + CLAMP_TO_EDGE: 10, + RENDERBUFFER: 11, + DEPTH_COMPONENT16: 12, + FRAMEBUFFER: 13, + COLOR_ATTACHMENT0: 14, + DEPTH_ATTACHMENT: 15, + FRAMEBUFFER_COMPLETE: 99, + createFramebuffer: () => ({ t: 'fb' }), + createTexture: () => ({ t: 'tex' }), + createRenderbuffer: () => ({ t: 'rb' }), + bindTexture: () => {}, + texImage2D: (...a: unknown[]) => calls.push(`texImage2D:${a[2]}:${a[3]}:${a[4]}:${a[7]}`), + texParameteri: () => {}, + bindRenderbuffer: () => {}, + renderbufferStorage: (..._a: unknown[]) => calls.push('rbStorage'), + bindFramebuffer: () => {}, + framebufferTexture2D: () => calls.push('attachColor'), + framebufferRenderbuffer: () => calls.push('attachDepth'), + checkFramebufferStatus: () => (complete ? 99 : 0), + deleteFramebuffer: vi.fn(), + deleteTexture: vi.fn(), + deleteRenderbuffer: vi.fn(), + } as unknown as WebGL2RenderingContext; + return { gl, calls }; +} + +describe('createLinearFramebuffer', () => { + it('allocates RGBA16F/HALF_FLOAT color + DEPTH_COMPONENT16 and returns resources when complete', () => { + const { gl, calls } = mockGL(true); + const fb = createLinearFramebuffer(gl, 320, 240); + expect(fb).not.toBeNull(); + expect(fb).toMatchObject({ width: 320, height: 240 }); + expect(calls).toContain('texImage2D:1:320:240:3'); // RGBA16F, w, h, HALF_FLOAT + expect(calls).toEqual(expect.arrayContaining(['rbStorage', 'attachColor', 'attachDepth'])); + }); + + it('returns null and deletes all three resources when framebuffer is incomplete', () => { + const { gl } = mockGL(false); + expect(createLinearFramebuffer(gl, 10, 10)).toBeNull(); + expect(gl.deleteFramebuffer as ReturnType).toHaveBeenCalledTimes(1); + expect(gl.deleteTexture as ReturnType).toHaveBeenCalledTimes(1); + expect(gl.deleteRenderbuffer as ReturnType).toHaveBeenCalledTimes(1); + }); +}); + +describe('destroyFramebuffer', () => { + it('deletes framebuffer, texture and depthBuffer', () => { + const { gl } = mockGL(true); + destroyFramebuffer(gl, { + framebuffer: {} as WebGLFramebuffer, + texture: {} as WebGLTexture, + depthBuffer: {} as WebGLRenderbuffer, + width: 1, + height: 1, + }); + expect(gl.deleteFramebuffer as ReturnType).toHaveBeenCalledTimes(1); + expect(gl.deleteTexture as ReturnType).toHaveBeenCalledTimes(1); + expect(gl.deleteRenderbuffer as ReturnType).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.ts new file mode 100644 index 00000000..995b9757 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/framebuffer.ts @@ -0,0 +1,61 @@ +import type { FramebufferResources } from '../types'; + +/** + * Allocate a LINEAR-filtered RGBA16F/HALF_FLOAT color target plus a + * DEPTH_COMPONENT16 renderbuffer, attach them to a framebuffer, and validate + * completeness. Returns the resource set, or `null` (after deleting all three + * resources) if any allocation fails or the framebuffer is incomplete. + * + * Pure helper: holds no renderer state and does not log. Callers decide how to + * react to a `null` result. + */ +export function createLinearFramebuffer( + gl: WebGL2RenderingContext, + width: number, + height: number, +): FramebufferResources | null { + const framebuffer = gl.createFramebuffer(); + const texture = gl.createTexture(); + const depthBuffer = gl.createRenderbuffer(); + if (!framebuffer || !texture || !depthBuffer) return null; + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); + + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer); + + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + if (status !== gl.FRAMEBUFFER_COMPLETE) { + gl.deleteFramebuffer(framebuffer); + gl.deleteTexture(texture); + gl.deleteRenderbuffer(depthBuffer); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + return null; + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + return { framebuffer, texture, depthBuffer, width, height }; +} + +/** + * Delete the framebuffer, color texture, and depth renderbuffer held by `fb`. + * Null-safe with respect to the resource set: pass a valid `FramebufferResources`. + */ +export function destroyFramebuffer(gl: WebGL2RenderingContext, fb: FramebufferResources): void { + gl.deleteFramebuffer(fb.framebuffer); + gl.deleteTexture(fb.texture); + gl.deleteRenderbuffer(fb.depthBuffer); +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.test.ts new file mode 100644 index 00000000..0fe19363 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { QUAD_VERTICES, drawGammaQuad } from './gamma-quad'; + +describe('QUAD_VERTICES', () => { + it('is the two-triangle full-screen quad (6 verts, 12 floats)', () => { + expect(Array.from(QUAD_VERTICES)).toEqual([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); + }); +}); + +describe('drawGammaQuad', () => { + it('binds the source texture + uniforms and draws 6 verts from the quad buffer', () => { + const calls: string[] = []; + const program = {} as WebGLProgram; + const quadBuffer = { name: 'quad' } as unknown as WebGLBuffer; + const sourceTexture = { name: 'src' } as unknown as WebGLTexture; + const gl = { + TEXTURE0: 33984, + TEXTURE_2D: 4, + ARRAY_BUFFER: 34962, + FLOAT: 5126, + TRIANGLES: 4, + useProgram: () => calls.push('useProgram'), + activeTexture: (u: number) => calls.push(`activeTexture:${u}`), + bindTexture: (_t: number, tex: { name?: string } | null) => + calls.push(`bindTexture:${tex?.name ?? 'null'}`), + getUniformLocation: (_p: WebGLProgram, n: string) => ({ n }), + uniform1i: (loc: { n: string }, v: number) => calls.push(`u1i:${loc.n}:${v}`), + uniform1f: (loc: { n: string }, v: number) => calls.push(`u1f:${loc.n}:${v}`), + bindBuffer: (_t: number, b: { name: string }) => calls.push(`bindBuffer:${b.name}`), + getAttribLocation: (_p: WebGLProgram, n: string) => (n === 'a_position' ? 7 : -1), + enableVertexAttribArray: (l: number) => calls.push(`enable:${l}`), + vertexAttribPointer: (l: number, s: number, t: number, _n: boolean, st: number, o: number) => + calls.push(`ptr:${l}:${s}:${t}:${st}:${o}`), + drawArrays: (m: number, f: number, c: number) => calls.push(`draw:${m}:${f}:${c}`), + disableVertexAttribArray: (l: number) => calls.push(`disable:${l}`), + } as unknown as WebGL2RenderingContext; + + drawGammaQuad(gl, program, sourceTexture, 2.2, quadBuffer, { + linearTexture: { n: 'u_linearTexture' } as unknown as WebGLUniformLocation, + gamma: { n: 'u_gamma' } as unknown as WebGLUniformLocation, + }); + + expect(calls).toEqual([ + 'useProgram', + 'activeTexture:33984', + 'bindTexture:src', + 'u1i:u_linearTexture:0', + 'u1f:u_gamma:2.2', + 'bindBuffer:quad', + 'enable:7', + 'ptr:7:2:5126:0:0', + 'draw:4:0:6', + 'disable:7', + 'bindTexture:null', + ]); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.ts b/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.ts new file mode 100644 index 00000000..3316444d --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/gamma-quad.ts @@ -0,0 +1,39 @@ +/** Two-triangle full-screen quad in clip space (matches the live setupQuad buffer). */ +export const QUAD_VERTICES = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); + +/** + * Run the gamma-correction full-screen-quad pass: sample `sourceTexture` on + * TEXTURE0, apply `gamma`, draw the quad from `quadBuffer` (must already hold + * QUAD_VERTICES). Assumes BLEND is already disabled by the caller. + * + * `uniforms` are the program's uniform locations resolved once at init (see + * WebGLRenderer.gammaCorrectionUniformLocations) — passing them in avoids a + * blocking `getUniformLocation` round-trip on every (per-frame) gamma pass. + */ +export function drawGammaQuad( + gl: WebGL2RenderingContext, + program: WebGLProgram, + sourceTexture: WebGLTexture, + gamma: number, + quadBuffer: WebGLBuffer, + uniforms: { + linearTexture: WebGLUniformLocation | null; + gamma: WebGLUniformLocation | null; + }, +): void { + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, sourceTexture); + gl.uniform1i(uniforms.linearTexture, 0); + gl.uniform1f(uniforms.gamma, gamma); + + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + const posLoc = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + + gl.disableVertexAttribArray(posLoc); + gl.bindTexture(gl.TEXTURE_2D, null); +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.test.ts new file mode 100644 index 00000000..1ed742ee --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GLResources } from './gl-resources'; +import type { FramebufferResources } from '../types'; + +function makeGl() { + return { + createBuffer: vi.fn(() => ({ k: 'buffer' })), + createVertexArray: vi.fn(() => ({ k: 'vao' })), + createTexture: vi.fn(() => ({ k: 'tex' })), + deleteBuffer: vi.fn(), + deleteVertexArray: vi.fn(), + deleteTexture: vi.fn(), + deleteProgram: vi.fn(), + deleteFramebuffer: vi.fn(), + deleteRenderbuffer: vi.fn(), + isProgram: vi.fn(() => true), + isVertexArray: vi.fn(() => true), + isBuffer: vi.fn(() => true), + isTexture: vi.fn(() => true), + isFramebuffer: vi.fn(() => true), + isRenderbuffer: vi.fn(() => true), + } as unknown as WebGL2RenderingContext; +} + +function makeFramebuffer(): FramebufferResources { + return { + framebuffer: { k: 'fb' } as unknown as WebGLFramebuffer, + texture: { k: 'fbtex' } as unknown as WebGLTexture, + depthBuffer: { k: 'rb' } as unknown as WebGLRenderbuffer, + width: 4, + height: 4, + }; +} + +describe('GLResources', () => { + it('createAll allocates the 6 vertex buffers + quad buffer, VAO is not built here, and the label texture', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + expect(gl.createBuffer).toHaveBeenCalledTimes(7); // 6 attrib + quad + expect(gl.createVertexArray).toHaveBeenCalledTimes(0); // VAO built in createPointVAO, not here + expect(gl.createTexture).toHaveBeenCalledTimes(1); // label color texture + expect(res.dataPositionBuffer).not.toBeNull(); + expect(res.sizeBuffer).not.toBeNull(); + expect(res.colorBuffer).not.toBeNull(); + expect(res.depthBuffer).not.toBeNull(); + expect(res.labelCountBuffer).not.toBeNull(); + expect(res.shapeBuffer).not.toBeNull(); + expect(res.quadBuffer).not.toBeNull(); + expect(res.labelColorTexture).not.toBeNull(); + }); + + it('deleteAll frees every owned handle and tolerates nulls', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + res.pointProgram = { k: 'prog' } as unknown as WebGLProgram; + res.gammaCorrectionProgram = { k: 'gamma' } as unknown as WebGLProgram; + res.pointVao = { k: 'vao' } as unknown as WebGLVertexArrayObject; + res.deleteAll(gl); + expect(gl.deleteBuffer).toHaveBeenCalledTimes(7); + expect(gl.deleteTexture).toHaveBeenCalledTimes(1); + expect(gl.deleteVertexArray).toHaveBeenCalledTimes(1); + expect(gl.deleteProgram).toHaveBeenCalledTimes(2); + }); + + it('deleteAll uses destroyFramebuffer to free the linear framebuffer and nulls it', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + res.linearFramebuffer = makeFramebuffer(); + res.deleteAll(gl); + expect(gl.deleteFramebuffer).toHaveBeenCalledTimes(1); + expect(gl.deleteTexture).toHaveBeenCalledTimes(2); // label texture + framebuffer color texture + expect(gl.deleteRenderbuffer).toHaveBeenCalledTimes(1); + expect(res.linearFramebuffer).toBeNull(); + }); + + it('validate returns true when every present handle is live', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + res.pointProgram = { k: 'prog' } as unknown as WebGLProgram; + expect(res.validate(gl)).toBe(true); + }); + + it('validate returns false when there is no point program', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + expect(res.validate(gl)).toBe(false); + }); + + it('validate returns false when a present buffer is not a live GL buffer', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + res.pointProgram = { k: 'prog' } as unknown as WebGLProgram; + (gl.isBuffer as ReturnType).mockReturnValueOnce(false); + expect(res.validate(gl)).toBe(false); + }); + + // NOTE: validate() byte-faithfully mirrors the original isRendererStateValid, + // which deliberately did NOT check quadBuffer or linearFramebuffer. Tests for + // those checks were removed because asserting them would lock in a behavior + // change (changing when ensureGL resets) that is out of scope for F-61. + + it('reset nulls every handle without touching gl', () => { + const gl = makeGl(); + const res = new GLResources(); + res.createAll(gl); + res.pointProgram = { k: 'prog' } as unknown as WebGLProgram; + res.gammaCorrectionProgram = { k: 'gamma' } as unknown as WebGLProgram; + res.pointVao = { k: 'vao' } as unknown as WebGLVertexArrayObject; + res.linearFramebuffer = makeFramebuffer(); + res.reset(); + expect(res.pointProgram).toBeNull(); + expect(res.gammaCorrectionProgram).toBeNull(); + expect(res.pointVao).toBeNull(); + expect(res.dataPositionBuffer).toBeNull(); + expect(res.sizeBuffer).toBeNull(); + expect(res.colorBuffer).toBeNull(); + expect(res.depthBuffer).toBeNull(); + expect(res.labelCountBuffer).toBeNull(); + expect(res.shapeBuffer).toBeNull(); + expect(res.quadBuffer).toBeNull(); + expect(res.labelColorTexture).toBeNull(); + expect(res.linearFramebuffer).toBeNull(); + expect(gl.deleteBuffer).not.toHaveBeenCalled(); + expect(gl.deleteFramebuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.ts b/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.ts new file mode 100644 index 00000000..f37ed41d --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/gl-resources.ts @@ -0,0 +1,117 @@ +import type { FramebufferResources } from '../types'; +import { destroyFramebuffer } from './framebuffer'; + +/** + * Holder for the GPU handles a WebGLRenderer owns. Centralizes the resource + * inventory that was previously enumerated independently in ensureGL (create*), + * isRendererStateValid (is*), dispose (delete*), and resetRendererState (null). + * + * Dirty-flag / signature / cache state is intentionally NOT held here — those + * stay on WebGLRenderer (labelTextureInitialized, gammaPipelineAvailable, + * warnedGammaFallback, buffersInitialized, currentPointCount, positionsDirty, + * stylesDirty, lastDataSignature, lastStyleSignature, renderedPointIds, + * sortedDataRef, and the WebGL2 context itself). + */ +export class GLResources { + pointProgram: WebGLProgram | null = null; + gammaCorrectionProgram: WebGLProgram | null = null; + pointVao: WebGLVertexArrayObject | null = null; + + dataPositionBuffer: WebGLBuffer | null = null; + sizeBuffer: WebGLBuffer | null = null; + colorBuffer: WebGLBuffer | null = null; + depthBuffer: WebGLBuffer | null = null; + labelCountBuffer: WebGLBuffer | null = null; + shapeBuffer: WebGLBuffer | null = null; + quadBuffer: WebGLBuffer | null = null; + + labelColorTexture: WebGLTexture | null = null; + linearFramebuffer: FramebufferResources | null = null; + + /** The 6 attribute buffers + quad buffer, in VAO-binding order. */ + private get vertexBuffers(): WebGLBuffer[] { + return [ + this.dataPositionBuffer, + this.sizeBuffer, + this.colorBuffer, + this.depthBuffer, + this.labelCountBuffer, + this.shapeBuffer, + this.quadBuffer, + ].filter((b): b is WebGLBuffer => b !== null); + } + + /** + * Allocate the 6 attribute buffers, the quad buffer, and the label texture. + * (The VAO is built by the renderer's `createPointVAO` and the two programs by + * the shader-init methods; those assign onto this holder after creation.) + */ + createAll(gl: WebGL2RenderingContext): void { + this.dataPositionBuffer = gl.createBuffer(); + this.sizeBuffer = gl.createBuffer(); + this.colorBuffer = gl.createBuffer(); + this.depthBuffer = gl.createBuffer(); + this.labelCountBuffer = gl.createBuffer(); + this.shapeBuffer = gl.createBuffer(); + this.quadBuffer = gl.createBuffer(); + this.labelColorTexture = gl.createTexture(); + } + + /** + * Byte-faithful mirror of the original `isRendererStateValid` resource checks. + * IMPORTANT (behavior-preserving): the original deliberately did NOT validate + * `quadBuffer` or `linearFramebuffer` — `ensureGL` reuses the context unless one + * of these specific handles is dead. Do not add checks here: that would change + * when `resetRendererState()` fires (an observable behavior change, out of scope + * for the F-61 extraction). + */ + validate(gl: WebGL2RenderingContext): boolean { + if (!this.pointProgram || !gl.isProgram(this.pointProgram)) return false; + if (this.pointVao && !gl.isVertexArray(this.pointVao)) return false; + if (this.dataPositionBuffer && !gl.isBuffer(this.dataPositionBuffer)) return false; + if (this.sizeBuffer && !gl.isBuffer(this.sizeBuffer)) return false; + if (this.colorBuffer && !gl.isBuffer(this.colorBuffer)) return false; + if (this.depthBuffer && !gl.isBuffer(this.depthBuffer)) return false; + if (this.labelCountBuffer && !gl.isBuffer(this.labelCountBuffer)) return false; + if (this.shapeBuffer && !gl.isBuffer(this.shapeBuffer)) return false; + if (this.labelColorTexture && !gl.isTexture(this.labelColorTexture)) return false; + return true; + } + + /** + * Delete every owned GPU handle, including the linear framebuffer (via + * `destroyFramebuffer`). Null-safe: handles that were never allocated are + * skipped. Deletion order matches the original `dispose()` byte-for-byte: VAO, + * then the attribute + quad buffers, then the label texture, then the point and + * gamma programs, and finally the linear framebuffer (which is also nulled). + * The order is immaterial to GL correctness (handles are independent) but is + * kept identical to avoid any behavioral drift from the extraction. + */ + deleteAll(gl: WebGL2RenderingContext): void { + if (this.pointVao) gl.deleteVertexArray(this.pointVao); + for (const buf of this.vertexBuffers) gl.deleteBuffer(buf); + if (this.labelColorTexture) gl.deleteTexture(this.labelColorTexture); + if (this.pointProgram) gl.deleteProgram(this.pointProgram); + if (this.gammaCorrectionProgram) gl.deleteProgram(this.gammaCorrectionProgram); + if (this.linearFramebuffer) { + destroyFramebuffer(gl, this.linearFramebuffer); + this.linearFramebuffer = null; + } + } + + /** Null every handle without touching gl (context-loss path). */ + reset(): void { + this.pointProgram = null; + this.gammaCorrectionProgram = null; + this.pointVao = null; + this.dataPositionBuffer = null; + this.sizeBuffer = null; + this.colorBuffer = null; + this.depthBuffer = null; + this.labelCountBuffer = null; + this.shapeBuffer = null; + this.quadBuffer = null; + this.labelColorTexture = null; + this.linearFramebuffer = null; + } +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.test.ts new file mode 100644 index 00000000..37fd8cd5 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { POINT_ATTRIBUTE_LAYOUT, setupAttributes } from './point-attributes'; +import type { PointAttribLocations } from '../types'; + +describe('POINT_ATTRIBUTE_LAYOUT', () => { + it('declares the six attributes with the live VAO sizes (2,1,4,1,1,1)', () => { + expect(POINT_ATTRIBUTE_LAYOUT.map((a) => [a.key, a.size])).toEqual([ + ['dataPosition', 2], + ['size', 1], + ['color', 4], + ['depth', 1], + ['labelCount', 1], + ['shape', 1], + ]); + }); +}); + +describe('setupAttributes', () => { + it('binds each buffer then enables + points its attribute with FLOAT, stride 0, offset 0', () => { + const calls: string[] = []; + const gl = { + ARRAY_BUFFER: 34962, + FLOAT: 5126, + bindBuffer: (_t: number, b: { name: string }) => calls.push(`bind:${b.name}`), + enableVertexAttribArray: (loc: number) => calls.push(`enable:${loc}`), + vertexAttribPointer: ( + loc: number, + size: number, + type: number, + _n: boolean, + stride: number, + off: number, + ) => calls.push(`ptr:${loc}:${size}:${type}:${stride}:${off}`), + } as unknown as WebGL2RenderingContext; + const buffers = { + dataPosition: { name: 'pos' }, + size: { name: 'sz' }, + color: { name: 'col' }, + depth: { name: 'dep' }, + labelCount: { name: 'lc' }, + shape: { name: 'sh' }, + } as unknown as Record; + const locations: PointAttribLocations = { + dataPosition: 0, + size: 1, + color: 2, + depth: 3, + labelCount: 4, + shape: 5, + }; + + setupAttributes(gl, buffers as never, locations); + + expect(calls).toEqual([ + 'bind:pos', + 'enable:0', + 'ptr:0:2:5126:0:0', + 'bind:sz', + 'enable:1', + 'ptr:1:1:5126:0:0', + 'bind:col', + 'enable:2', + 'ptr:2:4:5126:0:0', + 'bind:dep', + 'enable:3', + 'ptr:3:1:5126:0:0', + 'bind:lc', + 'enable:4', + 'ptr:4:1:5126:0:0', + 'bind:sh', + 'enable:5', + 'ptr:5:1:5126:0:0', + ]); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.ts new file mode 100644 index 00000000..d1a6c700 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-attributes.ts @@ -0,0 +1,36 @@ +import type { PointAttribLocations } from '../types'; + +type PointAttribKey = keyof PointAttribLocations; + +interface PointAttributeSpec { + key: PointAttribKey; + size: number; // components per vertex (matches createPointVAO sizes) +} + +/** Single source of truth for the six point attributes (order + component count). */ +export const POINT_ATTRIBUTE_LAYOUT: readonly PointAttributeSpec[] = [ + { key: 'dataPosition', size: 2 }, + { key: 'size', size: 1 }, + { key: 'color', size: 4 }, + { key: 'depth', size: 1 }, + { key: 'labelCount', size: 1 }, + { key: 'shape', size: 1 }, +] as const; + +type PointBuffers = Record; + +/** + * Wire the six point attributes into the currently-bound VAO. Caller must have + * bound the target VAO first; this mirrors the live createPointVAO sequence. + */ +export function setupAttributes( + gl: WebGL2RenderingContext, + buffers: PointBuffers, + locations: PointAttribLocations, +): void { + for (const { key, size } of POINT_ATTRIBUTE_LAYOUT) { + gl.bindBuffer(gl.ARRAY_BUFFER, buffers[key]); + gl.enableVertexAttribArray(locations[key]); + gl.vertexAttribPointer(locations[key], size, gl.FLOAT, false, 0, 0); + } +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts new file mode 100644 index 00000000..070170d3 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { resolvePointLocations } from './point-locations'; + +function mockGL() { + const program = {} as WebGLProgram; + const attribNames = [ + 'a_dataPosition', + 'a_pointSize', + 'a_color', + 'a_depth', + 'a_labelCount', + 'a_shape', + ]; + return { + program, + gl: { + getAttribLocation: (_p: WebGLProgram, name: string) => attribNames.indexOf(name), + getUniformLocation: (_p: WebGLProgram, name: string) => + ({ name }) as unknown as WebGLUniformLocation, + } as unknown as WebGL2RenderingContext, + }; +} + +describe('resolvePointLocations', () => { + it('resolves all six attributes by their shader names', () => { + const { gl, program } = mockGL(); + const { attribs } = resolvePointLocations(gl, program); + expect(attribs).toEqual({ + dataPosition: 0, + size: 1, + color: 2, + depth: 3, + labelCount: 4, + shape: 5, + }); + }); + + it('resolves all seven uniforms by their shader names', () => { + const { gl, program } = mockGL(); + const { uniforms } = resolvePointLocations(gl, program); + expect(Object.keys(uniforms).sort()).toEqual( + [ + 'dpr', + 'gamma', + 'labelColors', + 'labelTextureSize', + 'maxLabels', + 'resolution', + 'transform', + ].sort(), + ); + expect((uniforms.resolution as unknown as { name: string }).name).toBe('u_resolution'); + expect((uniforms.maxLabels as unknown as { name: string }).name).toBe('u_maxLabels'); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts new file mode 100644 index 00000000..35af7015 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts @@ -0,0 +1,32 @@ +import type { PointAttribLocations, PointUniformLocations } from '../types'; + +/** + * Resolve the attribute and uniform locations for the point shader program. + * + * Pure helper: it queries the supplied GL context/program by the exact shader + * variable names and returns a structured descriptor. No WebGLRenderer import. + */ +export function resolvePointLocations( + gl: WebGL2RenderingContext, + program: WebGLProgram, +): { attribs: PointAttribLocations; uniforms: PointUniformLocations } { + return { + attribs: { + dataPosition: gl.getAttribLocation(program, 'a_dataPosition'), + size: gl.getAttribLocation(program, 'a_pointSize'), + color: gl.getAttribLocation(program, 'a_color'), + depth: gl.getAttribLocation(program, 'a_depth'), + labelCount: gl.getAttribLocation(program, 'a_labelCount'), + shape: gl.getAttribLocation(program, 'a_shape'), + }, + uniforms: { + resolution: gl.getUniformLocation(program, 'u_resolution'), + transform: gl.getUniformLocation(program, 'u_transform'), + dpr: gl.getUniformLocation(program, 'u_dpr'), + gamma: gl.getUniformLocation(program, 'u_gamma'), + labelColors: gl.getUniformLocation(program, 'u_labelColors'), + labelTextureSize: gl.getUniformLocation(program, 'u_labelTextureSize'), + maxLabels: gl.getUniformLocation(program, 'u_maxLabels'), + }, + }; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.test.ts new file mode 100644 index 00000000..85845969 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { buildPaintOrder } from './point-staging'; + +describe('buildPaintOrder', () => { + it('orders slots far -> near (descending depth, ascending-index tie-break) in place', () => { + const order = new Uint32Array(4); + const depths = new Float32Array([0.5, 0.1, 0.9, 0.1]); + const plan = buildPaintOrder(order, depths, 4, false, () => 1); + // 0.9 (idx2) > 0.5 (idx0) > 0.1 (idx1, idx3 — ascending tiebreak) + expect(Array.from(plan.order)).toEqual([2, 0, 1, 3]); + expect(plan.order).toBe(order); // sorted in place, same instance returned + }); + + it('invokes the opacity callback exactly once per slot, in sorted draw order', () => { + const order = new Uint32Array(4); + const depths = new Float32Array([0.5, 0.1, 0.9, 0.1]); // sorted order -> [2,0,1,3] + const calls: Array<[number, number]> = []; + buildPaintOrder(order, depths, 4, false, (k, src) => { + calls.push([k, src]); + return 1; + }); + // (sortedIndex k, srcSlot order[k]) for the whole staged set, in draw order + expect(calls).toEqual([ + [0, 2], + [1, 0], + [2, 1], + [3, 3], + ]); + }); + + it('selectedStartIndex = first sorted slot with opacity >= 0.99 when a selection is active', () => { + const order = new Uint32Array(4); + const depths = new Float32Array([0.5, 0.1, 0.9, 0.1]); // sorted -> [2,0,1,3] + const opacityByOriginalSlot = [1.0, 1.0, 0.3, 0.3]; // slots 0,1 selected; 2,3 faded + const plan = buildPaintOrder(order, depths, 4, true, (_k, src) => opacityByOriginalSlot[src]); + // draw order [2,0,1,3] -> opacities [0.3, 1.0, 1.0, 0.3]; first >= 0.99 is at k=1 + expect(plan.selectedStartIndex).toBe(1); + }); + + it('threshold is inclusive at 0.99 and excludes just below', () => { + const depths = new Float32Array([0.2, 0.1]); // sorted -> [0,1] + expect( + buildPaintOrder(new Uint32Array(2), depths, 2, true, () => 0.99).selectedStartIndex, + ).toBe(0); + expect( + buildPaintOrder(new Uint32Array(2), depths, 2, true, () => 0.98).selectedStartIndex, + ).toBe(2); + }); + + it('selectedStartIndex = count when selection active but no slot qualifies (single blended pass)', () => { + const order = new Uint32Array(3); + const depths = new Float32Array([0.5, 0.2, 0.8]); + const plan = buildPaintOrder(order, depths, 3, true, () => 0.5); + expect(plan.selectedStartIndex).toBe(3); + }); + + it('selectedStartIndex = count when selection inactive, even with fully opaque points', () => { + const order = new Uint32Array(3); + const depths = new Float32Array([0.5, 0.2, 0.8]); + const plan = buildPaintOrder(order, depths, 3, false, () => 1.0); + expect(plan.selectedStartIndex).toBe(3); + }); + + it('count 0: no throw, callback never called, selectedStartIndex 0', () => { + const order = new Uint32Array(4); + const depths = new Float32Array([0.5, 0.3, 0.1, 0.8]); + let called = 0; + const plan = buildPaintOrder(order, depths, 0, true, () => { + called++; + return 1; + }); + expect(called).toBe(0); + expect(plan.selectedStartIndex).toBe(0); + }); + + it('count smaller than array length stages only order[0..count)', () => { + const order = new Uint32Array(6); + const depths = new Float32Array([0.3, 0.8, 0.1, 0.6, 0.9, 0.2]); + const seen: number[] = []; + const plan = buildPaintOrder(order, depths, 3, false, (_k, src) => { + seen.push(src); + return 1; + }); + // depths[0..3) = [0.3, 0.8, 0.1] -> sorted [1, 0, 2] + expect(Array.from(plan.order.subarray(0, 3))).toEqual([1, 0, 2]); + expect(seen).toEqual([1, 0, 2]); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.ts new file mode 100644 index 00000000..3a8a5a57 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-staging.ts @@ -0,0 +1,71 @@ +/** + * Canonical painter-order staging shared by the live render path + * (`WebGLRenderer.populateBuffers`) and the off-screen export path + * (`ExportRenderer.prepareOffscreenBufferData`). + * + * The live path is canonical: it stages EVERY slot (including opacity-0 slots, + * which are invisible but kept so the sort order is stable across visibility + * toggles), sorts indices far->near via {@link sortIndicesByDepthDescending} + * (descending depth, ties broken by ascending original slot index), and derives + * the two-pass selection cut (`selectedStartIndex`) from the FIRST sorted slot + * whose opacity is >= 0.99 when a selection is active. + * + * Extracting it here makes export == live structural rather than + * hand-maintained: both consume {@link buildPaintOrder}. + */ + +import { sortIndicesByDepthDescending } from './depth-sort'; + +/** Opacity threshold at/above which a point counts as selected for the two-pass cut. */ +const SELECTED_OPACITY_THRESHOLD = 0.99; + +/** Result of computing the canonical painter-order staging plan. */ +interface PaintOrderPlan { + /** + * Slot indices in far->near (descending-depth) draw order. `order[0..count)` + * is valid; entries index into the ORIGINAL (input) slot order. This is the + * caller's `sortOrder` scratch, sorted in place. + */ + order: Uint32Array; + /** + * Index into `order` where the selected (opacity >= 0.99) run begins, used by + * the two-pass selection blend. Equals `count` when no selection is active or + * no point qualifies (i.e. draw everything in a single blended pass). + */ + selectedStartIndex: number; +} + +/** + * Compute the canonical painter-order plan for `count` slots. + * + * @param order Caller-owned scratch (length >= count); sorted in place and returned. + * @param depths Per-slot depth scratch indexed by ORIGINAL slot index (length >= count). + * Caller fills `depths[i]` for every `i < count` before calling. + * @param count Number of slots to stage. + * @param selectionActive Whether a selection is active (enables the two-pass cut). + * @param getOpacityAtSortedSlot Returns the opacity of the slot drawn at sorted + * position `k` (i.e. for `order[k]`). Called once per slot in + * sorted order; lets the caller hook per-slot side effects + * (e.g. tracking rendered IDs) while we locate `firstSelected`. + */ +export function buildPaintOrder( + order: Uint32Array, + depths: Float32Array, + count: number, + selectionActive: boolean, + getOpacityAtSortedSlot: (sortedIndex: number, srcSlot: number) => number, +): PaintOrderPlan { + sortIndicesByDepthDescending(order, depths, count); + + let firstSelected = -1; + for (let k = 0; k < count; k++) { + const opacity = getOpacityAtSortedSlot(k, order[k]); + if (selectionActive && firstSelected === -1 && opacity >= SELECTED_OPACITY_THRESHOLD) { + firstSelected = k; + } + } + + const selectedStartIndex = selectionActive && firstSelected !== -1 ? firstSelected : count; + + return { order, selectedStartIndex }; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts new file mode 100644 index 00000000..72138614 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { bindAndClearTarget, setPointBlendState, drawPoints } from './render-target'; + +function mockGL() { + const calls: string[] = []; + const gl = { + FRAMEBUFFER: 13, + COLOR_BUFFER_BIT: 0x4000, + DEPTH_BUFFER_BIT: 0x100, + BLEND: 1, + ONE: 1, + ONE_MINUS_SRC_ALPHA: 771, + DEPTH_TEST: 2929, + POINTS: 0, + bindFramebuffer: (_t: number, fb: unknown) => + calls.push(`bindFB:${fb === null ? 'null' : 'fb'}`), + viewport: (...a: number[]) => calls.push(`viewport:${a.join(',')}`), + clearColor: (...a: number[]) => calls.push(`clearColor:${a.join(',')}`), + clear: (m: number) => calls.push(`clear:${m}`), + enable: (c: number) => calls.push(`enable:${c}`), + disable: (c: number) => calls.push(`disable:${c}`), + blendFunc: (...a: number[]) => calls.push(`blendFunc:${a.join(',')}`), + depthMask: (b: boolean) => calls.push(`depthMask:${b}`), + drawArrays: (...a: number[]) => calls.push(`drawArrays:${a.join(',')}`), + } as unknown as WebGL2RenderingContext; + return { gl, calls }; +} + +describe('bindAndClearTarget', () => { + it('binds the given framebuffer, sets viewport, clears transparent color+depth', () => { + const { gl, calls } = mockGL(); + const fb = {} as WebGLFramebuffer; + bindAndClearTarget(gl, fb, 400, 300); + expect(calls).toEqual([ + 'bindFB:fb', + 'viewport:0,0,400,300', + 'clearColor:0,0,0,0', + `clear:${0x4000 | 0x100}`, + ]); + }); + + it('binds the default framebuffer when passed null', () => { + const { gl, calls } = mockGL(); + bindAndClearTarget(gl, null, 10, 20); + expect(calls[0]).toBe('bindFB:null'); + }); +}); + +describe('setPointBlendState', () => { + it('enables premultiplied-over blend and disables depth test + mask', () => { + const { gl, calls } = mockGL(); + setPointBlendState(gl); + expect(calls).toEqual(['enable:1', 'blendFunc:1,771', 'disable:2929', 'depthMask:false']); + }); +}); + +describe('drawPoints', () => { + it('two-pass: selection active draws unselected (blend off) then selected (blend on)', () => { + const { gl, calls } = mockGL(); + drawPoints(gl, 100, true, 30); + expect(calls).toEqual([ + 'disable:1', + 'drawArrays:0,0,30', + 'enable:1', + 'blendFunc:1,771', + 'drawArrays:0,30,70', + ]); + }); + + it('two-pass: skips the unselected draw when selectedStartIndex is 0', () => { + const { gl, calls } = mockGL(); + drawPoints(gl, 100, true, 0); + expect(calls).toEqual(['disable:1', 'enable:1', 'blendFunc:1,771', 'drawArrays:0,0,100']); + }); + + it('single-pass: no selection draws all points with blend on', () => { + const { gl, calls } = mockGL(); + drawPoints(gl, 100, false, 0); + expect(calls).toEqual(['enable:1', 'blendFunc:1,771', 'drawArrays:0,0,100']); + }); + + it('single-pass: falls back when selectedStartIndex is at/after the point count', () => { + const { gl, calls } = mockGL(); + drawPoints(gl, 100, true, 100); + expect(calls).toEqual(['enable:1', 'blendFunc:1,771', 'drawArrays:0,0,100']); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts new file mode 100644 index 00000000..8c404f57 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts @@ -0,0 +1,127 @@ +/** + * Pure WebGL render-target helpers. + * + * These capture the uniform bind/clear, blend-state, and point-draw decision + * subsets shared across the renderer's draw paths. They are intentionally free + * of any `WebGLRenderer` dependency so they can be unit-tested against a + * recording mock GL. + */ + +import type { PointUniformLocations } from '../types'; + +/** + * Binds the given framebuffer (or the default framebuffer when `null`), sets the + * viewport to the full target, and clears it to transparent black + depth. + */ +export function bindAndClearTarget( + gl: WebGL2RenderingContext, + framebufferOrNull: WebGLFramebuffer | null, + width: number, + height: number, +): void { + gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferOrNull); + gl.viewport(0, 0, width, height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); +} + +/** Premultiplied-over blend with depth test/mask disabled (painter's-algorithm draw). */ +export function setPointBlendState(gl: WebGL2RenderingContext): void { + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); +} + +/** Per-draw inputs for {@link bindPointDrawState}. */ +interface PointDrawStateParams { + /** Physical target dimensions in device pixels (u_resolution). */ + width: number; + height: number; + /** Current zoom transform (u_transform = x, y, k). */ + transform: { x: number; y: number; k: number }; + /** Device pixel ratio (u_dpr). */ + dpr: number; + /** Effective gamma (u_gamma); 1.0 when the gamma pipeline is unavailable. */ + gamma: number; + /** Max labels per point (u_maxLabels). */ + maxLabels: number; + /** Label color texture atlas width in texels. */ + labelTextureWidth: number; + /** Total length of the label-color texel array (RGBA u8); used to derive atlas height. */ + labelColorDataLength: number; +} + +/** + * Bind the per-frame point-draw GL state shared by the live and export draw + * paths, immediately before {@link drawPoints}: + * 1. select the point program, + * 2. (re)assert the painter's-algorithm blend/depth precondition + * ({@link setPointBlendState} — idempotent, so calling it per-draw is + * behavior-preserving and removes the live path's dependence on a single + * once-at-init call), + * 3. push the point uniforms (resolution, transform, dpr, gamma, maxLabels, + * labelTextureSize) in the exact order both paths used, + * 4. bind the label-color texture to TEXTURE1 and point the sampler at unit 1, + * 5. bind the point VAO. + * + * The caller issues `drawPoints(...)` next, then unbinds the VAO. + */ +export function bindPointDrawState( + gl: WebGL2RenderingContext, + program: WebGLProgram, + uniforms: PointUniformLocations, + vao: WebGLVertexArrayObject | null, + labelTexture: WebGLTexture | null, + params: PointDrawStateParams, +): void { + gl.useProgram(program); + + // Painter's-algorithm precondition local to the point draw: premultiplied-over + // blend, depth test/mask off. Idempotent GL-state setup. + setPointBlendState(gl); + + gl.uniform2f(uniforms.resolution, params.width, params.height); + gl.uniform3f(uniforms.transform, params.transform.x, params.transform.y, params.transform.k); + gl.uniform1f(uniforms.dpr, params.dpr); + gl.uniform1f(uniforms.gamma, params.gamma); + gl.uniform1i(uniforms.maxLabels, params.maxLabels); + gl.uniform2f( + uniforms.labelTextureSize, + params.labelTextureWidth, + params.labelColorDataLength / 4 / params.labelTextureWidth, + ); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, labelTexture); + gl.uniform1i(uniforms.labelColors, 1); + + gl.bindVertexArray(vao); +} + +/** + * Draws the staged points, choosing the two-pass (selection-active) or + * single-pass (no selection) strategy. + * + * Two-pass: unselected points are drawn with blend OFF (flat fading, no density + * accumulation) followed by selected points with blend ON (correct MSAA on + * opaque points). Single-pass: all points with blend ON (density visible). + */ +export function drawPoints( + gl: WebGL2RenderingContext, + pointCount: number, + selectionActive: boolean, + selectedStartIndex: number, +): void { + if (selectionActive && selectedStartIndex < pointCount) { + gl.disable(gl.BLEND); + if (selectedStartIndex > 0) gl.drawArrays(gl.POINTS, 0, selectedStartIndex); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.drawArrays(gl.POINTS, selectedStartIndex, pointCount - selectedStartIndex); + } else { + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.drawArrays(gl.POINTS, 0, pointCount); + } +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts new file mode 100644 index 00000000..12ee5aae --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { stagePoint, type StagePointArrays, type StagePointStyle } from './stage-point'; +import type { PlotDataPoint } from '@protspace/utils'; + +function arrays(capacity: number): StagePointArrays { + return { + dataPositions: new Float32Array(capacity * 2), + sizes: new Float32Array(capacity), + colors: new Float32Array(capacity * 4), + depths: new Float32Array(capacity), + labelCounts: new Float32Array(capacity), + shapes: new Float32Array(capacity), + labelColorData: new Uint8Array(capacity * 8 * 4), + }; +} + +const style = { + getColors: () => ['#ff0000'], + getPointSize: () => 36, // sqrt(36)/3 = 2 + getShape: () => 'circle', // shapeIndex 0 +} as unknown as StagePointStyle; + +describe('stagePoint', () => { + it('writes scaled position, clamped color, basePointSize and depth for one slot', () => { + const a = arrays(4); + const sp: PlotDataPoint = { id: 'p', x: 0, y: 0, originalIndex: 0 }; + // screenX/screenY already scaled by the caller (scales.x/scales.y) + stagePoint( + a, + /*idx*/ 1, + sp, + /*screenX*/ 12, + /*screenY*/ 34, + /*opacity*/ 0.5, + /*depth*/ 0.7, + style, + /*dpr*/ 2, + /*sizeScaleFactor*/ 1, + ); + expect(a.dataPositions[2]).toBe(12); + expect(a.dataPositions[3]).toBe(34); + expect(a.colors[4]).toBeCloseTo(1); // r + expect(a.colors[7]).toBeCloseTo(0.5); // clamped opacity + // size=2, basePointSize=max(1, 2*2*2*1)=8, circle → 8 + expect(a.sizes[1]).toBeCloseTo(8); + expect(a.depths[1]).toBeCloseTo(0.7); + expect(a.labelCounts[1]).toBe(1); + expect(a.shapes[1]).toBe(0); + }); + + it('applies DIAMOND_SIZE_SCALE for shapeIndex 2 (diamond)', () => { + const a = arrays(2); + const diamond = { + getColors: () => ['#00ff00'], + getPointSize: () => 36, + getShape: () => 'diamond', + } as never; + const sp: PlotDataPoint = { id: 'p', x: 0, y: 0, originalIndex: 0 }; + stagePoint(a, 0, sp, 0, 0, 1, 0, diamond, 1, 1); + // size=2, base=max(1,2*2*1*1)=4, diamond → 4*1.25=5 + expect(a.sizes[0]).toBeCloseTo(5); + }); + + it('sizeScaleFactor scales basePointSize (export parity)', () => { + const a = arrays(2); + const sp: PlotDataPoint = { id: 'p', x: 0, y: 0, originalIndex: 0 }; + stagePoint(a, 0, sp, 0, 0, 1, 0, style, 1, 2); + // size=2, base=max(1, 2*2*1*2)=8 + expect(a.sizes[0]).toBeCloseTo(8); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts new file mode 100644 index 00000000..e990120d --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts @@ -0,0 +1,103 @@ +import type { PlotDataPoint } from '@protspace/utils'; +import { getShapeIndex } from '@protspace/utils'; +import type { WebGLStyleGetters } from '../types'; +import { resolveColor } from '../color-utils'; +import { fillLabelColorTexels } from './label-texture-utils'; + +// ============================================================================ +// Per-point staging constants (owned here; only MAX_LABELS is re-imported by +// the renderer — the rest are internal to the staging helpers). +// ============================================================================ + +const POINT_SIZE_DIVISOR = 3; +const MIN_POINT_SIZE = 1; +const DIAMOND_SIZE_SCALE = 1.25; +export const MAX_LABELS = 8; + +/** + * The parallel target arrays a staged point is written into. The renderer holds + * the same Float32Array/Uint8Array instances as class fields; this struct is a + * zero-copy view re-pointed whenever capacity is reallocated. + */ +export interface StagePointArrays { + dataPositions: Float32Array; + sizes: Float32Array; + colors: Float32Array; + depths: Float32Array; + labelCounts: Float32Array; + shapes: Float32Array; + labelColorData: Uint8Array; +} + +/** The subset of style getters a single staged-point write depends on. */ +export type StagePointStyle = Pick; + +/** The style channels (everything except position + depth) a staged point writes. */ +type StagePointStyleArrays = Pick< + StagePointArrays, + 'colors' | 'sizes' | 'labelCounts' | 'shapes' | 'labelColorData' +>; + +/** + * Write a point's *style* channels (color, alpha, size, shape, label texels) into + * `target` at slot `idx`. Shared by the full-rebuild path (via {@link stagePoint}) + * and the color-only update path in the renderer, so a legend recolor encodes a + * point identically to a full rebuild / export — the single source of truth for + * the per-point style packing. + * + * Pure helper: no GL, no WebGLRenderer import. + */ +export function stagePointStyle( + target: StagePointStyleArrays, + idx: number, + sp: PlotDataPoint, + opacity: number, + style: StagePointStyle, + dpr: number, + sizeScaleFactor = 1, +): void { + const pointColors = style.getColors(sp); + const [r, g, b] = resolveColor(pointColors[0] ?? '#888888'); + const size = Math.sqrt(style.getPointSize(sp)) / POINT_SIZE_DIVISOR; + const shapeIndex = getShapeIndex(style.getShape(sp)); + + target.colors[idx * 4] = r; + target.colors[idx * 4 + 1] = g; + target.colors[idx * 4 + 2] = b; + target.colors[idx * 4 + 3] = Math.min(1, Math.max(0, opacity)); + + const basePointSize = Math.max(MIN_POINT_SIZE, size * 2 * dpr * sizeScaleFactor); + target.sizes[idx] = shapeIndex === 2 ? basePointSize * DIAMOND_SIZE_SCALE : basePointSize; + target.labelCounts[idx] = pointColors.length; + target.shapes[idx] = shapeIndex; + + fillLabelColorTexels(target.labelColorData, idx, pointColors, MAX_LABELS); +} + +/** + * Write one staged point into the parallel target arrays at slot `idx`. + * + * `screenX`/`screenY` are already in device-independent screen space (the caller + * applied `scales.x`/`scales.y`). `opacity`/`depth` were computed by the caller's + * painter's-algorithm sort. `sizeScaleFactor` defaults to 1 for the live path; + * the offscreen export passes the export/display area ratio. + * + * Pure helper: no GL, no WebGLRenderer import. + */ +export function stagePoint( + target: StagePointArrays, + idx: number, + sp: PlotDataPoint, + screenX: number, + screenY: number, + opacity: number, + depth: number, + style: StagePointStyle, + dpr: number, + sizeScaleFactor = 1, +): void { + target.dataPositions[idx * 2] = screenX; + target.dataPositions[idx * 2 + 1] = screenY; + stagePointStyle(target, idx, sp, opacity, style, dpr, sizeScaleFactor); + target.depths[idx] = depth; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts b/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts new file mode 100644 index 00000000..29e2dc18 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts @@ -0,0 +1,144 @@ +// Minimal WebGL2 stub: enough surface for WebGLRenderer.ensureGL()/render() to run in jsdom. +// Toggles let tests force the failure exits the audit cites (F-03) and gamma fallbacks (F-09). +import { vi } from 'vitest'; + +export interface MockGLOptions { + /** getContext('webgl2') returns null (F-03 no-context exit). */ + contextUnavailable?: boolean; + /** linkProgram succeeds but getProgramParameter(LINK_STATUS) reports false → createProgram null (F-03). */ + failProgramLink?: boolean; + /** getExtension(EXT_color_buffer_float|EXT_float_blend) returns null → gamma unavailable (F-09). */ + missingFloatExtensions?: boolean; + /** checkFramebufferStatus returns a non-COMPLETE value (F-09 framebuffer-incomplete fallback). */ + framebufferIncomplete?: boolean; +} + +export function createMockCanvas(opts: MockGLOptions = {}): { + canvas: HTMLCanvasElement; + gl: WebGL2RenderingContext | null; + setContextLost: (v: boolean) => void; + /** Restores the getContext spy. Callers using afterEach(vi.restoreAllMocks) + * get this for free; this handle lets callers restore explicitly instead of + * relying on suite-level discipline. */ + restore: () => void; +} { + const canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + let lost = false; + + const gl = opts.contextUnavailable + ? null + : (makeGL(opts, () => lost) as unknown as WebGL2RenderingContext); + + // jsdom canvas.getContext returns null; intercept to return our stub. + const getContextSpy = vi + .spyOn(canvas, 'getContext') + .mockImplementation(((id: string) => + id === 'webgl2' ? gl : null) as typeof canvas.getContext); + + return { + canvas, + gl, + setContextLost: (v: boolean) => { + lost = v; + }, + restore: () => getContextSpy.mockRestore(), + }; +} + +function makeGL(opts: MockGLOptions, isLost: () => boolean): Record { + const C = { + LINK_STATUS: 0x8b82, + COMPILE_STATUS: 0x8b81, + FRAGMENT_SHADER: 0x8b30, + VERTEX_SHADER: 0x8b31, + FRAMEBUFFER: 0x8d40, + FRAMEBUFFER_COMPLETE: 0x8cd5, + COLOR_BUFFER_BIT: 0x4000, + DEPTH_BUFFER_BIT: 0x100, + BLEND: 0x0be2, + DEPTH_TEST: 0x0b71, + POINTS: 0x0000, + ARRAY_BUFFER: 0x8892, + TEXTURE_2D: 0x0de1, + RENDERBUFFER: 0x8d41, + ONE: 1, + ONE_MINUS_SRC_ALPHA: 0x0303, + }; + const noop = () => {}; + const obj: Record = { + ...C, + isContextLost: () => isLost(), + getExtension: (name: string) => + opts.missingFloatExtensions && + (name === 'EXT_color_buffer_float' || name === 'EXT_float_blend') + ? null + : {}, + createShader: () => ({}), + shaderSource: noop, + compileShader: noop, + getShaderParameter: () => true, + getShaderInfoLog: () => '', + createProgram: () => ({}), + attachShader: noop, + linkProgram: noop, + getProgramParameter: (_p: unknown, pname: number) => + pname === C.LINK_STATUS ? !opts.failProgramLink : true, + getProgramInfoLog: () => '', + useProgram: noop, + deleteProgram: noop, + deleteShader: noop, + getAttribLocation: () => 0, + getUniformLocation: () => ({}), + createBuffer: () => ({}), + bindBuffer: noop, + bufferData: noop, + deleteBuffer: noop, + createVertexArray: () => ({}), + bindVertexArray: noop, + deleteVertexArray: noop, + enableVertexAttribArray: noop, + vertexAttribPointer: noop, + createTexture: () => ({}), + bindTexture: noop, + texImage2D: noop, + texParameteri: noop, + texSubImage2D: noop, + deleteTexture: noop, + activeTexture: noop, + createFramebuffer: () => ({}), + bindFramebuffer: noop, + framebufferTexture2D: noop, + framebufferRenderbuffer: noop, + deleteFramebuffer: noop, + createRenderbuffer: () => ({}), + bindRenderbuffer: noop, + renderbufferStorage: noop, + deleteRenderbuffer: noop, + checkFramebufferStatus: () => (opts.framebufferIncomplete ? 0 : C.FRAMEBUFFER_COMPLETE), + isProgram: () => true, + isVertexArray: () => true, + isBuffer: () => true, + isTexture: () => true, + isFramebuffer: () => true, + isRenderbuffer: () => true, + viewport: noop, + clearColor: noop, + clear: noop, + enable: noop, + disable: noop, + blendFunc: noop, + depthMask: noop, + drawArrays: noop, + uniform1f: noop, + uniform1i: noop, + uniform2f: noop, + uniform3f: noop, + uniformMatrix3fv: noop, + uniform4fv: noop, + pixelStorei: noop, + disableVertexAttribArray: noop, + }; + return obj; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/viewport-defaults.ts b/packages/core/src/components/scatter-plot/webgl/renderer/viewport-defaults.ts new file mode 100644 index 00000000..7cace7a4 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/viewport-defaults.ts @@ -0,0 +1,3 @@ +/** Fallback CSS-pixel viewport used when ScatterplotConfig omits width/height. */ +export const DEFAULT_VIEWPORT_WIDTH = 800; +export const DEFAULT_VIEWPORT_HEIGHT = 600; diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.context-loss.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.context-loss.test.ts new file mode 100644 index 00000000..fed3440e --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.context-loss.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { WebGLRenderer } from './webgl-renderer'; +import type { PlotData } from '@protspace/utils'; +import type { ScalePair, WebGLStyleGetters } from '../types'; +import { createMockCanvas } from './test-support/mock-webgl2'; + +// The shared mock-webgl2 harness provides the full gl.* surface the render path needs +// (incl. uniform3f / disableVertexAttribArray), so render()-driven tests below can +// exercise the real path via createMockCanvas directly. +const pd: PlotData = { + length: 2, + xs: new Float32Array([0, 1]), + ys: new Float32Array([0, 1]), + zs: null, + originalIndices: null, + proteinIds: ['p0', 'p1'], +}; +const scales = (): ScalePair => ({ + x: d3.scaleLinear().domain([0, 1]).range([0, 800]), + y: d3.scaleLinear().domain([0, 1]).range([0, 600]), +}); +const style = (): WebGLStyleGetters => ({ + getColors: () => ['#f00'], + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', +}); + +describe('WebGLRenderer context loss + restore (F-09 characterization lock)', () => { + let rafQueue: FrameRequestCallback[]; + beforeEach(() => { + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); // restores the requestAnimationFrame stub + vi.restoreAllMocks(); // restores vi.spyOn spies (render spies + createMockCanvas getContext) + }); + const drain = () => { + const q = rafQueue; + rafQueue = []; + q.forEach((cb) => cb(0)); + }; + + it('webglcontextlost fires onContextLost and preventDefaults', () => { + const { canvas } = createMockCanvas(); + const onLost = vi.fn(); + new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + onLost, + ); + const ev = new Event('webglcontextlost', { cancelable: true }); + const prevented = !canvas.dispatchEvent(ev); + expect(onLost).toHaveBeenCalledTimes(1); + expect(prevented).toBe(true); // preventDefault() was called + }); + + it('destroy() removes both listeners (post-destroy loss does not fire onContextLost)', () => { + const { canvas } = createMockCanvas(); + const onLost = vi.fn(); + const r = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + onLost, + ); + r.destroy(); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + expect(onLost).not.toHaveBeenCalled(); + }); + + // F-39: the internal webglcontextrestored recovery handler was deleted. It was + // unreachable in production (real loss → onContextLost → scatter-plot destroy()s + // the renderer, which removes the webglcontextlost listener and disposes; the + // restore listener never survived to fire). Recovery now flows solely through the + // scatter-plot rebuild-on-loss path. These two cases used to characterize the dead + // internal handler (they only "passed" because they synthesized the restore event + // directly); they now pin its absence. + it('F-39: no webglcontextrestored listener — dispatching restore does NOT re-render', () => { + const { canvas } = createMockCanvas(); + const r = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); + r.render(pd); // sets lastRenderedData + const renderSpy = vi.spyOn(r, 'render'); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + canvas.dispatchEvent(new Event('webglcontextrestored')); + drain(); // no RAF was ever queued by the (now-deleted) restore handler + expect(renderSpy).not.toHaveBeenCalled(); + }); + + it('F-39: constructor registers no webglcontextrestored listener', () => { + const addSpy = vi.spyOn(HTMLCanvasElement.prototype, 'addEventListener'); + const r = new WebGLRenderer( + createMockCanvas().canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); + const types = addSpy.mock.calls.map((c) => c[0]); + expect(types).toContain('webglcontextlost'); + expect(types).not.toContain('webglcontextrestored'); + r.destroy(); + }); +}); + +describe('WebGLRenderer gamma fallback (F-09 characterization lock)', () => { + // Restores the createMockCanvas getContext spies and any console.warn spy so + // none leak into later suites. vi.unstubAllGlobals does not restore vi.spyOn. + afterEach(() => vi.restoreAllMocks()); + + // CHARACTERIZATION LOCK (verified against the unmodified tree, webgl-renderer.ts): + // On the missing-float-extensions path, ensureGL sets `gammaPipelineAvailable = false` + // (L1492) BEFORE calling handleGammaFallback('required extensions missing') (L1494). + // handleGammaFallback's first line `if (!this.gammaPipelineAvailable) return;` (L535) + // short-circuits past console.warn, so production emits ZERO warnings on this path + // (the warn-once message is effectively unreachable for the missing-extensions case) + // while getEffectiveGamma() still drops to 1.0 (shouldUseGammaPipeline() === false). + // This pins BOTH facts; any refactor that changes the warn count or the gamma value + // fails the lock. (The plan sketch asserted "warns once"; the true count is 0.) + it('missing float extensions → getEffectiveGamma() drops to 1.0 (silently, no warn)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { canvas } = createMockCanvas({ missingFloatExtensions: true }); + const r = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); + r.render(pd); // ensureGL detects missing extensions → gammaPipelineAvailable = false + const getGamma = (r as unknown as { getEffectiveGamma(): number }).getEffectiveGamma.bind(r); + expect(getGamma()).toBe(1.0); + expect(warnSpy).toHaveBeenCalledTimes(0); // L1492-before-L1494 ordering bypasses the warn + }); + + it('framebuffer incomplete during init → gamma pipeline drops to direct (gamma 1.0)', () => { + const { canvas } = createMockCanvas({ framebufferIncomplete: true }); + const r = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); + r.render(pd); + expect((r as unknown as { getEffectiveGamma(): number }).getEffectiveGamma()).toBe(1.0); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.init.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.init.test.ts new file mode 100644 index 00000000..7963dcef --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.init.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { WebGLRenderer } from './webgl-renderer'; +import type { PlotData } from '@protspace/utils'; +import type { ScalePair, WebGLStyleGetters } from '../types'; +import { createMockCanvas, type MockGLOptions } from './test-support/mock-webgl2'; + +const pd: PlotData = { + length: 2, + xs: new Float32Array([0, 1]), + ys: new Float32Array([0, 1]), + zs: null, + originalIndices: null, + proteinIds: ['p0', 'p1'], +}; +const scales = (): ScalePair => ({ + x: d3.scaleLinear().domain([0, 1]).range([0, 800]), + y: d3.scaleLinear().domain([0, 1]).range([0, 600]), +}); +const style = (): WebGLStyleGetters => ({ + getColors: () => ['#f00'], + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', +}); + +function makeRenderer(opts: MockGLOptions) { + const { canvas } = createMockCanvas(opts); + return new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); +} + +describe('WebGLRenderer init failure (F-03 characterization lock)', () => { + // Per-test cleanup: restores every vi.spyOn (console.error below + the + // getContext spy createMockCanvas installs) even if a test throws before any + // inline restore. Inline mockRestore() can be skipped by an exception and leak + // a console spy into the rest of the suite. + afterEach(() => vi.restoreAllMocks()); + + it('getContext(webgl2) null → render() is a no-op, does not throw', () => { + const r = makeRenderer({ contextUnavailable: true }); + expect(() => r.render(pd)).not.toThrow(); + // No usable context, so no draw is attempted. drawArrays spy proves nothing rendered. + }); + + it('program link failure → render() does not throw and draws nothing', () => { + const { canvas, gl } = createMockCanvas({ failProgramLink: true }); + const drawSpy = vi.spyOn(gl as unknown as { drawArrays: () => void }, 'drawArrays'); + const r = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); + expect(() => r.render(pd)).not.toThrow(); + expect(drawSpy).not.toHaveBeenCalled(); + }); + + it('console.error is emitted (not swallowed) when getContext returns null', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + makeRenderer({ contextUnavailable: true }).render(pd); + expect(errSpy).toHaveBeenCalledWith('WebGL2 not available'); + // Restore is handled by afterEach(vi.restoreAllMocks) so an early throw + // above cannot leak this console.error spy into later tests. + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.lifecycle.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.lifecycle.test.ts new file mode 100644 index 00000000..045ff221 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.lifecycle.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { WebGLRenderer } from './webgl-renderer'; +import type { PlotData } from '@protspace/utils'; +import type { ScalePair, WebGLStyleGetters } from '../types'; +import { createMockCanvas } from './test-support/mock-webgl2'; + +// B1 renderer lifecycle behavior-change tests (TDD): F-43, F-39, F-01. +// These assert POST-change behavior, so on the unmodified tree: +// - F-43 (destroy disposes GPU resources) -> RED +// - F-39 (no webglcontextrestored listener) -> RED +// - F-01 programmatic loss routes to onContextLost -> RED +// - F-01 DOM no-double-fire (invariant lock) -> GREEN +// +// The shared mock-webgl2 harness provides the full gl.* surface the render path +// needs (incl. uniform3f / disableVertexAttribArray), so render()-driven tests +// exercise the real path via createMockCanvas directly. + +const scales = (): ScalePair => ({ + x: d3.scaleLinear().domain([0, 1]).range([0, 800]), + y: d3.scaleLinear().domain([0, 1]).range([0, 600]), +}); + +const styleGetters = (): WebGLStyleGetters => ({ + getColors: () => ['#f00'], + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', +}); + +const getTransform = () => d3.zoomIdentity; +const getConfig = () => ({ width: 800, height: 600 }); + +function makePlotData(n: number): PlotData { + const xs = new Float32Array(n); + const ys = new Float32Array(n); + const proteinIds: string[] = []; + for (let i = 0; i < n; i++) { + xs[i] = i / Math.max(1, n - 1); + ys[i] = i / Math.max(1, n - 1); + proteinIds.push(`p${i}`); + } + return { length: n, xs, ys, zs: null, originalIndices: null, proteinIds }; +} + +describe('WebGLRenderer lifecycle (B1: F-43 / F-39 / F-01)', () => { + let rafQueue: FrameRequestCallback[]; + beforeEach(() => { + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + // F-43 — destroy() becomes the single GPU-teardown owner. + it('F-43: destroy() deletes GPU resources via dispose()', () => { + const { canvas, gl } = createMockCanvas(); + const renderer = new WebGLRenderer(canvas, scales, getTransform, getConfig, styleGetters()); + // Force lazy resource creation so there are handles to delete. + renderer.render(makePlotData(3)); // ensureGL() -> createBuffer/VAO/texture/program + + const del = { + vao: vi.spyOn(gl!, 'deleteVertexArray'), + buffer: vi.spyOn(gl!, 'deleteBuffer'), + texture: vi.spyOn(gl!, 'deleteTexture'), + program: vi.spyOn(gl!, 'deleteProgram'), + }; + + renderer.destroy(); + + expect(del.vao).toHaveBeenCalledTimes(1); // pointVao + expect(del.buffer.mock.calls.length).toBeGreaterThanOrEqual(7); // 6 data buffers + quad + expect(del.texture.mock.calls.length).toBeGreaterThanOrEqual(1); // labelColorTexture (+linearFramebuffer.texture when the gamma pipeline is available) + expect(del.program.mock.calls.length).toBeGreaterThanOrEqual(1); // pointProgram (+gamma if available) + }); + + // F-39 — delete the unreachable internal handleContextRestored recovery. + it('F-39: constructor registers no webglcontextrestored listener', () => { + const { canvas } = createMockCanvas(); + const add = vi.spyOn(canvas, 'addEventListener'); + const r = new WebGLRenderer(canvas, scales, getTransform, getConfig, styleGetters(), vi.fn()); + const types = add.mock.calls.map((c) => c[0]); + expect(types).toContain('webglcontextlost'); + expect(types).not.toContain('webglcontextrestored'); + r.destroy(); + }); + + // F-01 — route programmatic context loss to recovery (sanctioned visible change). + it('F-01: programmatic loss (gl.isContextLost) routes to onContextLost once', () => { + const { canvas, gl } = createMockCanvas(); + const onContextLost = vi.fn(); + const r = new WebGLRenderer( + canvas, + scales, + getTransform, + getConfig, + styleGetters(), + onContextLost, + ); + r.render(makePlotData(3)); // acquire context + // Simulate a driver reset with NO webglcontextlost DOM event: + vi.spyOn(gl!, 'isContextLost').mockReturnValue(true); + r.render(makePlotData(3)); // render -> ensureGL/isContextLost -> markContextLost + expect(onContextLost).toHaveBeenCalledTimes(1); + r.destroy(); + }); + + it('F-01: DOM webglcontextlost still fires onContextLost exactly once (no double-fire)', () => { + const { canvas } = createMockCanvas(); + const onContextLost = vi.fn(); + const r = new WebGLRenderer( + canvas, + scales, + getTransform, + getConfig, + styleGetters(), + onContextLost, + ); + r.render(makePlotData(3)); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + expect(onContextLost).toHaveBeenCalledTimes(1); + r.destroy(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.signature.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.signature.test.ts new file mode 100644 index 00000000..46b9c468 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.signature.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { WebGLRenderer } from './webgl-renderer'; +import type { PlotData } from '@protspace/utils'; +import type { ScalePair, WebGLStyleGetters } from '../types'; +import { createMockCanvas } from './test-support/mock-webgl2'; + +function pd(xs: number[], ys: number[]): PlotData { + return { + length: xs.length, + xs: new Float32Array(xs), + ys: new Float32Array(ys), + zs: null, + originalIndices: null, + proteinIds: xs.map((_, i) => `p${i}`), + }; +} +const scales = (): ScalePair => ({ + x: d3.scaleLinear().domain([0, 10]).range([0, 800]), + y: d3.scaleLinear().domain([0, 10]).range([0, 600]), +}); +const style = (): WebGLStyleGetters => ({ + getColors: () => ['#ff0000'], + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', +}); + +function makeRenderer() { + const { canvas } = createMockCanvas(); + return new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(), + ); +} + +describe('WebGLRenderer sampled-slot signatures (F-02 characterization lock)', () => { + let populateSpy: ReturnType; + let renderer: ReturnType; + beforeEach(() => { + renderer = makeRenderer(); + // populateBuffers is the buffer-rebuild gate render() runs iff a signature changed. + populateSpy = vi + .spyOn( + renderer as unknown as { populateBuffers: (...a: unknown[]) => void }, + 'populateBuffers', + ) + .mockImplementation(() => {}); + // Stub the gamma draw pass: this lock characterizes the signature/populateBuffers gate + // only, not pixel output, so neutralizing the draw pass keeps render() cheap and leaves + // every assertion intact. + vi.spyOn( + renderer as unknown as { renderWithGammaCorrection: (...a: unknown[]) => void }, + 'renderWithGammaCorrection', + ).mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + it('a coordinate change at a SAMPLED slot (0, len/2, len-1) triggers a rebuild', () => { + const a = pd([0, 1, 2], [0, 1, 2]); + renderer.render(a); + populateSpy.mockClear(); + renderer.render(pd([0, 1, 9], [0, 1, 2])); // slot 2 (= len-1) x changed + expect(populateSpy).toHaveBeenCalled(); + }); + + it('LOCK (documents the lossy gap, INV-12/INV-09): a change at an UNSAMPLED slot is MISSED by the signature', () => { + // Length 5 → sampled slots for data sig are {0, 2, 4}; slot 1 and 3 are NOT sampled. + const a = pd([0, 1, 2, 3, 4], [0, 1, 2, 3, 4]); + renderer.render(a); + populateSpy.mockClear(); + // Mutate only slot 1 (unsampled): same length, identical at 0/2/4 → signature collides. + renderer.render(pd([0, 99, 2, 3, 4], [0, 1, 2, 3, 4])); + // Current behavior is INTENTIONALLY lossy; explicit invalidate*() covers real mutation paths. + // B6 MUST keep an explicit invalidate on same-shape in-place coordinate swaps (INV-12/INV-09). + expect(populateSpy).not.toHaveBeenCalled(); + }); + + it('positionsDirty (explicit invalidate) forces a rebuild even when signatures collide', () => { + const a = pd([0, 1, 2, 3, 4], [0, 1, 2, 3, 4]); + renderer.render(a); + populateSpy.mockClear(); + renderer.invalidatePositionCache(); // the explicit path that backstops the lossy signature + renderer.render(pd([0, 99, 2, 3, 4], [0, 1, 2, 3, 4])); + expect(populateSpy).toHaveBeenCalled(); + }); +}); + +// ── F-55 / F-56 removal guards on a live WebGLRenderer instance ───────────── +// F-55: the unused public getGamma/setGamma accessors are removed; the gamma +// field and its effective-gamma resolver (getEffectiveGamma) stay. +// F-56: the @deprecated no-op setSelectedAnnotation is removed; the live +// signature methods (setStyleSignature) survive. +describe('WebGLRenderer dead-accessor removal guards (F-55, F-56)', () => { + let renderer: ReturnType; + beforeEach(() => { + renderer = makeRenderer(); + }); + afterEach(() => vi.restoreAllMocks()); + + it('F-55: getGamma / setGamma are gone; getEffectiveGamma survives', () => { + const surface = renderer as unknown as Record; + expect(surface.getGamma).toBeUndefined(); + expect(surface.setGamma).toBeUndefined(); + // getEffectiveGamma is private; reach it through the same indexed view used + // by the context-loss lock. + expect(typeof surface.getEffectiveGamma).toBe('function'); + }); + + it('F-56: setSelectedAnnotation is gone; setStyleSignature survives', () => { + const surface = renderer as unknown as Record; + expect(surface.setSelectedAnnotation).toBeUndefined(); + expect(typeof surface.setStyleSignature).toBe('function'); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts index 7a73fa8b..ae813f0e 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts @@ -8,209 +8,45 @@ * Falls back to direct rendering if gamma pipeline is unavailable. */ -import * as d3 from 'd3'; +import type * as d3 from 'd3'; import type { PlotData, PlotDataPoint, ScatterplotConfig } from '@protspace/utils'; -import { getShapeIndex, EMPTY_PLOT_DATA } from '@protspace/utils'; import { type WebGLStyleGetters, type ScalePair, - type FramebufferResources, + type PointAttribLocations, + type PointUniformLocations, MAX_POINTS_DIRECT_RENDER, DEFAULT_GAMMA, } from '../types'; -import { resolveColor } from '../color-utils'; import { createProgramFromSources } from '../shader-utils'; -import { fillLabelColorTexels } from './label-texture-utils'; -import { sortIndicesByDepthDescending } from './depth-sort'; +import { resolvePointLocations } from './point-locations'; +import { setupAttributes } from './point-attributes'; +import { buildPaintOrder } from './point-staging'; import { planRendererCapacity } from './capacity-planner'; - -// ============================================================================ -// Shader Sources -// ============================================================================ +import { createLinearFramebuffer, destroyFramebuffer } from './framebuffer'; +import { GLResources } from './gl-resources'; +import { + bindAndClearTarget, + setPointBlendState, + drawPoints, + bindPointDrawState, +} from './render-target'; +import { QUAD_VERTICES, drawGammaQuad } from './gamma-quad'; +import { DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT } from './viewport-defaults'; +import { stagePoint, stagePointStyle, type StagePointArrays, MAX_LABELS } from './stage-point'; +import { ContextLossController } from './context-loss-controller'; +import { ExportRenderer } from './export-renderer'; +import { + POINT_VERTEX_SHADER, + POINT_FRAGMENT_SHADER, + GAMMA_VERTEX_SHADER, + GAMMA_FRAGMENT_SHADER, +} from './export-shaders'; // Constants -const POINT_SIZE_DIVISOR = 3; -const MIN_POINT_SIZE = 1; const MIN_CAPACITY = 1024; -const MAX_LABELS = 8; const LABEL_TEXTURE_WIDTH = 2048; const POINTS_PER_TEXTURE_ROW = LABEL_TEXTURE_WIDTH / MAX_LABELS; -const DIAMOND_SIZE_SCALE = 1.25; - -// Stable reference dimensions for margin scaling at export time. Tying margin -// scaling to the live display canvas (via `config.width/height`, which track -// `clientWidth/clientHeight`) made captured plots window-size dependent — same -// data lands at slightly different pixel positions when the browser is resized, -// causing publish-modal overlays to drift relative to clusters across sessions. -// Anchoring to a fixed reference makes the export render reproducible. -const EXPORT_MARGIN_REFERENCE_WIDTH = 800; -const EXPORT_MARGIN_REFERENCE_HEIGHT = 600; - -const POINT_VERTEX_SHADER = `#version 300 es -precision highp float; - -in vec2 a_dataPosition; -in float a_pointSize; -in vec4 a_color; -in float a_depth; -in float a_labelCount; -in float a_shape; - -uniform vec2 u_resolution; -uniform vec3 u_transform; -uniform float u_dpr; -uniform float u_gamma; - -out vec4 v_color; -out float v_labelCount; -flat out float v_shape; -flat out int v_pointIndex; - -void main() { - vec2 cssTransformed = a_dataPosition * u_transform.z + u_transform.xy; - vec2 physicalPos = cssTransformed * u_dpr; - vec2 clipSpace = (physicalPos / u_resolution) * 2.0 - 1.0; - - // Depth is computed per-point on the CPU (opacity + legend z-order tie-break) - gl_Position = vec4(clipSpace.x, -clipSpace.y, a_depth, 1.0); - gl_PointSize = max(1.0, a_pointSize); - - // Convert sRGB input to linear RGB for proper blending - vec3 linearColor = pow(max(a_color.rgb, vec3(0.0)), vec3(u_gamma)); - v_color = vec4(linearColor, a_color.a); - v_labelCount = a_labelCount; - v_shape = a_shape; - v_pointIndex = gl_VertexID; -}`; - -const POINT_FRAGMENT_SHADER = `#version 300 es -precision highp float; - -in vec4 v_color; -in float v_labelCount; -flat in float v_shape; -flat in int v_pointIndex; - -uniform sampler2D u_labelColors; -uniform vec2 u_labelTextureSize; -uniform int u_maxLabels; -uniform float u_gamma; - -out vec4 fragColor; - -const float PI = 3.14159265359; -const float SQRT3 = 1.73205080757; - -void main() { - vec2 coord = gl_PointCoord * 2.0 - 1.0; - - // Compute signed edge distance for each shape. - // Positive = inside, zero = on boundary, negative = outside. - // This single computation drives both anti-aliasing and the outline effect. - float edgeDist; - - if (v_shape < 0.5) { // Circle - edgeDist = 1.0 - length(coord); - } else if (v_shape < 1.5) { // Square - edgeDist = 1.0 - max(abs(coord.x), abs(coord.y)); - } else if (v_shape < 2.5) { // Diamond - // Match d3.symbolDiamond proportions (same mapping as D3's "tan30" constant, i.e. sqrt(1/3)) - edgeDist = 1.0 - (abs(coord.x) * SQRT3 + abs(coord.y)); - } else if (v_shape < 3.5) { // Triangle Up - // Inside region: abs(x)*SQRT3 <= 1 + y, clipped to point quad [-1,1]^2. - float eSides = (1.0 + coord.y - abs(coord.x) * SQRT3) / 2.0; - float eBottom = 1.0 - coord.y; - float eLR = 1.0 - abs(coord.x); - edgeDist = min(eSides, min(eBottom, eLR)); - } else if (v_shape < 4.5) { // Triangle Down - // Inside region: abs(x)*SQRT3 <= 1 - y, clipped to point quad [-1,1]^2. - float eSides = (1.0 - coord.y - abs(coord.x) * SQRT3) / 2.0; - float eTop = 1.0 + coord.y; - float eLR = 1.0 - abs(coord.x); - edgeDist = min(eSides, min(eTop, eLR)); - } else { // Plus — SDF as union of vertical and horizontal arms - float thickness = 0.35; - // SDF for vertical arm (half-extents: thickness x 1.0) - vec2 dV = abs(coord) - vec2(thickness, 1.0); - float sdfV = length(max(dV, 0.0)) + min(max(dV.x, dV.y), 0.0); - // SDF for horizontal arm (half-extents: 1.0 x thickness) - vec2 dH = abs(coord) - vec2(1.0, thickness); - float sdfH = length(max(dH, 0.0)) + min(max(dH.x, dH.y), 0.0); - // Union of both arms; negate so positive = inside - edgeDist = -min(sdfV, sdfH); - } - - // Anti-aliased shape edge: smooth alpha over ~1 screen pixel using - // screen-space derivatives of the distance field. - float aa = fwidth(edgeDist); - float shapeAlpha = smoothstep(0.0, aa, edgeDist); - if (shapeAlpha < 0.001) discard; - - // Early-out for hidden points (alpha=0). These remain in GPU arrays to - // preserve sort order across visibility toggles, avoiding costly re-sorts. - if (v_color.a < 0.001) discard; - - vec3 finalColor = v_color.rgb; - - // Pie Chart Logic (only for multi-label points, which always use circle shape) - if (v_labelCount > 1.5) { - float angle = atan(coord.y, coord.x); // -PI to PI - // Map to 0..1 - float normalizedAngle = (angle + PI) / (2.0 * PI); - - float count = floor(v_labelCount + 0.5); - float sliceIndex = floor(normalizedAngle * count); - - // Calculate texture lookup index - int globalIndex = v_pointIndex * u_maxLabels + int(sliceIndex); - int texW = int(u_labelTextureSize.x); - int tx = globalIndex % texW; - int ty = globalIndex / texW; - - vec4 texColor = texelFetch(u_labelColors, ivec2(tx, ty), 0); - - // Linearize texture color - finalColor = pow(max(texColor.rgb, vec3(0.0)), vec3(u_gamma)); - } - - // Darken near the edge to mimic a border/outline. - // Skip for faded points (low alpha) where the darkening is disproportionately visible. - float strokeWidth = 0.15; - if (v_color.a > 0.5 && max(edgeDist, 0.0) < strokeWidth) { - finalColor = finalColor * 0.5; - } - - float finalAlpha = v_color.a * shapeAlpha; - fragColor = vec4(finalColor * finalAlpha, finalAlpha); -}`; -const GAMMA_VERTEX_SHADER = `#version 300 es -precision highp float; - -in vec2 a_position; -out vec2 v_texCoord; - -void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = (a_position + 1.0) * 0.5; -}`; - -const GAMMA_FRAGMENT_SHADER = `#version 300 es -precision highp float; - -uniform sampler2D u_linearTexture; -uniform float u_gamma; - -in vec2 v_texCoord; -out vec4 fragColor; - -void main() { - vec4 linear = texture(u_linearTexture, v_texCoord); - - // Apply gamma correction to RGB, preserve alpha - vec3 corrected = pow(max(linear.rgb, vec3(0.0)), vec3(1.0 / u_gamma)); - - fragColor = vec4(corrected, linear.a); -}`; // ============================================================================ // WebGL2 Renderer Implementation @@ -219,50 +55,22 @@ void main() { export class WebGLRenderer { private gl: WebGL2RenderingContext | null = null; - // Point rendering - private pointProgram: WebGLProgram | null = null; - private pointVao: WebGLVertexArrayObject | null = null; - private pointAttribLocations: { - dataPosition: number; - size: number; - color: number; - depth: number; - labelCount: number; - shape: number; - } | null = null; - private pointUniformLocations: { - resolution: WebGLUniformLocation | null; - transform: WebGLUniformLocation | null; - dpr: WebGLUniformLocation | null; - gamma: WebGLUniformLocation | null; - labelColors: WebGLUniformLocation | null; - labelTextureSize: WebGLUniformLocation | null; - maxLabels: WebGLUniformLocation | null; - } | null = null; - - // Full-screen quad for gamma correction - private quadBuffer: WebGLBuffer | null = null; + // Owned GPU handles (programs, VAO, buffers, quad, label texture, framebuffer). + // Resource inventory (create/validate/delete/reset) lives in GLResources; the + // dirty-flag/signature/cache state below stays on the class. + private resources = new GLResources(); - // Gamma correction (final pass) - private gammaCorrectionProgram: WebGLProgram | null = null; + // Shader location maps (resolved from the live programs; not GPU-owned handles, + // so they are NOT part of the GLResources inventory). + private pointAttribLocations: PointAttribLocations | null = null; + private pointUniformLocations: PointUniformLocations | null = null; private gammaCorrectionUniformLocations: { linearTexture: WebGLUniformLocation | null; gamma: WebGLUniformLocation | null; } | null = null; - // Linear RGB framebuffer for gamma-correct rendering - private linearFramebuffer: FramebufferResources | null = null; private gamma = DEFAULT_GAMMA; - // GPU Buffers - private dataPositionBuffer: WebGLBuffer | null = null; - private sizeBuffer: WebGLBuffer | null = null; - private colorBuffer: WebGLBuffer | null = null; - private depthBuffer: WebGLBuffer | null = null; - private labelCountBuffer: WebGLBuffer | null = null; - private shapeBuffer: WebGLBuffer | null = null; - private labelColorTexture: WebGLTexture | null = null; - // CPU arrays private dataPositions = new Float32Array(0); private sizes = new Float32Array(0); @@ -272,6 +80,10 @@ export class WebGLRenderer { private shapes = new Float32Array(0); private labelColorData = new Uint8Array(0); + // Zero-copy view over the parallel staging arrays above, passed to `stagePoint`. + // Re-pointed in `refreshStageArrays()` whenever capacity is reallocated. + private stageArrays: StagePointArrays = this.buildStageArrays(); + // State private capacity = 0; private labelTextureInitialized = false; @@ -316,23 +128,15 @@ export class WebGLRenderer { private styleSignature: string | null = null; private gammaPipelineAvailable = true; private warnedGammaFallback = false; - private contextLost = false; - private readonly handleContextLost = (event: Event) => { - event.preventDefault(); - this.markContextLost(); - this.onContextLost?.(); - }; - private readonly handleContextRestored = () => { - this.contextLost = false; - this.resetRendererState(); - if (this.lastRenderedData && this.lastRenderedData.length > 0) { - requestAnimationFrame(() => { - if (!this.contextLost) { - this.render(this.lastRenderedData ?? EMPTY_PLOT_DATA); - } - }); - } - }; + + // Context-loss lifecycle (listener + idempotent "lost" flag) lives in the + // controller; `markContextLost`/`isContextLost` delegate to it. + private readonly lossController: ContextLossController; + + // Off-screen export subsystem. Stateless apart from the ephemeral context it + // creates per `renderToCanvas` call; the facade passes in the live data, + // config, style getters, transform, gamma, and selection state. + private readonly exportRenderer = new ExportRenderer(); constructor( private canvas: HTMLCanvasElement, @@ -342,36 +146,21 @@ export class WebGLRenderer { private style: WebGLStyleGetters, private onContextLost?: () => void, ) { - this.canvas.addEventListener('webglcontextlost', this.handleContextLost, { passive: false }); - this.canvas.addEventListener('webglcontextrestored', this.handleContextRestored); + this.lossController = new ContextLossController(this.canvas, () => { + this.resetRendererState(); + this.onContextLost?.(); + }); } destroy() { - this.canvas.removeEventListener('webglcontextlost', this.handleContextLost); - this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored); + this.lossController.destroy(); + this.dispose(); } // ============================================================================ // Public API // ============================================================================ - /** - * Set the gamma value for display. - * Standard sRGB displays use gamma ~2.2. - * @param gamma Gamma value (clamped between 1.0 and 3.0) - */ - setGamma(gamma: number) { - this.gamma = Math.max(1.0, Math.min(3.0, gamma)); - } - - /** - * Get the current gamma value. - * @returns Current gamma value - */ - getGamma(): number { - return this.gamma; - } - setStyleSignature(signature: string | null) { if (this.styleSignature !== signature) { this.styleSignature = signature; @@ -383,14 +172,6 @@ export class WebGLRenderer { this.selectionActive = active; } - /** - * @deprecated Selected annotation is now handled via style signature. - * Kept for backward compatibility. - */ - setSelectedAnnotation(_annotation: string) { - // No-op: selected annotation is now part of style signature - } - invalidateStyleCache() { this.stylesDirty = true; } @@ -476,58 +257,24 @@ export class WebGLRenderer { const gl = this.gl; // Reuse existing framebuffer if dimensions match - if (this.linearFramebuffer) { - if (this.linearFramebuffer.width === width && this.linearFramebuffer.height === height) { + if (this.resources.linearFramebuffer) { + if ( + this.resources.linearFramebuffer.width === width && + this.resources.linearFramebuffer.height === height + ) { return true; } // Clean up old framebuffer - gl.deleteFramebuffer(this.linearFramebuffer.framebuffer); - gl.deleteTexture(this.linearFramebuffer.texture); - gl.deleteRenderbuffer(this.linearFramebuffer.depthBuffer); - this.linearFramebuffer = null; - } - - const framebuffer = gl.createFramebuffer(); - const texture = gl.createTexture(); - const depthBuffer = gl.createRenderbuffer(); - - if (!framebuffer || !texture || !depthBuffer) { - return false; + destroyFramebuffer(gl, this.resources.linearFramebuffer); + this.resources.linearFramebuffer = null; } - gl.bindTexture(gl.TEXTURE_2D, texture); - - // Use RGBA16F for linear color space with good precision - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - - gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); - gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); - - gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); - gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer); - - const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); - if (status !== gl.FRAMEBUFFER_COMPLETE) { - console.error('Linear framebuffer not complete:', status); - gl.deleteFramebuffer(framebuffer); - gl.deleteTexture(texture); - gl.deleteRenderbuffer(depthBuffer); - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.bindTexture(gl.TEXTURE_2D, null); - gl.bindRenderbuffer(gl.RENDERBUFFER, null); + const fb = createLinearFramebuffer(gl, width, height); + if (!fb) { + console.error('Linear framebuffer not complete'); return false; } - - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.bindTexture(gl.TEXTURE_2D, null); - gl.bindRenderbuffer(gl.RENDERBUFFER, null); - - this.linearFramebuffer = { framebuffer, texture, depthBuffer, width, height }; + this.resources.linearFramebuffer = fb; return true; } @@ -548,32 +295,30 @@ export class WebGLRenderer { return; } - if (this.gammaCorrectionProgram) { - gl.deleteProgram(this.gammaCorrectionProgram); - this.gammaCorrectionProgram = null; + if (this.resources.gammaCorrectionProgram) { + gl.deleteProgram(this.resources.gammaCorrectionProgram); + this.resources.gammaCorrectionProgram = null; } - if (this.linearFramebuffer) { - gl.deleteFramebuffer(this.linearFramebuffer.framebuffer); - gl.deleteTexture(this.linearFramebuffer.texture); - gl.deleteRenderbuffer(this.linearFramebuffer.depthBuffer); - this.linearFramebuffer = null; + if (this.resources.linearFramebuffer) { + destroyFramebuffer(gl, this.resources.linearFramebuffer); + this.resources.linearFramebuffer = null; } this.gammaCorrectionUniformLocations = null; } private cleanupGammaResources() { - this.gammaCorrectionProgram = null; + this.resources.gammaCorrectionProgram = null; this.gammaCorrectionUniformLocations = null; - this.linearFramebuffer = null; + this.resources.linearFramebuffer = null; } private shouldUseGammaPipeline(): boolean { return ( this.gammaPipelineAvailable && - !!this.linearFramebuffer && - !!this.gammaCorrectionProgram && + !!this.resources.linearFramebuffer && + !!this.resources.gammaCorrectionProgram && !!this.gammaCorrectionUniformLocations ); } @@ -599,8 +344,8 @@ export class WebGLRenderer { if (!gl || !scales || this.isContextLost()) return; const config = this.getConfig(); - const width = config.width ?? 800; - const height = config.height ?? 600; + const width = config.width ?? DEFAULT_VIEWPORT_WIDTH; + const height = config.height ?? DEFAULT_VIEWPORT_HEIGHT; this.resize(width, height); const transform = this.getTransform(); @@ -642,7 +387,7 @@ export class WebGLRenderer { return; } - const framebuffer = this.linearFramebuffer; + const framebuffer = this.resources.linearFramebuffer; if (!framebuffer) { this.renderDirect(transform); return; @@ -666,10 +411,7 @@ export class WebGLRenderer { this.renderPoints(transform); // Pass 2: Gamma correction to canvas - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, this.canvas.width, this.canvas.height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + bindAndClearTarget(gl, null, this.canvas.width, this.canvas.height); this.renderGammaCorrection(); } @@ -677,42 +419,32 @@ export class WebGLRenderer { private renderGammaCorrection() { if ( !this.gl || - !this.gammaCorrectionProgram || - !this.linearFramebuffer || - !this.gammaCorrectionUniformLocations + !this.resources.gammaCorrectionProgram || + !this.resources.linearFramebuffer || + !this.gammaCorrectionUniformLocations || + !this.resources.quadBuffer ) { return; } const gl = this.gl; gl.disable(gl.BLEND); - gl.useProgram(this.gammaCorrectionProgram); - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, this.linearFramebuffer.texture); - gl.uniform1i(this.gammaCorrectionUniformLocations.linearTexture, 0); - gl.uniform1f(this.gammaCorrectionUniformLocations.gamma, this.gamma); - - // Draw full-screen quad - gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer); - const posLoc = gl.getAttribLocation(this.gammaCorrectionProgram, 'a_position'); - gl.enableVertexAttribArray(posLoc); - gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.TRIANGLES, 0, 6); - - gl.disableVertexAttribArray(posLoc); - gl.bindTexture(gl.TEXTURE_2D, null); + drawGammaQuad( + gl, + this.resources.gammaCorrectionProgram, + this.resources.linearFramebuffer.texture, + this.gamma, + this.resources.quadBuffer, + this.gammaCorrectionUniformLocations, + ); } private renderDirect(transform: d3.ZoomTransform) { if (!this.gl) return; const gl = this.gl; - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, this.canvas.width, this.canvas.height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + bindAndClearTarget(gl, null, this.canvas.width, this.canvas.height); this.renderPoints(transform); } @@ -721,36 +453,24 @@ export class WebGLRenderer { if (!this.gl) return; const gl = this.gl; - if (this.pointVao) gl.deleteVertexArray(this.pointVao); - if (this.dataPositionBuffer) gl.deleteBuffer(this.dataPositionBuffer); - if (this.sizeBuffer) gl.deleteBuffer(this.sizeBuffer); - if (this.colorBuffer) gl.deleteBuffer(this.colorBuffer); - if (this.depthBuffer) gl.deleteBuffer(this.depthBuffer); - if (this.labelCountBuffer) gl.deleteBuffer(this.labelCountBuffer); - if (this.shapeBuffer) gl.deleteBuffer(this.shapeBuffer); - if (this.quadBuffer) gl.deleteBuffer(this.quadBuffer); - if (this.labelColorTexture) gl.deleteTexture(this.labelColorTexture); - if (this.pointProgram) gl.deleteProgram(this.pointProgram); - if (this.gammaCorrectionProgram) gl.deleteProgram(this.gammaCorrectionProgram); - - if (this.linearFramebuffer) { - gl.deleteFramebuffer(this.linearFramebuffer.framebuffer); - gl.deleteTexture(this.linearFramebuffer.texture); - gl.deleteRenderbuffer(this.linearFramebuffer.depthBuffer); - this.linearFramebuffer = null; - } + this.resources.deleteAll(gl); this.gl = null; } // ============================================================================ - // Off-Screen Export Rendering + // Off-Screen Export Rendering (delegated to ExportRenderer) // ============================================================================ /** * Render visualization at arbitrary dimensions to a new off-screen canvas. * Creates a temporary WebGL context, renders at requested size, returns 2D canvas. * + * Thin delegate over {@link ExportRenderer.renderToCanvas}: the facade supplies + * the last-rendered data, the live config + style getters, and the live render + * state (selection, transform, gamma) so the export equals the on-screen render + * (incl. the F-15 two-pass selection blend). + * * @param width Target width in CSS pixels (will be multiplied by DPR) * @param height Target height in CSS pixels * @param dpr Device pixel ratio to use (defaults to 1 for max resolution control) @@ -763,164 +483,16 @@ export class WebGLRenderer { dataDomain?: { xMin: number; xMax: number; yMin: number; yMax: number }, pointSizeReference?: { width: number; height: number }, ): HTMLCanvasElement { - // Validate dimensions - const physicalWidth = Math.floor(width * dpr); - const physicalHeight = Math.floor(height * dpr); - - const MAX_DIMENSION = 8192; - const MAX_AREA = 268435456; // ~268M pixels - - if (physicalWidth > MAX_DIMENSION || physicalHeight > MAX_DIMENSION) { - throw new Error( - `Export dimensions ${physicalWidth}×${physicalHeight} exceed browser limit of ${MAX_DIMENSION}px`, - ); - } - if (physicalWidth * physicalHeight > MAX_AREA) { - throw new Error( - `Export area ${(physicalWidth * physicalHeight).toLocaleString()} exceeds limit of ${MAX_AREA.toLocaleString()} pixels`, - ); - } - - // Ensure we have data to render - const pd = this.lastRenderedData; - if (!pd || pd.length === 0) { - throw new Error('No points available to render. Call render() first.'); - } - - // Create scales for export dimensions - const exportScales = this.createExportScales(pd, physicalWidth, physicalHeight, dataDomain); - if (!exportScales) { - throw new Error('Could not create scales for export rendering'); - } - - // Create off-screen WebGL canvas - const offscreenCanvas = document.createElement('canvas'); - offscreenCanvas.width = physicalWidth; - offscreenCanvas.height = physicalHeight; - - // Get WebGL2 context with same options as main canvas - const gl = offscreenCanvas.getContext('webgl2', { - antialias: true, - preserveDrawingBuffer: true, - premultipliedAlpha: false, - alpha: true, - powerPreference: 'high-performance', + return this.exportRenderer.renderToCanvas(this.lastRenderedData, this.getConfig(), this.style, { + width, + height, + dpr, + dataDomain, + pointSizeReference, + selectionActive: this.selectionActive, + transform: this.getTransform(), + gamma: this.gamma, }); - - if (!gl) { - throw new Error('Failed to create WebGL2 context for export'); - } - - try { - // Initialize WebGL state for the off-screen context - this.initializeOffscreenContext( - gl, - physicalWidth, - physicalHeight, - pd, - exportScales, - dpr, - pointSizeReference, - ); - - // Copy WebGL canvas to 2D canvas for safe export - const outputCanvas = document.createElement('canvas'); - outputCanvas.width = physicalWidth; - outputCanvas.height = physicalHeight; - - const ctx = outputCanvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to create 2D context for export'); - } - - ctx.drawImage(offscreenCanvas, 0, 0); - - return outputCanvas; - } finally { - // Clean up off-screen context - const loseContext = gl.getExtension('WEBGL_lose_context'); - if (loseContext) { - loseContext.loseContext(); - } - } - } - - /** - * Create scales appropriate for export dimensions. - * Scales the margin proportionally to maintain visual consistency. - */ - private createExportScales( - pd: PlotData, - exportWidth: number, - exportHeight: number, - dataDomain?: { xMin: number; xMax: number; yMin: number; yMax: number }, - ): ScalePair | null { - if (pd.length === 0) return null; - - const config = this.getConfig(); - - // Default margin if not specified - const margin = config.margin ?? { top: 20, right: 20, bottom: 20, left: 20 }; - - // Scale margins from a fixed reference instead of the live display size, - // so the export render is reproducible across browser-window resizes. - const scaleX = exportWidth / EXPORT_MARGIN_REFERENCE_WIDTH; - const scaleY = exportHeight / EXPORT_MARGIN_REFERENCE_HEIGHT; - - const scaledMargin = { - top: margin.top * scaleY, - right: margin.right * scaleX, - bottom: margin.bottom * scaleY, - left: margin.left * scaleX, - }; - - let xDomMin: number; - let xDomMax: number; - let yDomMin: number; - let yDomMax: number; - let useFullBleed = false; - if (dataDomain) { - // Caller supplied an exact viewport — used by inset (geometric zoom) - // rendering. Skip the 5% padding AND skip margins so the data domain - // fills the canvas edge-to-edge. The caller is responsible for picking - // a domain that already accounts for the source plot's margins, so the - // inset's data fills aligns 1:1 with the source rect's data region. - xDomMin = dataDomain.xMin; - xDomMax = dataDomain.xMax; - yDomMin = dataDomain.yMin; - yDomMax = dataDomain.yMax; - useFullBleed = true; - } else { - // Compute data extent + 5% padding (ScaleManager.createScales convention). - let xMin = Infinity, - xMax = -Infinity, - yMin = Infinity, - yMax = -Infinity; - const { xs, ys, length } = pd; - for (let i = 0; i < length; i++) { - if (xs[i] < xMin) xMin = xs[i]; - if (xs[i] > xMax) xMax = xs[i]; - if (ys[i] < yMin) yMin = ys[i]; - if (ys[i] > yMax) yMax = ys[i]; - } - const padding = 0.05; - const xPadding = Math.abs(xMax - xMin) * padding; - const yPadding = Math.abs(yMax - yMin) * padding; - xDomMin = xMin - xPadding; - xDomMax = xMax + xPadding; - yDomMin = yMin - yPadding; - yDomMax = yMax + yPadding; - } - - const xRangeStart = useFullBleed ? 0 : scaledMargin.left; - const xRangeEnd = useFullBleed ? exportWidth : exportWidth - scaledMargin.right; - const yRangeStart = useFullBleed ? exportHeight : exportHeight - scaledMargin.bottom; - const yRangeEnd = useFullBleed ? 0 : scaledMargin.top; - - const xScale = d3.scaleLinear().domain([xDomMin, xDomMax]).range([xRangeStart, xRangeEnd]); - const yScale = d3.scaleLinear().domain([yDomMin, yDomMax]).range([yRangeStart, yRangeEnd]); - - return { x: xScale, y: yScale }; } /** @@ -935,18 +507,7 @@ export class WebGLRenderer { exportWidth: number, exportHeight: number, ): { marginLeft: number; marginRight: number; marginTop: number; marginBottom: number } { - const config = this.getConfig(); - const margin = config.margin ?? { top: 20, right: 20, bottom: 20, left: 20 }; - // Match createExportScales: anchor to the same fixed reference so insets' - // data-domain inversion stays consistent with the export render. - const scaleX = exportWidth / EXPORT_MARGIN_REFERENCE_WIDTH; - const scaleY = exportHeight / EXPORT_MARGIN_REFERENCE_HEIGHT; - return { - marginLeft: margin.left * scaleX, - marginRight: margin.right * scaleX, - marginTop: margin.top * scaleY, - marginBottom: margin.bottom * scaleY, - }; + return ExportRenderer.getRenderInfo(this.getConfig(), exportWidth, exportHeight); } /** @@ -955,498 +516,7 @@ export class WebGLRenderer { * source rects (in normalized canvas coords) into data-coordinate viewports. */ public getDataExtent(): { xMin: number; xMax: number; yMin: number; yMax: number } | null { - const pd = this.lastRenderedData; - if (!pd || pd.length === 0) return null; - let xMin = Infinity, - xMax = -Infinity, - yMin = Infinity, - yMax = -Infinity; - const { xs, ys, length } = pd; - for (let i = 0; i < length; i++) { - if (xs[i] < xMin) xMin = xs[i]; - if (xs[i] > xMax) xMax = xs[i]; - if (ys[i] < yMin) yMin = ys[i]; - if (ys[i] > yMax) yMax = ys[i]; - } - return { xMin, xMax, yMin, yMax }; - } - - /** - * Initialize and render to an off-screen WebGL context - */ - private initializeOffscreenContext( - gl: WebGL2RenderingContext, - width: number, - height: number, - pd: PlotData, - scales: ScalePair, - dpr: number, - pointSizeReference?: { width: number; height: number }, - ): void { - // Calculate size scale factor based on export vs display dimensions. - // For inset (zoom) renders, callers pass `pointSizeReference` set to the - // source plot's render size so points stay visually the same size as in - // the main plot — instead of shrinking when the inset target is small. - const config = this.getConfig(); - const displayWidth = config.width ?? 800; - const displayHeight = config.height ?? 600; - const refW = pointSizeReference?.width ?? width; - const refH = pointSizeReference?.height ?? height; - const sizeScaleFactor = Math.sqrt((refW * refH) / (displayWidth * displayHeight)); - // Enable extensions for float textures (needed for gamma pipeline) - const colorBufferFloatExt = gl.getExtension('EXT_color_buffer_float'); - const floatBlendExt = gl.getExtension('EXT_float_blend'); - gl.getExtension('OES_texture_float_linear'); - - const useGammaPipeline = !!colorBufferFloatExt && !!floatBlendExt; - - // Create shader programs - const pointProgram = createProgramFromSources(gl, POINT_VERTEX_SHADER, POINT_FRAGMENT_SHADER); - if (!pointProgram) { - throw new Error('Failed to create point shader program for export'); - } - - let gammaCorrectionProgram: WebGLProgram | null = null; - if (useGammaPipeline) { - gammaCorrectionProgram = createProgramFromSources( - gl, - GAMMA_VERTEX_SHADER, - GAMMA_FRAGMENT_SHADER, - ); - } - - // Get attribute and uniform locations - const attribs = { - dataPosition: gl.getAttribLocation(pointProgram, 'a_dataPosition'), - size: gl.getAttribLocation(pointProgram, 'a_pointSize'), - color: gl.getAttribLocation(pointProgram, 'a_color'), - depth: gl.getAttribLocation(pointProgram, 'a_depth'), - labelCount: gl.getAttribLocation(pointProgram, 'a_labelCount'), - shape: gl.getAttribLocation(pointProgram, 'a_shape'), - }; - - const uniforms = { - resolution: gl.getUniformLocation(pointProgram, 'u_resolution'), - transform: gl.getUniformLocation(pointProgram, 'u_transform'), - dpr: gl.getUniformLocation(pointProgram, 'u_dpr'), - gamma: gl.getUniformLocation(pointProgram, 'u_gamma'), - labelColors: gl.getUniformLocation(pointProgram, 'u_labelColors'), - labelTextureSize: gl.getUniformLocation(pointProgram, 'u_labelTextureSize'), - maxLabels: gl.getUniformLocation(pointProgram, 'u_maxLabels'), - }; - - // Prepare point data using existing CPU arrays (reuse from main renderer) - const maxPoints = Math.min(pd.length, MAX_POINTS_DIRECT_RENDER); - - // Populate buffers for off-screen rendering - const { - dataPositions, - sizes, - colors, - depths, - labelCounts, - shapes, - labelColorData, - pointCount, - } = this.prepareOffscreenBufferData(pd, scales, maxPoints, dpr, sizeScaleFactor); - - // Create and upload buffers - const dataPositionBuffer = gl.createBuffer(); - const sizeBuffer = gl.createBuffer(); - const colorBuffer = gl.createBuffer(); - const depthBuffer = gl.createBuffer(); - const labelCountBuffer = gl.createBuffer(); - const shapeBuffer = gl.createBuffer(); - const labelColorTexture = gl.createTexture(); - - gl.bindBuffer(gl.ARRAY_BUFFER, dataPositionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, dataPositions.subarray(0, pointCount * 2), gl.STATIC_DRAW); - - gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuffer); - gl.bufferData(gl.ARRAY_BUFFER, sizes.subarray(0, pointCount), gl.STATIC_DRAW); - - gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); - gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, pointCount * 4), gl.STATIC_DRAW); - - gl.bindBuffer(gl.ARRAY_BUFFER, depthBuffer); - gl.bufferData(gl.ARRAY_BUFFER, depths.subarray(0, pointCount), gl.STATIC_DRAW); - - gl.bindBuffer(gl.ARRAY_BUFFER, labelCountBuffer); - gl.bufferData(gl.ARRAY_BUFFER, labelCounts.subarray(0, pointCount), gl.STATIC_DRAW); - - gl.bindBuffer(gl.ARRAY_BUFFER, shapeBuffer); - gl.bufferData(gl.ARRAY_BUFFER, shapes.subarray(0, pointCount), gl.STATIC_DRAW); - - // Setup label color texture - gl.bindTexture(gl.TEXTURE_2D, labelColorTexture); - const texHeight = labelColorData.length / 4 / LABEL_TEXTURE_WIDTH; - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA8, - LABEL_TEXTURE_WIDTH, - texHeight, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - labelColorData, - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - - // Create VAO - const pointVao = gl.createVertexArray(); - gl.bindVertexArray(pointVao); - - gl.bindBuffer(gl.ARRAY_BUFFER, dataPositionBuffer); - gl.enableVertexAttribArray(attribs.dataPosition); - gl.vertexAttribPointer(attribs.dataPosition, 2, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuffer); - gl.enableVertexAttribArray(attribs.size); - gl.vertexAttribPointer(attribs.size, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); - gl.enableVertexAttribArray(attribs.color); - gl.vertexAttribPointer(attribs.color, 4, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, depthBuffer); - gl.enableVertexAttribArray(attribs.depth); - gl.vertexAttribPointer(attribs.depth, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, labelCountBuffer); - gl.enableVertexAttribArray(attribs.labelCount); - gl.vertexAttribPointer(attribs.labelCount, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, shapeBuffer); - gl.enableVertexAttribArray(attribs.shape); - gl.vertexAttribPointer(attribs.shape, 1, gl.FLOAT, false, 0, 0); - - gl.bindVertexArray(null); - - // Get current transform and scale it for export dimensions - const displayTransform = this.getTransform(); - // Scale transform's translation to export dimensions - const scaleFactorX = width / displayWidth; - const scaleFactorY = height / displayHeight; - // Create a scaled transform that preserves the current view at export resolution - const exportTransform = { - x: displayTransform.x * scaleFactorX, - y: displayTransform.y * scaleFactorY, - k: displayTransform.k, // Zoom level stays the same - } as d3.ZoomTransform; - const gamma = useGammaPipeline ? this.gamma : 1.0; - - // Setup linear framebuffer if using gamma pipeline - let linearFramebuffer: FramebufferResources | null = null; - if (useGammaPipeline && gammaCorrectionProgram) { - linearFramebuffer = this.createOffscreenLinearFramebuffer(gl, width, height); - } - - // Render - if (linearFramebuffer && gammaCorrectionProgram) { - // Gamma-correct pipeline - gl.bindFramebuffer(gl.FRAMEBUFFER, linearFramebuffer.framebuffer); - gl.viewport(0, 0, width, height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.disable(gl.DEPTH_TEST); - gl.depthMask(false); - - this.renderOffscreenPoints( - gl, - pointProgram, - pointVao, - uniforms, - width, - height, - dpr, - gamma, - exportTransform, - labelColorTexture, - labelColorData.length, - pointCount, - ); - - // Apply gamma correction - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, width, height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - gl.disable(gl.BLEND); - - this.renderOffscreenGammaCorrection(gl, gammaCorrectionProgram, linearFramebuffer, gamma); - } else { - // Direct rendering - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, width, height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.disable(gl.DEPTH_TEST); - gl.depthMask(false); - - this.renderOffscreenPoints( - gl, - pointProgram, - pointVao, - uniforms, - width, - height, - dpr, - gamma, - exportTransform, - labelColorTexture, - labelColorData.length, - pointCount, - ); - } - - // Cleanup - gl.deleteVertexArray(pointVao); - gl.deleteBuffer(dataPositionBuffer); - gl.deleteBuffer(sizeBuffer); - gl.deleteBuffer(colorBuffer); - gl.deleteBuffer(depthBuffer); - gl.deleteBuffer(labelCountBuffer); - gl.deleteBuffer(shapeBuffer); - gl.deleteTexture(labelColorTexture); - gl.deleteProgram(pointProgram); - if (gammaCorrectionProgram) gl.deleteProgram(gammaCorrectionProgram); - if (linearFramebuffer) { - gl.deleteFramebuffer(linearFramebuffer.framebuffer); - gl.deleteTexture(linearFramebuffer.texture); - gl.deleteRenderbuffer(linearFramebuffer.depthBuffer); - } - } - - /** - * Prepare buffer data for off-screen rendering - */ - private prepareOffscreenBufferData( - pd: PlotData, - scales: ScalePair, - maxPoints: number, - dpr: number, - sizeScaleFactor: number = 1, - ): { - dataPositions: Float32Array; - sizes: Float32Array; - colors: Float32Array; - depths: Float32Array; - labelCounts: Float32Array; - shapes: Float32Array; - labelColorData: Uint8Array; - pointCount: number; - } { - const capacity = Math.max(MIN_CAPACITY, maxPoints); - const dataPositions = new Float32Array(capacity * 2); - const sizes = new Float32Array(capacity); - const colors = new Float32Array(capacity * 4); - const depths = new Float32Array(capacity); - const labelCounts = new Float32Array(capacity); - const shapes = new Float32Array(capacity); - const requiredPixels = capacity * MAX_LABELS; - const texHeight = Math.ceil(requiredPixels / LABEL_TEXTURE_WIDTH); - const labelColorData = new Uint8Array(LABEL_TEXTURE_WIDTH * texHeight * 4); - - // Stage slots by depth (painter's algorithm) — use a temp scratch point per slot. - const { xs, ys } = pd; - const oi = pd.originalIndices; - const sp: PlotDataPoint = { id: '', x: 0, y: 0, originalIndex: 0 }; - const staged: Array<{ slot: number; opacity: number; depth: number }> = []; - for (let i = 0; i < pd.length && staged.length < maxPoints; i++) { - const origIdx = oi ? oi[i] : i; - sp.id = pd.proteinIds[origIdx]; - sp.x = xs[i]; - sp.y = ys[i]; - sp.originalIndex = origIdx; - const opacity = this.style.getOpacity(sp); - if (opacity === 0) continue; - const depth = this.style.getDepth(sp); - staged.push({ slot: i, opacity, depth }); - } - staged.sort((a, b) => b.depth - a.depth); - - let idx = 0; - for (let s = 0; s < staged.length; s++) { - const { slot, opacity, depth } = staged[s]; - const origIdx = oi ? oi[slot] : slot; - sp.id = pd.proteinIds[origIdx]; - sp.x = xs[slot]; - sp.y = ys[slot]; - sp.originalIndex = origIdx; - - dataPositions[idx * 2] = scales.x(xs[slot]); - dataPositions[idx * 2 + 1] = scales.y(ys[slot]); - - const pointColors = this.style.getColors(sp); - const [r, g, b] = resolveColor(pointColors[0] ?? '#888888'); - const size = Math.sqrt(this.style.getPointSize(sp)) / POINT_SIZE_DIVISOR; - const shapeType = this.style.getShape(sp); - const shapeIndex = getShapeIndex(shapeType); - - colors[idx * 4] = r; - colors[idx * 4 + 1] = g; - colors[idx * 4 + 2] = b; - colors[idx * 4 + 3] = Math.min(1, Math.max(0, opacity)); - // Scale point sizes proportionally to export dimensions - const basePointSize = Math.max(MIN_POINT_SIZE, size * 2 * dpr * sizeScaleFactor); - sizes[idx] = shapeIndex === 2 ? basePointSize * DIAMOND_SIZE_SCALE : basePointSize; - depths[idx] = depth; - labelCounts[idx] = pointColors.length; - shapes[idx] = shapeIndex; - - // Fill label color texture data (skips single-label points; see fillLabelColorTexels) - fillLabelColorTexels(labelColorData, idx, pointColors, MAX_LABELS); - - idx++; - } - - return { - dataPositions, - sizes, - colors, - depths, - labelCounts, - shapes, - labelColorData, - pointCount: idx, - }; - } - - /** - * Create linear framebuffer for off-screen gamma-correct rendering - */ - private createOffscreenLinearFramebuffer( - gl: WebGL2RenderingContext, - width: number, - height: number, - ): FramebufferResources | null { - const framebuffer = gl.createFramebuffer(); - const texture = gl.createTexture(); - const depthBuffer = gl.createRenderbuffer(); - - if (!framebuffer || !texture || !depthBuffer) { - return null; - } - - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - - gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); - gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); - - gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); - gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer); - - const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); - if (status !== gl.FRAMEBUFFER_COMPLETE) { - gl.deleteFramebuffer(framebuffer); - gl.deleteTexture(texture); - gl.deleteRenderbuffer(depthBuffer); - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - return null; - } - - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.bindTexture(gl.TEXTURE_2D, null); - gl.bindRenderbuffer(gl.RENDERBUFFER, null); - - return { framebuffer, texture, depthBuffer, width, height }; - } - - /** - * Render points in off-screen context - */ - private renderOffscreenPoints( - gl: WebGL2RenderingContext, - program: WebGLProgram, - vao: WebGLVertexArrayObject, - uniforms: { - resolution: WebGLUniformLocation | null; - transform: WebGLUniformLocation | null; - dpr: WebGLUniformLocation | null; - gamma: WebGLUniformLocation | null; - labelColors: WebGLUniformLocation | null; - labelTextureSize: WebGLUniformLocation | null; - maxLabels: WebGLUniformLocation | null; - }, - width: number, - height: number, - dpr: number, - gamma: number, - transform: d3.ZoomTransform, - labelColorTexture: WebGLTexture | null, - labelColorDataLength: number, - pointCount: number, - ): void { - gl.useProgram(program); - - gl.uniform2f(uniforms.resolution, width, height); - gl.uniform3f(uniforms.transform, transform.x, transform.y, transform.k); - gl.uniform1f(uniforms.dpr, dpr); - gl.uniform1f(uniforms.gamma, gamma); - gl.uniform1i(uniforms.maxLabels, MAX_LABELS); - gl.uniform2f( - uniforms.labelTextureSize, - LABEL_TEXTURE_WIDTH, - labelColorDataLength / 4 / LABEL_TEXTURE_WIDTH, - ); - - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, labelColorTexture); - gl.uniform1i(uniforms.labelColors, 1); - - gl.bindVertexArray(vao); - gl.drawArrays(gl.POINTS, 0, pointCount); - gl.bindVertexArray(null); - } - - /** - * Apply gamma correction in off-screen context - */ - private renderOffscreenGammaCorrection( - gl: WebGL2RenderingContext, - program: WebGLProgram, - framebuffer: FramebufferResources, - gamma: number, - ): void { - gl.useProgram(program); - - const linearTextureLocation = gl.getUniformLocation(program, 'u_linearTexture'); - const gammaLocation = gl.getUniformLocation(program, 'u_gamma'); - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, framebuffer.texture); - gl.uniform1i(linearTextureLocation, 0); - gl.uniform1f(gammaLocation, gamma); - - // Create and draw full-screen quad - const quadBuffer = gl.createBuffer(); - const quadVertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); - - gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); - gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW); - - const posLoc = gl.getAttribLocation(program, 'a_position'); - gl.enableVertexAttribArray(posLoc); - gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); - - gl.drawArrays(gl.TRIANGLES, 0, 6); - - gl.disableVertexAttribArray(posLoc); - gl.deleteBuffer(quadBuffer); + return this.exportRenderer.getDataExtent(this.lastRenderedData); } // ============================================================================ @@ -1454,7 +524,7 @@ export class WebGLRenderer { // ============================================================================ private ensureGL(): WebGL2RenderingContext | null { - if (this.contextLost) return null; + if (this.lossController.isLost) return null; if (this.gl) { if (this.gl.isContextLost && this.gl.isContextLost()) { this.markContextLost(); @@ -1464,7 +534,12 @@ export class WebGLRenderer { this.resetRendererState(); } } - if (this.gl && this.pointProgram && this.pointAttribLocations && this.pointUniformLocations) { + if ( + this.gl && + this.resources.pointProgram && + this.pointAttribLocations && + this.pointUniformLocations + ) { return this.gl; } @@ -1502,27 +577,16 @@ export class WebGLRenderer { } } - this.dataPositionBuffer = gl.createBuffer(); - this.sizeBuffer = gl.createBuffer(); - this.colorBuffer = gl.createBuffer(); - this.depthBuffer = gl.createBuffer(); - this.labelCountBuffer = gl.createBuffer(); - this.shapeBuffer = gl.createBuffer(); - this.quadBuffer = gl.createBuffer(); - this.labelColorTexture = gl.createTexture(); + this.resources.createAll(gl); this.labelTextureInitialized = false; this.createPointVAO(); this.setupQuad(); - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - // We want overlapping points to remain visible, so we do NOT use the depth buffer to cull. // Z-order is preserved via painter's algorithm (CPU sorting) in populateBuffers(). - gl.disable(gl.DEPTH_TEST); - gl.depthMask(false); + setPointBlendState(gl); if ( this.gammaPipelineAvailable && @@ -1535,20 +599,11 @@ export class WebGLRenderer { } private isRendererStateValid(gl: WebGL2RenderingContext): boolean { - if (!this.pointProgram || !gl.isProgram(this.pointProgram)) return false; - if (this.pointVao && !gl.isVertexArray(this.pointVao)) return false; - if (this.dataPositionBuffer && !gl.isBuffer(this.dataPositionBuffer)) return false; - if (this.sizeBuffer && !gl.isBuffer(this.sizeBuffer)) return false; - if (this.colorBuffer && !gl.isBuffer(this.colorBuffer)) return false; - if (this.depthBuffer && !gl.isBuffer(this.depthBuffer)) return false; - if (this.labelCountBuffer && !gl.isBuffer(this.labelCountBuffer)) return false; - if (this.shapeBuffer && !gl.isBuffer(this.shapeBuffer)) return false; - if (this.labelColorTexture && !gl.isTexture(this.labelColorTexture)) return false; - return true; + return this.resources.validate(gl); } private isContextLost(): boolean { - if (this.contextLost) return true; + if (this.lossController.isLost) return true; const gl = this.gl; if (gl?.isContextLost && gl.isContextLost()) { this.markContextLost(); @@ -1558,28 +613,17 @@ export class WebGLRenderer { } private markContextLost() { - if (this.contextLost) return; - this.contextLost = true; - this.resetRendererState(); + // Idempotent: the controller fires the onLost callback (resetRendererState + + // onContextLost) exactly once. + this.lossController.markLost(); } private resetRendererState() { this.gl = null; - this.pointProgram = null; - this.pointVao = null; + this.resources.reset(); this.pointAttribLocations = null; this.pointUniformLocations = null; - this.quadBuffer = null; - this.gammaCorrectionProgram = null; this.gammaCorrectionUniformLocations = null; - this.linearFramebuffer = null; - this.dataPositionBuffer = null; - this.sizeBuffer = null; - this.colorBuffer = null; - this.depthBuffer = null; - this.labelCountBuffer = null; - this.shapeBuffer = null; - this.labelColorTexture = null; this.labelTextureInitialized = false; this.gammaPipelineAvailable = true; this.warnedGammaFallback = false; @@ -1594,42 +638,34 @@ export class WebGLRenderer { } private initializePointShaders(gl: WebGL2RenderingContext): boolean { - this.pointProgram = createProgramFromSources(gl, POINT_VERTEX_SHADER, POINT_FRAGMENT_SHADER); - if (!this.pointProgram) return false; - - this.pointAttribLocations = { - dataPosition: gl.getAttribLocation(this.pointProgram, 'a_dataPosition'), - size: gl.getAttribLocation(this.pointProgram, 'a_pointSize'), - color: gl.getAttribLocation(this.pointProgram, 'a_color'), - depth: gl.getAttribLocation(this.pointProgram, 'a_depth'), - labelCount: gl.getAttribLocation(this.pointProgram, 'a_labelCount'), - shape: gl.getAttribLocation(this.pointProgram, 'a_shape'), - }; + this.resources.pointProgram = createProgramFromSources( + gl, + POINT_VERTEX_SHADER, + POINT_FRAGMENT_SHADER, + ); + if (!this.resources.pointProgram) return false; - this.pointUniformLocations = { - resolution: gl.getUniformLocation(this.pointProgram, 'u_resolution'), - transform: gl.getUniformLocation(this.pointProgram, 'u_transform'), - dpr: gl.getUniformLocation(this.pointProgram, 'u_dpr'), - gamma: gl.getUniformLocation(this.pointProgram, 'u_gamma'), - labelColors: gl.getUniformLocation(this.pointProgram, 'u_labelColors'), - labelTextureSize: gl.getUniformLocation(this.pointProgram, 'u_labelTextureSize'), - maxLabels: gl.getUniformLocation(this.pointProgram, 'u_maxLabels'), - }; + const { attribs, uniforms } = resolvePointLocations(gl, this.resources.pointProgram); + this.pointAttribLocations = attribs; + this.pointUniformLocations = uniforms; return true; } private initializeGammaCorrectionShaders(gl: WebGL2RenderingContext): boolean { - this.gammaCorrectionProgram = createProgramFromSources( + this.resources.gammaCorrectionProgram = createProgramFromSources( gl, GAMMA_VERTEX_SHADER, GAMMA_FRAGMENT_SHADER, ); - if (!this.gammaCorrectionProgram) return false; + if (!this.resources.gammaCorrectionProgram) return false; this.gammaCorrectionUniformLocations = { - linearTexture: gl.getUniformLocation(this.gammaCorrectionProgram, 'u_linearTexture'), - gamma: gl.getUniformLocation(this.gammaCorrectionProgram, 'u_gamma'), + linearTexture: gl.getUniformLocation( + this.resources.gammaCorrectionProgram, + 'u_linearTexture', + ), + gamma: gl.getUniformLocation(this.resources.gammaCorrectionProgram, 'u_gamma'), }; return true; @@ -1643,44 +679,31 @@ export class WebGLRenderer { const gl = this.gl; if (!gl || !this.pointAttribLocations) return; - this.pointVao = gl.createVertexArray(); - gl.bindVertexArray(this.pointVao); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.dataPositionBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.dataPosition); - gl.vertexAttribPointer(this.pointAttribLocations.dataPosition, 2, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.sizeBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.size); - gl.vertexAttribPointer(this.pointAttribLocations.size, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.color); - gl.vertexAttribPointer(this.pointAttribLocations.color, 4, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.depthBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.depth); - gl.vertexAttribPointer(this.pointAttribLocations.depth, 1, gl.FLOAT, false, 0, 0); + this.resources.pointVao = gl.createVertexArray(); + gl.bindVertexArray(this.resources.pointVao); - gl.bindBuffer(gl.ARRAY_BUFFER, this.labelCountBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.labelCount); - gl.vertexAttribPointer(this.pointAttribLocations.labelCount, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.shapeBuffer); - gl.enableVertexAttribArray(this.pointAttribLocations.shape); - gl.vertexAttribPointer(this.pointAttribLocations.shape, 1, gl.FLOAT, false, 0, 0); + setupAttributes( + gl, + { + dataPosition: this.resources.dataPositionBuffer, + size: this.resources.sizeBuffer, + color: this.resources.colorBuffer, + depth: this.resources.depthBuffer, + labelCount: this.resources.labelCountBuffer, + shape: this.resources.shapeBuffer, + }, + this.pointAttribLocations, + ); gl.bindVertexArray(null); } private setupQuad() { const gl = this.gl; - if (!gl || !this.quadBuffer) return; + if (!gl || !this.resources.quadBuffer) return; - const quadVertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer); - gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, this.resources.quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW); } // ============================================================================ @@ -1691,56 +714,33 @@ export class WebGLRenderer { if ( !this.gl || this.currentPointCount === 0 || - !this.pointProgram || + !this.resources.pointProgram || !this.pointUniformLocations ) { return; } const gl = this.gl; - gl.useProgram(this.pointProgram); - - const gamma = this.getEffectiveGamma(); - gl.uniform2f(this.pointUniformLocations.resolution, this.canvas.width, this.canvas.height); - gl.uniform3f(this.pointUniformLocations.transform, transform.x, transform.y, transform.k); - gl.uniform1f(this.pointUniformLocations.dpr, this.dpr); - gl.uniform1f(this.pointUniformLocations.gamma, gamma); - gl.uniform1i(this.pointUniformLocations.maxLabels, MAX_LABELS); - gl.uniform2f( - this.pointUniformLocations.labelTextureSize, - LABEL_TEXTURE_WIDTH, - this.labelColorData.length / 4 / LABEL_TEXTURE_WIDTH, - ); - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, this.labelColorTexture); - gl.uniform1i(this.pointUniformLocations.labelColors, 1); - - gl.bindVertexArray(this.pointVao); - - if (this.selectionActive && this.selectedStartIndex < this.currentPointCount) { - // Two-pass rendering: - // Pass 1 — Unselected points (blend OFF): flat fading, no density accumulation. - // The subtle MSAA edge artifact at low alpha is imperceptible. - gl.disable(gl.BLEND); - if (this.selectedStartIndex > 0) { - gl.drawArrays(gl.POINTS, 0, this.selectedStartIndex); - } + bindPointDrawState( + gl, + this.resources.pointProgram, + this.pointUniformLocations, + this.resources.pointVao, + this.resources.labelColorTexture, + { + width: this.canvas.width, + height: this.canvas.height, + transform: { x: transform.x, y: transform.y, k: transform.k }, + dpr: this.dpr, + gamma: this.getEffectiveGamma(), + maxLabels: MAX_LABELS, + labelTextureWidth: LABEL_TEXTURE_WIDTH, + labelColorDataLength: this.labelColorData.length, + }, + ); - // Pass 2 — Selected points (blend ON): correct MSAA anti-aliasing on opaque points. - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.drawArrays( - gl.POINTS, - this.selectedStartIndex, - this.currentPointCount - this.selectedStartIndex, - ); - } else { - // No selection — single pass with blend (density visible, original behavior) - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.drawArrays(gl.POINTS, 0, this.currentPointCount); - } + drawPoints(gl, this.currentPointCount, this.selectionActive, this.selectedStartIndex); gl.bindVertexArray(null); } @@ -1871,61 +871,50 @@ export class WebGLRenderer { sp.originalIndex = origIdx; depthScratch[i] = this.style.getDepth(sp); } - sortIndicesByDepthDescending(order, depthScratch, count); - - // Find where selected points start (opacity ≈ 1.0, contiguous at the end after sort). - // Used for two-pass rendering: unselected without blend, selected with blend. - let firstSelected = -1; - - for (let k = 0; k < count; k++) { - const srcSlot = order[k]; - const origIdx = oi ? oi[srcSlot] : srcSlot; - sp.id = pd.proteinIds[origIdx]; - sp.x = xs[srcSlot]; - sp.y = ys[srcSlot]; - sp.originalIndex = origIdx; - const opacity = this.style.getOpacity(sp); - - if (this.selectionActive && firstSelected === -1 && opacity >= 0.99) { - firstSelected = k; - } + // Canonical painter-order plan (shared with the export path via + // buildPaintOrder): sort far->near, then locate the two-pass selection cut + // from the first sorted slot with opacity >= 0.99. The per-slot callback + // also performs the live side effects (ID tracking + staging) so every slot + // — including opacity-0 — is staged exactly as before. + const { selectedStartIndex } = buildPaintOrder( + order, + depthScratch, + count, + this.selectionActive, + (k, srcSlot) => { + const origIdx = oi ? oi[srcSlot] : srcSlot; + sp.id = pd.proteinIds[origIdx]; + sp.x = xs[srcSlot]; + sp.y = ys[srcSlot]; + sp.originalIndex = origIdx; + const opacity = this.style.getOpacity(sp); - if (this.trackRenderedPointIds && opacity > 0) { - this.renderedPointIds.add(sp.id); - } + if (this.trackRenderedPointIds && opacity > 0) { + this.renderedPointIds.add(sp.id); + } - // updatePositions is always true here (see above) - this.dataPositions[idx * 2] = scales.x(xs[srcSlot]); - this.dataPositions[idx * 2 + 1] = scales.y(ys[srcSlot]); - - const pointColors = this.style.getColors(sp); - const [r, g, b] = resolveColor(pointColors[0] ?? '#888888'); - const size = Math.sqrt(this.style.getPointSize(sp)) / POINT_SIZE_DIVISOR; - const shapeType = this.style.getShape(sp); - const shapeIndex = getShapeIndex(shapeType); - - this.colors[idx * 4] = r; - this.colors[idx * 4 + 1] = g; - this.colors[idx * 4 + 2] = b; - this.colors[idx * 4 + 3] = Math.min(1, Math.max(0, opacity)); - const basePointSize = Math.max(MIN_POINT_SIZE, size * 2 * this.dpr); - this.sizes[idx] = shapeIndex === 2 ? basePointSize * DIAMOND_SIZE_SCALE : basePointSize; - // Use depthScratch[srcSlot] (indexed by original slot), NOT depthScratch[k]. - this.depths[idx] = depthScratch[srcSlot]; - this.labelCounts[idx] = pointColors.length; - this.shapes[idx] = shapeIndex; - - // Fill label color texture data (skips single-label points; see fillLabelColorTexels) - fillLabelColorTexels(this.labelColorData, idx, pointColors, MAX_LABELS); - - idx++; - } + // updatePositions is always true here (see above). Positions are + // pre-scaled by the caller; depth uses depthScratch[srcSlot] (indexed by + // original slot), NOT depthScratch[k]. sizeScaleFactor=1 for the live path. + stagePoint( + this.stageArrays, + k, + sp, + scales.x(xs[srcSlot]), + scales.y(ys[srcSlot]), + opacity, + depthScratch[srcSlot], + this.style, + this.dpr, + 1, + ); + + return opacity; + }, + ); - this.selectedStartIndex = this.selectionActive - ? firstSelected === -1 - ? count - : firstSelected - : count; + idx = count; + this.selectedStartIndex = selectedStartIndex; // Cache the PlotData reference so color-only / positions-only paths can index via sortOrder. this.sortedDataRef = pd; } else if (updateStyles) { @@ -1950,24 +939,11 @@ export class WebGLRenderer { this.renderedPointIds.add(sp.id); } - // Update only style buffers (colors, shapes, sizes) - const pointColors = this.style.getColors(sp); - const [r, g, b] = resolveColor(pointColors[0] ?? '#888888'); - const size = Math.sqrt(this.style.getPointSize(sp)) / POINT_SIZE_DIVISOR; - const shapeType = this.style.getShape(sp); - const shapeIndex = getShapeIndex(shapeType); - - this.colors[idx * 4] = r; - this.colors[idx * 4 + 1] = g; - this.colors[idx * 4 + 2] = b; - this.colors[idx * 4 + 3] = Math.min(1, Math.max(0, opacity)); - const basePointSize = Math.max(MIN_POINT_SIZE, size * 2 * this.dpr); - this.sizes[idx] = shapeIndex === 2 ? basePointSize * DIAMOND_SIZE_SCALE : basePointSize; - this.labelCounts[idx] = pointColors.length; - this.shapes[idx] = shapeIndex; - - // Fill label color texture data (skips single-label points; see fillLabelColorTexels) - fillLabelColorTexels(this.labelColorData, idx, pointColors, MAX_LABELS); + // Update only style channels (color/alpha/size/shape/label texels) — + // positions and depths are unchanged from the last rebuild. Shares the + // exact packing the full-rebuild path uses via stagePoint (stageArrays + // aliases this.colors/this.sizes/... so this writes the same buffers). + stagePointStyle(this.stageArrays, idx, sp, opacity, this.style, this.dpr); idx++; } @@ -2008,22 +984,22 @@ export class WebGLRenderer { this.currentPointCount = idx; - gl.bindVertexArray(this.pointVao); + gl.bindVertexArray(this.resources.pointVao); if (updatePositions) { - this.updateBuffer(gl, this.dataPositionBuffer, this.dataPositions, idx * 2); + this.updateBuffer(gl, this.resources.dataPositionBuffer, this.dataPositions, idx * 2); } if (updateStyles) { - this.updateBuffer(gl, this.sizeBuffer, this.sizes, idx); - this.updateBuffer(gl, this.colorBuffer, this.colors, idx * 4); - this.updateBuffer(gl, this.depthBuffer, this.depths, idx); - this.updateBuffer(gl, this.labelCountBuffer, this.labelCounts, idx); - this.updateBuffer(gl, this.shapeBuffer, this.shapes, idx); + this.updateBuffer(gl, this.resources.sizeBuffer, this.sizes, idx); + this.updateBuffer(gl, this.resources.colorBuffer, this.colors, idx * 4); + this.updateBuffer(gl, this.resources.depthBuffer, this.depths, idx); + this.updateBuffer(gl, this.resources.labelCountBuffer, this.labelCounts, idx); + this.updateBuffer(gl, this.resources.shapeBuffer, this.shapes, idx); // Update label-color texture. Allocate storage once (and whenever capacity grew); // afterwards update in place with texSubImage2D — no 32 MiB reallocation per recolor. - gl.bindTexture(gl.TEXTURE_2D, this.labelColorTexture); + gl.bindTexture(gl.TEXTURE_2D, this.resources.labelColorTexture); const texHeight = this.labelColorData.length / 4 / LABEL_TEXTURE_WIDTH; if (!this.labelTextureInitialized) { gl.texImage2D( @@ -2075,6 +1051,23 @@ export class WebGLRenderer { } } + /** + * Build a fresh {@link StagePointArrays} view bound to the current parallel + * staging arrays. Call after any reallocation so `stagePoint` writes into the + * live buffers (zero copy — the struct only holds references). + */ + private buildStageArrays(): StagePointArrays { + return { + dataPositions: this.dataPositions, + sizes: this.sizes, + colors: this.colors, + depths: this.depths, + labelCounts: this.labelCounts, + shapes: this.shapes, + labelColorData: this.labelColorData, + }; + } + private expandCapacity(minCapacity: number) { const nextCapacity = planRendererCapacity( minCapacity, @@ -2100,6 +1093,9 @@ export class WebGLRenderer { this.labelColorData = new Uint8Array(LABEL_TEXTURE_WIDTH * texHeight * 4); this.labelTextureInitialized = false; + // Re-point the staging view at the freshly reallocated arrays (zero copy). + this.stageArrays = this.buildStageArrays(); + this.buffersInitialized = false; } } diff --git a/packages/core/src/components/scatter-plot/webgl/types.ts b/packages/core/src/components/scatter-plot/webgl/types.ts index 241f1dd1..1694d827 100644 --- a/packages/core/src/components/scatter-plot/webgl/types.ts +++ b/packages/core/src/components/scatter-plot/webgl/types.ts @@ -1,6 +1,9 @@ -import type * as d3 from 'd3'; import type { PlotDataPoint } from '@protspace/utils'; +// ScalePair is owned by @protspace/utils (data-processor `createScales`); re-export +// it here so webgl code importing `ScalePair` from this module still resolves. +export type { ScalePair } from '@protspace/utils'; + // ============================================================================ // Types & Interfaces // ============================================================================ @@ -10,16 +13,9 @@ export interface WebGLStyleGetters { getPointSize: (point: PlotDataPoint) => number; getOpacity: (point: PlotDataPoint) => number; getDepth: (point: PlotDataPoint) => number; - getStrokeColor: (point: PlotDataPoint) => string; - getStrokeWidth: (point: PlotDataPoint) => number; getShape: (point: PlotDataPoint) => string; } -export type ScalePair = { - x: d3.ScaleLinear; - y: d3.ScaleLinear; -}; - /** * Framebuffer resources for offscreen rendering */ @@ -31,6 +27,27 @@ export interface FramebufferResources { height: number; } +/** Attribute locations for the point shader program (six attributes). */ +export interface PointAttribLocations { + dataPosition: number; + size: number; + color: number; + depth: number; + labelCount: number; + shape: number; +} + +/** Uniform locations for the point shader program (seven uniforms). */ +export interface PointUniformLocations { + resolution: WebGLUniformLocation | null; + transform: WebGLUniformLocation | null; + dpr: WebGLUniformLocation | null; + gamma: WebGLUniformLocation | null; + labelColors: WebGLUniformLocation | null; + labelTextureSize: WebGLUniformLocation | null; + maxLabels: WebGLUniformLocation | null; +} + // ============================================================================ // Configuration Constants (tuned for performance) // ============================================================================ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 15017c33..28f19ad1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,7 @@ export * from './types'; export * from './visualization/shapes'; export * from './visualization/annotation-data-access'; +export * from './visualization/slice-visualization-data'; export * from './visualization/annotation-metadata'; export * from './visualization/plot-data-accessors'; export * from './visualization/data-processor'; diff --git a/packages/utils/src/visualization/data-processor.ts b/packages/utils/src/visualization/data-processor.ts index 13b50d3d..4219805d 100644 --- a/packages/utils/src/visualization/data-processor.ts +++ b/packages/utils/src/visualization/data-processor.ts @@ -1,5 +1,6 @@ import type { VisualizationData, PlotData } from '../types.js'; import { EMPTY_PLOT_DATA } from './plot-data.js'; +import { DATA_EXTENT_PADDING } from './scales.js'; import * as d3 from 'd3'; // Memoize x/y extents per PlotData object reference. Resizes pass the same PlotData @@ -7,6 +8,14 @@ import * as d3 from 'd3'; // a NEW PlotData via clonePlotData, correctly missing the cache. const extentCache = new WeakMap(); +// Shared d3 linear scale pair returned by createScales. Declared here (not in +// @protspace/core's webgl/types.ts) because utils cannot import core; core's +// webgl/types.ts re-exports this type. +export type ScalePair = { + x: d3.ScaleLinear; + y: d3.ScaleLinear; +}; + export class DataProcessor { static processVisualizationData( data: VisualizationData, @@ -115,7 +124,7 @@ export class DataProcessor { width: number, height: number, margin: { top: number; right: number; bottom: number; left: number }, - ) { + ): ScalePair | null { if (plotData.length === 0) return null; let extents = extentCache.get(plotData); @@ -142,8 +151,8 @@ export class DataProcessor { const xExtent = extents.x; const yExtent = extents.y; - const xPadding = Math.abs(xExtent[1] - xExtent[0]) * 0.05; - const yPadding = Math.abs(yExtent[1] - yExtent[0]) * 0.05; + const xPadding = Math.abs(xExtent[1] - xExtent[0]) * DATA_EXTENT_PADDING; + const yPadding = Math.abs(yExtent[1] - yExtent[0]) * DATA_EXTENT_PADDING; return { x: d3 diff --git a/packages/utils/src/visualization/scales.ts b/packages/utils/src/visualization/scales.ts index 7388e2f8..cb8c10b3 100644 --- a/packages/utils/src/visualization/scales.ts +++ b/packages/utils/src/visualization/scales.ts @@ -1,13 +1,21 @@ import * as d3 from 'd3'; import type { PlotDataPoint } from '../types.js'; +/** + * Fraction of the data range added as padding on every edge when building plot + * scales. Single source of truth shared by the live scales (here and + * DataProcessor) and the WebGL export domain, so on-screen and exported framing + * stay in lockstep. + */ +export const DATA_EXTENT_PADDING = 0.05; + export class ScaleManager { static createScales( data: PlotDataPoint[], width: number, height: number, margin: { top: number; right: number; bottom: number; left: number }, - padding: number = 0.05, + padding: number = DATA_EXTENT_PADDING, ) { if (data.length === 0) return null; diff --git a/packages/utils/src/visualization/slice-visualization-data.test.ts b/packages/utils/src/visualization/slice-visualization-data.test.ts new file mode 100644 index 00000000..98cc8841 --- /dev/null +++ b/packages/utils/src/visualization/slice-visualization-data.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { sliceVisualizationDataByIndices } from './slice-visualization-data'; +import type { Annotation, VisualizationData } from '../types'; + +function baseViz(): VisualizationData { + const famAnnotation: Annotation = { + kind: 'categorical', + values: ['a', 'b'], + colors: ['#000', '#fff'], + shapes: ['circle', 'square'], + }; + return { + protein_ids: ['p0', 'p1', 'p2', 'p3'], + projections: [ + { name: 'umap', dimension: 2, data: new Float32Array([0, 0, 1, 1, 2, 2, 3, 3]) }, + { name: 'pca3', dimension: 3, data: new Float32Array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3]) }, + ], + annotations: { fam: famAnnotation }, + annotation_data: { fam: new Int32Array([0, 1, 0, 1]) }, + numeric_annotation_data: { plddt: [10, 20, 30, 40] }, + annotation_scores: { fam: [[[0.1]], [[0.2]], [[0.3]], [[0.4]]] }, + annotation_evidence: { fam: [['x'], ['y'], ['z'], ['w']] }, + }; +} + +describe('sliceVisualizationDataByIndices', () => { + it('keeps protein_ids in keptIndices order', () => { + const out = sliceVisualizationDataByIndices(baseViz(), [1, 3]); + expect(out.protein_ids).toEqual(['p1', 'p3']); + }); + + it('copies 2D and 3D projections per kept index into fresh Float32Arrays', () => { + const src = baseViz(); + const out = sliceVisualizationDataByIndices(src, [1, 3]); + expect(out.projections[0].dimension).toBe(2); + expect(Array.from(out.projections[0].data)).toEqual([1, 1, 3, 3]); + expect(out.projections[1].dimension).toBe(3); + expect(Array.from(out.projections[1].data)).toEqual([1, 1, 1, 3, 3, 3]); + // fresh buffer, not aliasing the source + expect(out.projections[0].data).not.toBe(src.projections[0].data); + }); + + it('reslices annotation_data via sliceAnnotationData (Int32Array shape preserved)', () => { + const out = sliceVisualizationDataByIndices(baseViz(), [1, 3]); + expect(out.annotation_data.fam).toBeInstanceOf(Int32Array); + expect(Array.from(out.annotation_data.fam as Int32Array)).toEqual([1, 1]); + }); + + it('reslices numeric_annotation_data to kept indices', () => { + const out = sliceVisualizationDataByIndices(baseViz(), [1, 3]); + expect(out.numeric_annotation_data!.plddt).toEqual([20, 40]); + }); + + it('reslices annotation_scores AND annotation_evidence to kept indices (fixes drift)', () => { + const out = sliceVisualizationDataByIndices(baseViz(), [1, 3]); + expect(out.annotation_scores!.fam).toEqual([[[0.2]], [[0.4]]]); + expect(out.annotation_evidence!.fam).toEqual([['y'], ['w']]); + }); + + it('omits optional maps that are absent on the source', () => { + const src = baseViz(); + delete src.numeric_annotation_data; + delete src.annotation_scores; + delete src.annotation_evidence; + const out = sliceVisualizationDataByIndices(src, [0]); + expect(out.numeric_annotation_data).toBeUndefined(); + expect(out.annotation_scores).toBeUndefined(); + expect(out.annotation_evidence).toBeUndefined(); + }); + + it('preserves annotations object by reference (not per-index data)', () => { + const src = baseViz(); + const out = sliceVisualizationDataByIndices(src, [0]); + expect(out.annotations).toBe(src.annotations); + }); +}); diff --git a/packages/utils/src/visualization/slice-visualization-data.ts b/packages/utils/src/visualization/slice-visualization-data.ts new file mode 100644 index 00000000..e054462b --- /dev/null +++ b/packages/utils/src/visualization/slice-visualization-data.ts @@ -0,0 +1,56 @@ +import type { VisualizationData } from '../types.js'; +import { sliceAnnotationData } from './annotation-data-access.js'; + +/** + * Build a VisualizationData constrained to `keptIndices` (ascending positions into + * `data.protein_ids`). Projections are copied per-index into fresh Float32Arrays; + * annotation_data is resliced via sliceAnnotationData; numeric/scores/evidence are + * resliced consistently (optional maps absent on the source stay absent). The + * `annotations` metadata object is shared by reference (per-index data lives in + * annotation_data, not annotations). + * + * Shared by the scatter-plot filtered-display path and the isolation path so the + * two cannot drift (and so scores/evidence stay index-aligned with protein_ids). + */ +export function sliceVisualizationDataByIndices( + data: VisualizationData, + keptIndices: number[], +): VisualizationData { + const sliceRows = (rows: readonly T[]): T[] => { + const out = new Array(keptIndices.length); + for (let k = 0; k < keptIndices.length; k++) out[k] = rows[keptIndices[k]]; + return out; + }; + const sliceRecord = ( + src: Record | undefined, + ): Record | undefined => + src + ? Object.fromEntries(Object.entries(src).map(([name, rows]) => [name, sliceRows(rows)])) + : undefined; + + return { + ...data, + protein_ids: keptIndices.map((index) => data.protein_ids[index]), + projections: data.projections.map((projection) => { + const dim = projection.dimension; + const out = new Float32Array(keptIndices.length * dim); + for (let k = 0; k < keptIndices.length; k++) { + const base = keptIndices[k] * dim; + const o = k * dim; + out[o] = projection.data[base]; + out[o + 1] = projection.data[base + 1]; + if (dim === 3) out[o + 2] = projection.data[base + 2]; + } + return { ...projection, data: out, dimension: dim }; + }), + annotation_data: Object.fromEntries( + Object.entries(data.annotation_data).map(([name, rows]) => [ + name, + sliceAnnotationData(rows, keptIndices), + ]), + ), + numeric_annotation_data: sliceRecord(data.numeric_annotation_data), + annotation_scores: sliceRecord(data.annotation_scores), + annotation_evidence: sliceRecord(data.annotation_evidence), + }; +}