Skip to content

Label declutter: screen-space overlap culling, on by default - #9

Open
aaltshuler wants to merge 1 commit into
mainfrom
label-declutter
Open

Label declutter: screen-space overlap culling, on by default#9
aaltshuler wants to merge 1 commit into
mainfrom
label-declutter

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Density had no spatial control: the label selector ranks and viewport-culls, but never checks where labels land on screen — so in dense clusters the top-ranked winners stack into an unreadable pile (they are all in the same blob by construction).

Fix

Greedy screen-space occupancy in rank order, the standard map-renderer declutter pass, inside selectLabelCandidates:

  • Each candidate gets an estimated label box (fixed per-character width — decluttering, not typesetting); a box intersecting an already-placed label loses its slot to the next-ranked candidate (rejects don't consume capacity).
  • showFor ids always render and claim their space first.
  • New LabelConfig fields: overlap: 'hide' (default) | 'allow', overlapPadding (px, default 2). A cell grid prunes intersection tests; the pass runs only on the existing throttled re-rank triggers — never per frame.
  • Overlap-blind when the viewport cannot project screen coordinates.

Default is on — same philosophy as the settle camera and fit clamp: the out-of-box render should be legible. FakeEngine projects identity coordinates, so decluttering engages in headless tests too; suites that pin label sets over tightly-packed fixtures opt out with overlap: 'allow' (this repo's scheduling suites and the react security fixture now do, each with a comment saying why).

Evidence

New Labels/Declutter story (in this PR): the same 1,500-node fixture with overlap: 'allow' shows text smears in every cluster core; with the default, every rendered label is legible and spaced.

Tests

Seven new selector tests: stacked-winner culling, slot promotion, showFor exemption + space claiming, 'allow' restores stacking, unprojectable-viewport fallback, padding widening, determinism. Full check green: 1,807 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_018SeFxK217ZrHSERqK6kjcb

Greptile Summary

The PR adds default-on, greedy screen-space overlap culling to ranked node-label selection, with opt-out and padding controls.

  • Forced labels render first and reserve their projected screen area.
  • Ranked labels that intersect reserved boxes yield their capacity to lower-ranked candidates.
  • Tests and Storybook coverage demonstrate culling, fallback, padding, determinism, and legacy overlap behavior.

Confidence Score: 4/5

The PR needs a finite-value guard for overlap padding before merging because an accepted configuration can hang label recomputation on the main thread.

The new occupancy pass derives incrementing grid-loop bounds directly from overlapPadding; positive infinity survives the current clamp and creates a loop whose negative-infinity counter can never advance.

Files Needing Attention: packages/core/src/labels.ts

Important Files Changed

Filename Overview
packages/core/src/labels.ts Implements the occupancy-grid declutter pass, but non-finite overlap padding can make its grid traversal non-terminating.
packages/core/src/types.ts Adds the public overlap policy and padding configuration contracts.
packages/core/test/labels.test.ts Covers normal declutter behavior comprehensively but does not exercise non-finite padding.
apps/storybook/src/graph/Labels.stories.tsx Adds an interactive comparison of overlap hiding and legacy overlap behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Visible candidates] --> B[Forced labels]
  B --> C[Claim forced label boxes]
  C --> D[Rank remaining candidates]
  D --> E{Projectable box?}
  E -- No --> F[Keep candidate]
  E -- Yes --> G{Intersects claimed box?}
  G -- Yes --> H[Skip and try next rank]
  G -- No --> I[Keep and claim box]
  F --> J{Capacity reached?}
  I --> J
  H --> J
  J -- No --> D
  J -- Yes --> K[Emit placements]
Loading

Fix all with Greploop Fix All in Claude Code

Reviews (1): Last reviewed commit: "Label declutter: screen-space overlap cu..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

Dense clusters stacked 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 over estimated label boxes (fixed per-character width, cell-grid
pruned): a colliding candidate passes its slot to the next-ranked one.
showFor ids always render and claim their space first. New LabelConfig
fields: overlap 'hide' (default) | 'allow', overlapPadding (px, default
2). Overlap-blind when the viewport cannot project. Seven new selector
tests; scheduling-focused suites (and the react security fixture, whose
subject is escaping) opt out explicitly with overlap: 'allow'.

Verified live in the catalog's new Labels/Declutter story: the same
1,500-node fixture goes from stacked text smears to fully legible labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SeFxK217ZrHSERqK6kjcb
// 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant