diff --git a/.changeset/label-declutter.md b/.changeset/label-declutter.md new file mode 100644 index 0000000..e01441b --- /dev/null +++ b/.changeset/label-declutter.md @@ -0,0 +1,19 @@ +--- +'@modernrelay/orbit-core': minor +--- + +Label declutter: screen-space overlap culling, on by default. + +Dense clusters used to stack their top-ranked labels into an unreadable +pile — the selector ranked and viewport-culled but never checked where +labels land on screen. Ranked selection now runs a greedy occupancy pass in +rank order: a candidate whose estimated label box intersects an +already-placed label loses its slot to the next-ranked candidate. `showFor` +ids always render and claim their space first. New `LabelConfig` fields: +`overlap: 'hide' (default) | 'allow'` and `overlapPadding` (px, default 2). +Boxes are fixed-per-character estimates — decluttering, not typesetting — +and selection stays overlap-blind when the viewport cannot project. +Note for FakeEngine-based tests: the double projects identity coordinates, +so decluttering engages there too — suites that pin label sets over +tightly-packed fixtures should opt out with `overlap: 'allow'` (this +repo's scheduling-focused suites now do). diff --git a/apps/storybook/src/graph/Labels.stories.tsx b/apps/storybook/src/graph/Labels.stories.tsx index 8ece142..4b3d96b 100644 --- a/apps/storybook/src/graph/Labels.stories.tsx +++ b/apps/storybook/src/graph/Labels.stories.tsx @@ -40,6 +40,20 @@ function frame(globals: Record, props: Partial) ); } +/** the unbearable case: labels at every zoom, dense clusters — then declutter */ +const PILEUP_ALLOW: LabelConfig = { + minZoom: 0, + maxVisible: 64, + getText: labelOf, + overlap: 'allow', +}; +const PILEUP_HIDE: LabelConfig = { + minZoom: 0, + maxVisible: 64, + getText: labelOf, + overlap: 'hide', +}; + const meta = { title: 'Graph/Labels', parameters: { @@ -87,3 +101,24 @@ export const CustomPills: Story = { ), }), }; + +interface OverlapArgs { + overlap: 'hide' | 'allow'; +} + +export const Declutter: StoryObj = { + args: { overlap: 'hide' }, + argTypes: { overlap: { control: 'radio', options: ['hide', 'allow'] } }, + parameters: { + docs: { + description: { + story: + "Screen-space declutter (the default): a ranked label whose box would " + + "land on an already-placed label passes its slot to the next candidate. " + + "Flip to 'allow' to see the old pileup.", + }, + }, + }, + render: (args, { globals }) => + frame(globals, { labels: args.overlap === 'hide' ? PILEUP_HIDE : PILEUP_ALLOW }), +}; diff --git a/packages/core/src/labels.ts b/packages/core/src/labels.ts index 9ec61b6..182fad3 100644 --- a/packages/core/src/labels.ts +++ b/packages/core/src/labels.ts @@ -138,6 +138,71 @@ export function selectLabelCandidates>( return degreeOf !== undefined ? degreeOf(i) : 0; }; + // --- screen-space declutter (overlap: 'hide', the default) --------------- + // Greedy occupancy in RANK order: an estimated label box that intersects + // an already-claimed box loses its slot to the next-ranked candidate. + // Boxes are estimates (fixed per-character width) — the goal is + // decluttering, not typesetting. A uniform cell grid prunes the + // intersection tests; without a projectable viewport there are no boxes + // and selection stays overlap-blind. + const declutter = config.overlap !== 'allow' && project !== undefined; + // non-finite padding would give the occupancy grid infinite loop bounds + // (a main-thread hang) — degenerate input falls back to the default. + const configuredPad = config.overlapPadding ?? 2; + const pad = Number.isFinite(configuredPad) ? Math.max(0, configuredPad) : 2; + const CELL = 64; + const CHAR_W = 7; + const BOX_H = 18; + const keptBoxes: number[] = []; // x0,y0,x1,y1 quads + const cells = new Map(); + const cellKey = (cx: number, cy: number): number => cx * 100003 + cy; + const boxOf = (i: number, text: string): readonly [number, number, number, number] | null => { + const x = positions[2 * i]; + const y = positions[2 * i + 1]; + if (x === undefined || y === undefined || Number.isNaN(x) || Number.isNaN(y)) return null; + const sPt = project!([x, y]); + if (sPt === null) return null; + const w = CHAR_W * text.length + 8 + 2 * pad; + const h = BOX_H + 2 * pad; + return [sPt[0] - w / 2, sPt[1] - h / 2, sPt[0] + w / 2, sPt[1] + h / 2]; + }; + const collides = (b: readonly [number, number, number, number]): boolean => { + const cx0 = Math.floor(b[0] / CELL); + const cy0 = Math.floor(b[1] / CELL); + const cx1 = Math.floor(b[2] / CELL); + const cy1 = Math.floor(b[3] / CELL); + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const bucket = cells.get(cellKey(cx, cy)); + if (bucket === undefined) continue; + for (const q of bucket) { + const x0 = keptBoxes[q]!; + const y0 = keptBoxes[q + 1]!; + const x1 = keptBoxes[q + 2]!; + const y1 = keptBoxes[q + 3]!; + if (b[0] < x1 && b[2] > x0 && b[1] < y1 && b[3] > y0) return true; + } + } + } + return false; + }; + const claim = (b: readonly [number, number, number, number]): void => { + const q = keptBoxes.length; + keptBoxes.push(b[0], b[1], b[2], b[3]); + const cx0 = Math.floor(b[0] / CELL); + const cy0 = Math.floor(b[1] / CELL); + const cx1 = Math.floor(b[2] / CELL); + const cy1 = Math.floor(b[3] / CELL); + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const key = cellKey(cx, cy); + const bucket = cells.get(key); + if (bucket === undefined) cells.set(key, [q]); + else bucket.push(q); + } + } + }; + const out: LabelCandidate[] = []; const chosen = new Set(); let overloadCount = 0; @@ -158,7 +223,14 @@ export function selectLabelCandidates>( for (let j = 0; j < take; j++) { const i = forcedIdx[j]!; chosen.add(i); - out.push({ id: scene.idByIndex[i]!, text: textOf(i), forced: true }); + const text = textOf(i); + if (declutter) { + // forced ids always render; they claim space so ranked fills avoid + // stacking onto them (forced-on-forced overlap is the host's call). + const b = boxOf(i, text); + if (b !== null) claim(b); + } + out.push({ id: scene.idByIndex[i]!, text, forced: true }); } } @@ -170,10 +242,18 @@ export function selectLabelCandidates>( ranked.push({ i, w: weightOf(i) }); } ranked.sort((a, b) => b.w - a.w || a.i - b.i); // weight desc, accepted-base tie-break - const need = k - out.length; - for (let j = 0; j < need && j < ranked.length; j++) { + for (let j = 0; j < ranked.length && out.length < k; j++) { const i = ranked[j]!.i; - out.push({ id: scene.idByIndex[i]!, text: textOf(i), forced: false }); + const text = textOf(i); + if (declutter) { + const b = boxOf(i, text); + // unprojectable here ⇒ visibility already fell back — keep the label + if (b !== null) { + if (collides(b)) continue; // the slot passes to the next-ranked + claim(b); + } + } + out.push({ id: scene.idByIndex[i]!, text, forced: false }); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5a994e6..7b993cb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -837,6 +837,19 @@ export interface LabelConfig> { getText?: (node: GraphNode) => string; /** Ranking weight; default nodeSize result order, else degree. */ getWeight?: (node: GraphNode) => number; + /** + * Screen-space overlap policy for ranked labels. 'hide' (default) + * declutters: a candidate whose estimated label box intersects an + * already-placed label loses its slot to the next-ranked candidate, so + * dense clusters stop stacking text. `showFor` ids always render and claim + * their space first. 'allow' restores overlap-blind selection. Boxes are + * width ESTIMATES (fixed per-character size) — decluttering, not + * typesetting — and require a projectable viewport; with an engine that + * cannot project screen coordinates, selection stays overlap-blind. + */ + overlap?: 'hide' | 'allow'; + /** Extra padding (CSS px) inflating each estimated label box. Default 2. */ + overlapPadding?: number; } /** accessibility runtime options. */ diff --git a/packages/core/test/cluster-labels.test.ts b/packages/core/test/cluster-labels.test.ts index 3f06dbf..a715784 100644 --- a/packages/core/test/cluster-labels.test.ts +++ b/packages/core/test/cluster-labels.test.ts @@ -56,7 +56,9 @@ interface Rig { placements: () => LabelPlacement[]; } -async function rig(labels: LabelConfig = { minZoom: 0 }): Promise { +// overlap: 'allow' — these tests pin the LOD hand-off, not declutter +// (FakeEngine's 10px seed grid would otherwise cull stacked fixtures). +async function rig(labels: LabelConfig = { minZoom: 0, overlap: 'allow' }): Promise { const engines: FakeEngine[] = []; const instance = createGraphInstance({ engine: () => { @@ -177,7 +179,7 @@ describe('label.maxZoom LOD hand-off', () => { const { instance, engine, placements } = await rig({ minZoom: 0, maxZoom: 2 }); engine.injectViewportChange({ x: 0, y: 0, zoom: 5 }); // Force the throttled re-rank synchronously through a labels-config write. - instance.applyHostUpdate({ labels: { minZoom: 0, maxZoom: 2 } }); + instance.applyHostUpdate({ labels: { minZoom: 0, maxZoom: 2, overlap: 'allow' } }); const list = placements(); expect(clusterLabels(list)).toEqual([]); @@ -185,14 +187,14 @@ describe('label.maxZoom LOD hand-off', () => { }); it('without maxZoom the two bands coexist, each on its own gate', async () => { - const { placements } = await rig({ minZoom: 0 }); + const { placements } = await rig({ minZoom: 0, overlap: 'allow' }); const list = placements(); expect(clusterLabels(list).map((p) => p.id)).toEqual(['red', 'blue']); expect(nodeLabels(list).map((p) => p.id)).toEqual(['a', 'c', 'b', 'd']); // degree rank }); it('cluster labels lead the emitted order (coarse layer first)', async () => { - const { placements } = await rig({ minZoom: 0 }); + const { placements } = await rig({ minZoom: 0, overlap: 'allow' }); expect(placements().map((p) => p.kind ?? 'node')).toEqual([ 'cluster', 'cluster', diff --git a/packages/core/test/labels.test.ts b/packages/core/test/labels.test.ts index 5f5a829..343be82 100644 --- a/packages/core/test/labels.test.ts +++ b/packages/core/test/labels.test.ts @@ -262,3 +262,161 @@ describe('text and capacity policy', () => { expect(select({ ...f, viewport: vp(1), config: { maxVisible: 0 } }).placements).toHaveLength(0); }); }); + +describe('screen-space declutter (overlap: hide, the default)', () => { + // identity projection: space coords ARE screen px, boxes ~7px/char + 8 + padding + const RECT: readonly [number, number, number, number] = [0, 0, 1000, 1000]; + + it('culls a lower-ranked label stacked on a winner; the slot passes to the next candidate', () => { + // a and b share a spot; c is far away. k=2 → a (top weight) + c, never b. + const f = fixture( + ['a', 'b', 'c'], + [ + [100, 100], + [104, 102], + [600, 600], + ], + ); + const result = select({ + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity }, + config: { maxVisible: 2, getWeight: (n) => ({ a: 3, b: 2, c: 1 })[n.id] ?? 0 }, + }); + expect(ids(result)).toEqual(['a', 'c']); + }); + + it('rejected candidates do not consume capacity (stacked pairs each yield one)', () => { + // two stacked pairs + one free node; k=3 → one per pair + the free node + const f = fixture( + ['a', 'b', 'c', 'd', 'e'], + [ + [100, 100], + [102, 101], + [500, 500], + [503, 499], + [900, 900], + ], + ); + const result = select({ + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity }, + config: { + maxVisible: 3, + getWeight: (n) => ({ a: 5, b: 4, c: 3, d: 2, e: 1 })[n.id] ?? 0, + }, + }); + expect(ids(result)).toEqual(['a', 'c', 'e']); + }); + + it('showFor ids always render and claim their space from ranked fills', () => { + // forced pair stacked together both render; ranked candidate on the same + // spot is culled, a distant one fills instead. + const f = fixture( + ['f1', 'f2', 'r1', 'r2'], + [ + [100, 100], + [101, 101], + [103, 99], + [700, 700], + ], + ); + const result = select({ + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity }, + config: { + maxVisible: 4, + showFor: ['f1', 'f2'], + getWeight: (n) => (n.id === 'r1' ? 2 : 1), + }, + }); + expect(ids(result)).toEqual(['f1', 'f2', 'r2']); + }); + + it("overlap: 'allow' restores overlap-blind selection", () => { + const f = fixture( + ['a', 'b'], + [ + [100, 100], + [101, 101], + ], + ); + const result = select({ + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity }, + config: { maxVisible: 2, overlap: 'allow' }, + }); + expect(ids(result)).toEqual(['a', 'b']); + }); + + it('without a projectable viewport, selection stays overlap-blind', () => { + const f = fixture( + ['a', 'b'], + [ + [100, 100], + [101, 101], + ], + ); + const result = select({ + ...f, + viewport: { zoom: 2 }, // no rect, no spaceToScreen + config: { maxVisible: 2 }, + }); + expect(ids(result)).toEqual(['a', 'b']); + }); + + it('overlapPadding widens the exclusion zone', () => { + // 60px apart: separate at default padding, colliding at padding 40 + const f = fixture( + ['a', 'b'], + [ + [100, 100], + [160, 100], + ], + ); + const base = { + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity } as const, + }; + expect(ids(select({ ...base, config: { maxVisible: 2 } }))).toEqual(['a', 'b']); + expect(ids(select({ ...base, config: { maxVisible: 2, overlapPadding: 40 } }))).toEqual(['a']); + }); + + it('non-finite overlapPadding falls back to the default instead of hanging', () => { + const f = fixture( + ['a', 'b', 'c'], + [ + [100, 100], + [104, 102], + [600, 600], + ], + ); + for (const bad of [Infinity, Number.NaN, -Infinity]) { + const result = select({ + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity }, + config: { maxVisible: 2, overlapPadding: bad, getWeight: (n) => ({ a: 3, b: 2, c: 1 })[n.id] ?? 0 }, + }); + // terminates AND behaves exactly like the default padding + expect(ids(result)).toEqual(['a', 'c']); + } + }); + + it('is deterministic across repeated calls', () => { + const f = fixture( + ['a', 'b', 'c', 'd'], + [ + [100, 100], + [102, 100], + [400, 400], + [402, 401], + ], + ); + const args = { + ...f, + viewport: { zoom: 2, screenRect: RECT, spaceToScreen: identity } as const, + config: { maxVisible: 4 }, + }; + const first = ids(select(args)); + for (let n = 0; n < 5; n++) expect(ids(select(args))).toEqual(first); + }); +}); diff --git a/packages/core/test/overlay-scheduler.test.ts b/packages/core/test/overlay-scheduler.test.ts index a723587..4d9d7b4 100644 --- a/packages/core/test/overlay-scheduler.test.ts +++ b/packages/core/test/overlay-scheduler.test.ts @@ -37,7 +37,8 @@ async function setup(labelOverrides: Partial> = {}) { const engine = h.engines[0]!; h.instance.applyHostUpdate({ data: snap(1, ['a', 'b', 'c'], [['a', 'b'], ['a', 'c']]), - labels: { minZoom: 0, ...labelOverrides }, + // overlap-blind by default: this file pins scheduling, not declutter + labels: { minZoom: 0, overlap: 'allow', ...labelOverrides }, }); engine.emitFrame(0); // sim-hot: first tick refreshes the CPU cache engine.injectSimulationEnd(); // settle: bank + re-rank diff --git a/packages/core/test/s8-crosscuts.test.ts b/packages/core/test/s8-crosscuts.test.ts index 5307aa9..08a79af 100644 --- a/packages/core/test/s8-crosscuts.test.ts +++ b/packages/core/test/s8-crosscuts.test.ts @@ -318,7 +318,7 @@ describe('label lane × hard scope', () => { const engine = engines[0]!; instance.applyHostUpdate({ data: snap(1, [...CHAIN_IDS], CHAIN_LINKS), - labels: { minZoom: 0, showFor: ['d'] }, + labels: { minZoom: 0, showFor: ['d'], overlap: 'allow' }, }); engine.injectSimulationEnd(); // settle: bank positions + full-base rank @@ -336,7 +336,7 @@ describe('label lane × hard scope', () => { engine.injectSimulationEnd(); engine.injectViewportChange({ x: 0, y: 0, zoom: 2 }); vi.advanceTimersByTime(100); // trailing viewport re-rank - instance.applyHostUpdate({ labels: { minZoom: 0, showFor: ['b', 'd'] } }); + instance.applyHostUpdate({ labels: { minZoom: 0, showFor: ['b', 'd'], overlap: 'allow' } }); expect(emissions.at(-1)).toEqual(['b', 'a']); // in-scope forced id first for (const list of emissions.slice(scopedFrom)) { for (const id of list) expect(['a', 'b']).toContain(id); diff --git a/packages/core/test/scheduler-invariants.test.ts b/packages/core/test/scheduler-invariants.test.ts index 9682b9d..495e500 100644 --- a/packages/core/test/scheduler-invariants.test.ts +++ b/packages/core/test/scheduler-invariants.test.ts @@ -41,7 +41,8 @@ async function setup(labelOverrides: Partial> = {}) { const engine = h.engines[0]!; h.instance.applyHostUpdate({ data: snap(1, IDS), - labels: { minZoom: 0, maxVisible: 64, ...labelOverrides }, + // overlap-blind by default: this file pins frame-tick invariants + labels: { minZoom: 0, maxVisible: 64, overlap: 'allow', ...labelOverrides }, }); engine.emitFrame(0); // sim-hot: first tick refreshes the CPU cache engine.injectSimulationEnd(); // settle: bank + re-rank diff --git a/packages/core/test/scope-instance.test.ts b/packages/core/test/scope-instance.test.ts index ac9d00d..6459fae 100644 --- a/packages/core/test/scope-instance.test.ts +++ b/packages/core/test/scope-instance.test.ts @@ -217,7 +217,7 @@ describe('label lane × scope', () => { const engine = h.engines[0]!; h.instance.applyHostUpdate({ data: snap(1, ['a', 'b', 'c'], [['a', 'b'], ['a', 'c']]), - labels: { minZoom: 0 }, + labels: { minZoom: 0, overlap: 'allow' }, }); engine.injectSimulationEnd(); // settle: bank positions + re-rank diff --git a/packages/core/test/soft-filter.test.ts b/packages/core/test/soft-filter.test.ts index 0a535ed..a5bf756 100644 --- a/packages/core/test/soft-filter.test.ts +++ b/packages/core/test/soft-filter.test.ts @@ -280,7 +280,7 @@ describe('selection over the visible set', () => { describe('label lane × mask', () => { it('mask-hidden nodes leave the candidate set', async () => { const { instance, engine } = await readyChain(); - instance.applyHostUpdate({ labels: { enabled: true } }); + instance.applyHostUpdate({ labels: { enabled: true, overlap: 'allow' } }); engine.injectSimulationEnd(); // bank positions so candidates are placeable let latest: readonly { id: string }[] = []; diff --git a/packages/react/test/security-fixture.test.tsx b/packages/react/test/security-fixture.test.tsx index 6d66d72..1007091 100644 --- a/packages/react/test/security-fixture.test.tsx +++ b/packages/react/test/security-fixture.test.tsx @@ -77,7 +77,7 @@ async function mountPayloadGraph(children?: ReactNode): Promise<{ engine} data={payloadDataset} - labels={{ minZoom: 0 }} + labels={{ minZoom: 0, overlap: 'allow' }} // every payload must render — subject is escaping, not declutter accessibility={{ label: 'Payload graph' }} > {children}