Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .changeset/label-declutter.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 35 additions & 0 deletions apps/storybook/src/graph/Labels.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ function frame(globals: Record<string, unknown>, props: Partial<DemoGraphProps>)
);
}

/** the unbearable case: labels at every zoom, dense clusters — then declutter */
const PILEUP_ALLOW: LabelConfig<DemoNodeAttrs> = {
minZoom: 0,
maxVisible: 64,
getText: labelOf,
overlap: 'allow',
};
const PILEUP_HIDE: LabelConfig<DemoNodeAttrs> = {
minZoom: 0,
maxVisible: 64,
getText: labelOf,
overlap: 'hide',
};

const meta = {
title: 'Graph/Labels',
parameters: {
Expand Down Expand Up @@ -87,3 +101,24 @@ export const CustomPills: Story = {
),
}),
};

interface OverlapArgs {
overlap: 'hide' | 'allow';
}

export const Declutter: StoryObj<OverlapArgs> = {
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 }),
};
85 changes: 81 additions & 4 deletions packages/core/src/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,68 @@ export function selectLabelCandidates<N = Record<string, unknown>>(
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;
const pad = Math.max(0, config.overlapPadding ?? 2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Infinite padding hangs selection

When a host supplies overlapPadding: Infinity, the clamp preserves infinity and the derived label box gives the occupancy grid infinite loop bounds. The loop counter starts at negative infinity and never advances, hanging label recomputation on the main thread.

Suggested change
const pad = Math.max(0, config.overlapPadding ?? 2);
const configuredPad = config.overlapPadding ?? 2;
const pad = Number.isFinite(configuredPad) ? Math.max(0, configuredPad) : 2;

Knowledge Base Used: Visual presentation and export

Fix in Claude Code

const CELL = 64;
const CHAR_W = 7;
const BOX_H = 18;
const keptBoxes: number[] = []; // x0,y0,x1,y1 quads
const cells = new Map<number, number[]>();
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<number>();
let overloadCount = 0;
Expand All @@ -158,7 +220,14 @@ export function selectLabelCandidates<N = Record<string, unknown>>(
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 });
}
}

Expand All @@ -170,10 +239,18 @@ export function selectLabelCandidates<N = Record<string, unknown>>(
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 });
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,19 @@ export interface LabelConfig<N = Record<string, unknown>> {
getText?: (node: GraphNode<N>) => string;
/** Ranking weight; default nodeSize result order, else degree. */
getWeight?: (node: GraphNode<N>) => 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. */
Expand Down
10 changes: 6 additions & 4 deletions packages/core/test/cluster-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ interface Rig {
placements: () => LabelPlacement[];
}

async function rig(labels: LabelConfig<NA> = { minZoom: 0 }): Promise<Rig> {
// 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<NA> = { minZoom: 0, overlap: 'allow' }): Promise<Rig> {
const engines: FakeEngine[] = [];
const instance = createGraphInstance<NA, EA>({
engine: () => {
Expand Down Expand Up @@ -177,22 +179,22 @@ 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([]);
expect(nodeLabels(list).map((p) => p.id)).toEqual(['a', 'c', 'b', 'd']); // degree rank
});

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',
Expand Down
138 changes: 138 additions & 0 deletions packages/core/test/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,141 @@ 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('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);
});
});
3 changes: 2 additions & 1 deletion packages/core/test/overlay-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ async function setup(labelOverrides: Partial<LabelConfig<NAttrs>> = {}) {
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
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/s8-crosscuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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);
Expand Down
Loading
Loading