Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4e4692d
test(scatter-plot): B7 characterization safety net (F-02,03,09,22,23,…
peymanvahidi Jun 20, 2026
68b216c
refactor(scatter-plot): B1 WebGL context-loss lifecycle & recovery (F…
peymanvahidi Jun 20, 2026
bb7b01e
refactor(scatter-plot): B3 offscreen export pipeline consolidation (F…
peymanvahidi Jun 20, 2026
ab1f023
refactor(scatter-plot): B4 WebGLRenderer god-class decomposition (F-5…
peymanvahidi Jun 20, 2026
1456d6a
refactor(scatter-plot): B6 data-derivation & cache correctness (F-60,…
peymanvahidi Jun 20, 2026
b1695b3
refactor(scatter-plot): B5 duplicate-stack/spiderfy/badge subsystem e…
peymanvahidi Jun 20, 2026
fee4790
refactor(scatter-plot): B8 interaction layer extraction (F-48,F-28,F-07)
peymanvahidi Jun 21, 2026
3332479
refactor(scatter-plot): B9 extract pure tooltip-position helper (F-34)
peymanvahidi Jun 21, 2026
0b39cec
refactor(scatter-plot): B10 dedupe isolation render-refresh (F-33)
peymanvahidi Jun 21, 2026
fa61e66
refactor(scatter-plot): B2 renderer lifecycle guards (F-35,F-11,F-05,…
peymanvahidi Jun 21, 2026
0ec6d5d
refactor(scatter-plot): B11 Lit reactivity & event-contract hygiene (…
peymanvahidi Jun 21, 2026
2437cf6
refactor(scatter-plot): B12 dead-code & doc cleanup (F-44,F-45,F-55,F…
peymanvahidi Jun 21, 2026
833187a
fix(scatter-plot): restore pre-data selection guard, dedupe transform
peymanvahidi Jun 21, 2026
54c7fee
refactor(scatter-plot): share point draw-state, unify export staging
peymanvahidi Jun 21, 2026
c3a1cf0
refactor(scatter-plot): dedupe duplicate-stack overlay helpers
peymanvahidi Jun 21, 2026
7b658b7
refactor(scatter-plot): drop dead numeric-recompute running flag
peymanvahidi Jun 21, 2026
7d363ac
refactor(scatter-plot): group helpers into feature subdirectories
peymanvahidi Jun 21, 2026
387363d
refactor(scatter-plot): apply code-review cleanups and guards
tsenoner Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions app/tests/brush-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 },
);
Expand All @@ -116,14 +119,14 @@ async function enableSelectionMode(page: Page): Promise<boolean> {
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;
});
}

Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -153,11 +157,13 @@ export class ScatterplotSyncController implements ReactiveController {
/**
* Update scatterplot config
*/
updateConfig(updates: Record<string, unknown>): void {
updateConfig(updates: Partial<ScatterplotConfig>): 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<string, unknown>)[key] !== value,
);
if (!hasChanges) return;

this._scatterplotElement.config = { ...currentConfig, ...updates };
Expand All @@ -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,
});
Expand All @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/components/legend/legend-mapping-events.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions packages/core/src/components/legend/legend-mapping-events.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
shapeMapping: Record<string, string>;
/** 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<string, number>;
}

export type LegendColorMappingChangeEvent = CustomEvent<LegendColorMappingDetail>;
export type LegendZOrderChangeEvent = CustomEvent<LegendZOrderDetail>;

/** 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<string, unknown>;
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<string, unknown>).zOrderMapping === 'object' &&
(d as Record<string, unknown>).zOrderMapping !== null
);
}
4 changes: 2 additions & 2 deletions packages/core/src/components/legend/scatterplot-interface.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -21,7 +21,7 @@ export interface IScatterplotElement extends Element {
numericManualOrderIdsByAnnotation?: Record<string, string[]>;

// Configuration
config: Record<string, unknown>;
config: Partial<ScatterplotConfig>;

// Isolation mode (optional - may not exist on all implementations)
isIsolationMode?(): boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, {
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);
});
});
Loading
Loading