Skip to content

perf(globe): bound HTML marker count and instrument the field (#5368)#5371

Open
koala73 wants to merge 2 commits into
mainfrom
perf/globe-marker-budget-5368
Open

perf(globe): bound HTML marker count and instrument the field (#5368)#5371
koala73 wants to merge 2 commits into
mainfrom
perf/globe-marker-budget-5368

Conversation

@koala73

@koala73 koala73 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Bounds how many DOM markers the 3D globe renders, and instruments the field so the mobile question can actually be answered.

What the mechanism is

Confirmed in node_modules, not inferred: globe.gl renders htmlElementsData through three's CSS2DRenderer, which per marker per frame writes style.display, builds and writes a style.transform string, and writes style.zIndex — then runs a whole-scene traverseVisible plus an O(n log n) depth sort. It runs on every animation frame, even with a still camera (CSS2DRenderer.js:214-310, driven from globe.gl.mjs:395). Write-only style thrash, no forced reflow. So frame cost is linear in marker count, and it lands in the interaction frame budget — which is what INP measures.

Two corrections to the issue's diagnosis

A production census (globe mode, prod, 2026-07-18) contradicts #5368 on two points, and I'd rather state them than quietly ship past them:

view markers dominant source
desktop default 2,319 vessels = 1,526 and climbing, live AIS websocket, uncapped
mobile default ~120 (measured 123 / 122 / 96-122 across three runs) earthquakes + hotspots + news
desktop + ucdpEvents 4,319 ucdp = 2,000
  1. ucdpEvents is not the cause. It is default-off (panels.ts:162) and its bootstrap projection caps at 150 unless explicitly opted into. The actual heavyweight — 1,526 AIS vessel markers — isn't mentioned in the issue.
  2. The mobile default view carries ~120 markers, not ~1,850. The 1,850 figure came from a repro at a desktop-class viewport; mobile defaults (FULL_MOBILE_MAP_LAYERS, panels.ts:194) turn off bases, nuclear, military, economic, waterways.

So this PR bounds the worst case — which is real, unbounded, and desktop/layer-heavy — but I can't claim it moves the mobile p75 that headlines the issue. A within-session A/B was suggestive (122 markers -> frame p50 158ms; 22 -> 76ms) but the isolation run drifted badly under software WebGL, so I'm not calling it evidence. Hence the telemetry.

What changed

  • src/utils/globe-marker-budget.ts — pure, DOM-free selection. Per-feed cap plus a global ceiling allocated max-min fair, so one 1,526-marker feed can't evict an 11-marker weather layer. Desktop {perLayer: 300, total: 800}, mobile {perLayer: 150, total: 400} (a deliberate no-op on the ~120-marker mobile default; it bites only when layers are stacked onto a phone).
  • Ranking. Layers with a severity signal rank by it (deaths, magnitude, brightness, carriers first). Everything else ranks by nearness to the camera — because trimming the alphabetically-ordered NUCLEAR_FACILITIES list by array order silently drops Zaporizhzhia and Pantex. Nearness makes the trim view-relative: you see the sites in the region you're looking at, and the rest arrive as you rotate toward them.
  • Disclosure. shown/total beside any trimmed layer. This is a monitoring product; silently withholding 2,000 conflict events is a data-integrity problem, not a UI detail. Flash and news markers are exempt (navigational / no panel row to disclose against).
  • Telemetry. The marker load rides the existing INP report (enqueueSentryCall, already sampled, trimmed to the bad tail, and safe against Sentry's deferred init) instead of a second observer. Counts and truncated-layer strings only — no layer-interest vector, no viewport, no deviceMemory/hardwareConcurrency.

Verification

  • 33 tests, all green. The headline cases feed the real selectGlobeMarkers the measured production census, not invented fixtures.
  • Mutation-tested: disabling the global ceiling, making proximityRank ignore its focus, leaking the layer vector from the probe, and dropping the globe extra from the INP report each turn tests red.
  • Browser-verified on a local build: 452 -> 118 markers under a temporarily-shrunk budget, badges reporting nuclear=26/250; and after the review fixes, badge outside the label, pointer-events: none, theme-aware colour, and clicking it no longer disables the layer.

Review

Reviewed by 6 reviewers plus an independent adversarial pass through a different model family (Codex/gpt-5.5). Fixed from that review: a P0 where unranked layers dropped nuclear sites by array order; the disclosure badge sitting inside the <label> so clicking it toggled the layer off; a hardcoded white badge colour invisible in light theme; a parallel Sentry import that bypassed the deferred-init queue and would have silently dropped early-session reports; as-cast rank callbacks replaced with _kind narrowing. The adversarial pass also proved my original source-regex wiring tests stayed green when the behaviour they named was deleted — those were replaced with behavioural tests that fail under mutation.

Follow-ups (not in scope here)

  • Name the mobile mover from the field data this ships.
  • The vessels AIS feed has no upstream ceiling — worth capping at the source.
  • ConflictMarker (_kind: 'conflict') has no producer; the marker branch in buildMarkerElement is dead code.
  • An i18n key for the badge tooltip (currently a plain literal, matching this panel's existing precedent).
  • sortObjects = false on the CSS2DRenderer would remove a full scene traverse + sort + n zIndex writes per frame, but globe.gl doesn't expose the instance.

Fixes #5368

https://claude.ai/code/session_01VEAjp5vuHXsnfJNHDgJioz

globe.gl renders `htmlElementsData` through three's CSS2DRenderer, which
rewrites display/transform/zIndex on every marker on every animation frame —
unconditionally, even with a still camera — plus a whole-scene traverseVisible
and an O(n log n) depth sort. Frame cost is linear in marker count and lands in
the interaction frame budget, which is what INP measures.

A production census on 2026-07-18 found the desktop default view carrying 2,319
markers, 1,526 of them live AIS vessels arriving over a websocket with no
ceiling of their own; opting into Armed Conflict Events adds a further 2,000.
Nothing upstream bounds either, so the ceiling goes in at the flush boundary:
a per-feed cap plus a global budget allocated max-min fair, so one oversized
feed cannot evict an 11-marker weather layer.

Truncation is disclosed as "shown/total" beside the layer in the panel. This is
a monitoring product — quietly dropping 2,000 conflict events would
misrepresent the data.

Layers with a severity signal (deaths, magnitude, brightness) rank by it. The
rest rank by nearness to the camera, NOT by feed order: trimming the
alphabetically-ordered nuclear facility list by array order silently drops
Zaporizhzhia. Nearness makes the trim view-relative, so a user sees the sites
in the region they are looking at and the rest arrive as they rotate toward
them.

The marker load also rides the existing INP report (`enqueueSentryCall`,
already sampled and deferred-Sentry-safe) rather than a second observer of its
own. This matters because the lab evidence is contradictory: the mobile default
view carries only ~120 markers, yet mobile INP regressed hardest — so this
bounds the worst case while the field names the actual mobile mover. Same play
as the #5336 CLS mover tracker. The probe deliberately sends counts and
truncated layers only, never the user's layer-interest vector or device
dimensions.

Claude-Session: https://claude.ai/code/session_01VEAjp5vuHXsnfJNHDgJioz
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 18, 2026 5:38pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR bounds the number of HTML markers the 3D globe hands to CSS2DRenderer (which rewrites display, transform, and zIndex per marker per frame unconditionally) and instruments the marker load so field INP data can settle whether the mobile regression is actually caused by globe markers or something else.

  • globe-marker-budget.ts — pure, DOM-free selection via a max-min fair binary search (fairShareCap) plus proximityRank as the default for layers with no severity signal, preventing alphabetical static layers (nuclear facilities) from silently shedding their tail entries under a cap.
  • globe-marker-probe.ts + inp-report.ts — module-level snapshot piggybacked onto the existing bad-tail-sampled INP Sentry event; deliberately omits the full layer-interest vector and hardware fingerprint fields, sending only counts and truncated-layer strings.
  • GlobeMap.ts — groups are now built per-feed rather than concatenated, the fair-share budget applied, disclosure badges updated on every flush and on panel mount, and setGlobeMarkerLoad(null) called on destroy to stop attributing INP events to an unmounted globe.

Confidence Score: 4/5

Safe to merge; the two findings are both non-blocking quality issues and do not affect the core budgeting invariants or telemetry correctness on the happy path.

The binary search, fair-share allocation, proximity ranking, takeTop, NaN-score handling, and probe lifecycle (mount/unmount) are all correct and well-covered by tests against production census figures. Two issues in GlobeMap.ts deserve a follow-up: the add() helper accepts Partial<GlobeMarkerGroup> as its override bag, which includes layer and markers — spread-last means an accidentally-passed layer key silently wins over the positional argument without a TypeScript error. Separately, the probe state and truncation fields are committed outside the try block that wraps globe.htmlElementsData(markers), so a WebGL throw leaves the probe one flush ahead of what the renderer actually applied until the next successful flush.

src/components/GlobeMap.ts — the add() helper type and probe-update ordering are both in the flushMarkersImmediate method.

Important Files Changed

Filename Overview
src/utils/globe-marker-budget.ts New pure utility for fair-share marker budgeting; binary search, proximityRank, and takeTop are all correct. The fairShareCap uses Math.ceil to avoid infinite loop on two-element ranges — intentional and correct.
src/bootstrap/globe-marker-probe.ts New telemetry snapshot module; correctly omits fingerprinting fields, buckets counts for low-cardinality tags, and exposes a test-reset hook. Clean.
src/bootstrap/inp-report.ts Globe extras injected via injectable globeExtra parameter (defaulting to module-level getter); spread (globe ?? {}) cleanly omits fields when globe is unmounted. Test coverage added for both mounted and flat-map paths.
src/components/GlobeMap.ts Core wiring: groups built with add(), proximity rank assigned to any group with no explicit rank, budget selected, truncation labels updated, probe set. The extra parameter on add() is typed as Partial<GlobeMarkerGroup> which includes layer and markers, creating a silent override footgun. Probe state is also set before the guarded globe.htmlElementsData call.
src/styles/main.css Adds .layer-truncation-count with margin-left: auto; confirmed layer-toggle-row is already display: flex in both globe and deckgl variants, so alignment works correctly. pointer-events: none prevents badge click toggling the layer.
tests/globe-marker-budget.test.mts 33 tests using production census figures; covers invariants (never exceeds ceiling), ranking, proximity, NaN guard, multi-feed rollup, and zero-budget edge cases. Behavioural tests are mutation-hostile per PR description.
tests/globe-marker-probe.test.mts Covers silent-when-unmounted, unmount clears state, payload shape, fingerprint absence, and bucket boundary values. Complete.
tests/inp-report.test.mts Added tests for globe-mounted and flat-map INP paths; existing INP fields verified to survive alongside globe extras.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant GL as GlobeMap
    participant BUD as globe-marker-budget
    participant PRB as globe-marker-probe
    participant INP as inp-report
    participant SEN as Sentry

    note over GL,PRB: On every animation-frame flush
    GL->>BUD: selectGlobeMarkers(groups, budget)
    BUD-->>GL: "markers[], truncated{}"
    GL->>GL: updateLayerTruncationLabels()
    GL->>PRB: setGlobeMarkerLoad(rendered, truncated, activeLayerCount)
    GL->>GL: globe.htmlElementsData(markers)

    note over GL,PRB: On globe unmount
    GL->>PRB: setGlobeMarkerLoad(null)

    note over INP,SEN: On INP event (bad-tail sample only)
    INP->>PRB: getGlobeMarkerExtra()
    PRB-->>INP: globeMarkers, bucket, truncated[] or null
    INP->>SEN: captureMessage web-vital INP
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant GL as GlobeMap
    participant BUD as globe-marker-budget
    participant PRB as globe-marker-probe
    participant INP as inp-report
    participant SEN as Sentry

    note over GL,PRB: On every animation-frame flush
    GL->>BUD: selectGlobeMarkers(groups, budget)
    BUD-->>GL: "markers[], truncated{}"
    GL->>GL: updateLayerTruncationLabels()
    GL->>PRB: setGlobeMarkerLoad(rendered, truncated, activeLayerCount)
    GL->>GL: globe.htmlElementsData(markers)

    note over GL,PRB: On globe unmount
    GL->>PRB: setGlobeMarkerLoad(null)

    note over INP,SEN: On INP event (bad-tail sample only)
    INP->>PRB: getGlobeMarkerExtra()
    PRB-->>INP: globeMarkers, bucket, truncated[] or null
    INP->>SEN: captureMessage web-vital INP
Loading

Comments Outside Diff (1)

  1. src/components/GlobeMap.ts, line 297-304 (link)

    P2 setGlobeMarkerLoad (and the truncation/count fields) are updated outside the try block, so they reflect the new budget result before globe.htmlElementsData(markers) actually applies it to the renderer. On a WebGL error the probe would report the freshly-computed marker count while the globe still holds the previous set, and that stale value persists until the next successful flush. Moving the probe call (and the this.markerTruncation / this.renderedMarkerCount writes) to after the try block keeps the reported values in sync with what the renderer actually holds.

Reviews (1): Last reviewed commit: "perf(globe): bound HTML marker count and..." | Re-trigger Greptile

Comment on lines +2122 to +2126
const add = (
layer: string,
markers: readonly GlobeMarker[],
extra: Partial<GlobeMarkerGroup<GlobeMarker>> = {},
): void => { if (markers.length) groups.push({ layer, markers, ...extra }); };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The extra parameter is typed as Partial<GlobeMarkerGroup<GlobeMarker>>, which includes layer and markers as optional fields. Because ...extra is spread last in the object literal, a caller that accidentally passes { layer: 'other' } inside extra would silently override the positional layer argument — TypeScript won't catch this since both are string. Restricting the type to only the safe fields eliminates the footgun entirely.

Suggested change
const add = (
layer: string,
markers: readonly GlobeMarker[],
extra: Partial<GlobeMarkerGroup<GlobeMarker>> = {},
): void => { if (markers.length) groups.push({ layer, markers, ...extra }); };
const add = (
layer: string,
markers: readonly GlobeMarker[],
extra: Pick<Partial<GlobeMarkerGroup<GlobeMarker>>, 'rank' | 'exempt'> = {},
): void => { if (markers.length) groups.push({ layer, markers, ...extra }); };

…be ordering (#5368)

Six findings from review of #5371, all confirmed against the cited lines first.

P1 — the badge promised something the code never did. `proximityRank` ran only
in `flushMarkersImmediate`, so a truncated layer froze at whichever POV happened
to be current at the last data-driven flush, while the tooltip said "rotate or
zoom to bring others in". Now re-selects when the camera settles (controls
`end`, plus after `setView`/`setCenter` transitions, which emit no such event),
and only while something is actually truncated — with nothing withheld the
selection is view-independent and a re-flush would churn DOM on the very path
this feature exists to make cheaper. Verified in-browser: rotating Europe->Asia
replaces the entire visible nuclear set.

P1 — an explicit rank silently disabled the proximity default. Vessels ranked
`carrier ? 1 : 0`, leaving ~1,473 non-carriers tied on 0 and cut by feed order:
exactly the arbitrary-selection bug this PR fixed for nuclear facilities.
Ranks now compose via a `tieBreak` compared lexicographically rather than summed
with a weight — severity decides first, nearness decides the rest, and a 0.1
magnitude gap stays decisive.

P2 — the probe and the badges were published before `htmlElementsData` was
known to have succeeded; on throw they described a render that never happened.
They now commit only after globe.gl accepts the markers.

P2 — `pointer-events: none` made the badge non-interactive, which also
suppressed the native `title` tooltip the whole disclosure lives in. Removed:
the badge is a sibling of the <label>, not a child, so hovering and clicking it
were already safe. Verified the layer no longer toggles on badge click.

P2 — the budget splits at the app's 768px layout breakpoint while
`getWebVitalsFormFactor` calls anything coarse-pointer or <=1024px "mobile", so
a 900px tablet is a mobile form factor running the desktop budget. Rather than
change rendering for 769-1024px devices, the probe now reports
`globeBudgetProfile` so the RUM join is unambiguous.

P2 — `add()` took a full `Partial<GlobeMarkerGroup>`, letting a caller overwrite
the `layer`/`markers` it had just set. Narrowed to the tuning knobs.

Every fix is mutation-tested: reverting the tie-break ordering, inverting its
precedence, and dropping the probe field each turn tests red.

Claude-Session: https://claude.ai/code/session_01VEAjp5vuHXsnfJNHDgJioz
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.

perf(inp): mobile INP regressed 272→504 in 3 days — 3D globe scene canvas interactions 3-5× slower on both devices (started 07-15)

1 participant