perf(globe): bound HTML marker count and instrument the field (#5368)#5371
perf(globe): bound HTML marker count and instrument the field (#5368)#5371koala73 wants to merge 2 commits into
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR bounds the number of HTML markers the 3D globe hands to
Confidence Score: 4/5Safe 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 src/components/GlobeMap.ts — the Important Files Changed
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
%%{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
|
| const add = ( | ||
| layer: string, | ||
| markers: readonly GlobeMarker[], | ||
| extra: Partial<GlobeMarkerGroup<GlobeMarker>> = {}, | ||
| ): void => { if (markers.length) groups.push({ layer, markers, ...extra }); }; |
There was a problem hiding this comment.
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.
| 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
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 rendershtmlElementsDatathrough three'sCSS2DRenderer, which per marker per frame writesstyle.display, builds and writes astyle.transformstring, and writesstyle.zIndex— then runs a whole-scenetraverseVisibleplus an O(n log n) depth sort. It runs on every animation frame, even with a still camera (CSS2DRenderer.js:214-310, driven fromglobe.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:
vessels= 1,526 and climbing, live AIS websocket, uncappeducdpEventsucdpEventsis 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.FULL_MOBILE_MAP_LAYERS,panels.ts:194) turn offbases,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).NUCLEAR_FACILITIESlist 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.shown/totalbeside 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).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, nodeviceMemory/hardwareConcurrency.Verification
selectGlobeMarkersthe measured production census, not invented fixtures.proximityRankignore its focus, leaking the layer vector from the probe, and dropping the globe extra from the INP report each turn tests red.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_kindnarrowing. 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)
vesselsAIS feed has no upstream ceiling — worth capping at the source.ConflictMarker(_kind: 'conflict') has no producer; the marker branch inbuildMarkerElementis dead code.sortObjects = falseon 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