File size: 8,534 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | /**
* Bounds how many HTML markers the 3D globe renders at once (#5368).
*
* globe.gl renders `htmlElementsData` through three's CSS2DRenderer, which walks
* EVERY marker on EVERY animation frame and rewrites `display`, `transform` and
* `zIndex` on each one β unconditionally, even with a still camera β then runs a
* whole-scene `traverseVisible` plus an O(n log n) depth sort
* (three/examples/jsm/renderers/CSS2DRenderer.js:214-310, driven from
* globe.gl's `_animationCycle`). Frame cost is therefore linear in marker count
* and lands squarely in the interaction frame budget, which is what INP measures.
*
* Measured on production 2026-07-18: the desktop default view carries 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 these, so the ceiling belongs here.
*
* This module is deliberately free of DOM and three.js imports so the real
* selection function can be unit-tested directly against real payload shapes.
*/
/** A single layer's contribution to the globe, before budgeting. */
export interface GlobeMarkerGroup<T> {
/**
* Layer toggle key this group belongs to. Several groups may share one key β
* `military` alone contributes flights, vessels and clusters β in which case
* each is budgeted independently (they are separate feeds with unrelated
* volumes) but their truncation is reported against the one toggle.
*/
layer: string;
markers: readonly T[];
/**
* Higher = more important = kept when the group must be trimmed. Layers with a
* severity signal (deaths, magnitude, brightness) pass it here; the rest get
* `proximityRank` so the cut is view-relative. Omitting it entirely falls back
* to raw feed order β see the warning in `takeTop`.
*/
rank?: (marker: T) => number;
/**
* Breaks ties within an equal `rank`, before source order does.
*
* A severity rank is usually coarse β `carrier ? 1 : 0` leaves ~1,500 vessels
* on the same score β and without a tie-break the cut among them falls back to
* feed order, which is the arbitrary-selection problem `proximityRank` exists
* to avoid. Pass `proximityRank` here so severity decides first and nearness
* decides the rest. Compared lexicographically, so no weighting to tune.
*/
tieBreak?: (marker: T) => number;
/**
* Ephemeral or navigational markers that must always render (e.g. the
* flash-to-location pin). Exempt groups bypass the budget entirely; keep them
* to a handful of markers.
*/
exempt?: boolean;
}
export interface GlobeMarkerBudget {
/**
* Ceiling per GROUP, not per layer key. A layer that supplies several feeds
* (`military` = flights + vessels + clusters) is budgeted once per feed, so
* its rendered total can reach this many times its feed count β bounded, as
* always, by `total`.
*/
perLayer: number;
/** Ceiling across all non-exempt layers combined. */
total: number;
}
export interface GlobeLayerTruncation {
shown: number;
total: number;
}
export interface GlobeMarkerSelection<T> {
markers: T[];
/** Only layers that actually lost markers appear here. */
truncated: Record<string, GlobeLayerTruncation>;
}
/**
* Mobile keeps the default view (~120 markers on production today) untouched and
* only bites when a user stacks heavy layers onto a phone.
*/
export const GLOBE_MARKER_BUDGET_MOBILE: GlobeMarkerBudget = { perLayer: 150, total: 400 };
/** Desktop's default view is 2,319 markers today, so this cuts it ~2.9x. */
export const GLOBE_MARKER_BUDGET_DESKTOP: GlobeMarkerBudget = { perLayer: 300, total: 800 };
/**
* Largest per-group cap `c <= max` for which `sum(min(len_i, c)) <= total`.
*
* Trimming the biggest groups first (max-min fairness) rather than trimming
* every group proportionally keeps small layers whole: one 1,526-marker vessel
* feed must not evict an 11-marker weather layer.
*/
function fairShareCap(sizes: readonly number[], total: number, max: number): number {
const used = (cap: number): number => sizes.reduce((sum, n) => sum + Math.min(n, cap), 0);
if (used(max) <= total) return max;
let lo = 0;
let hi = max;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (used(mid) <= total) lo = mid; else hi = mid - 1;
}
return lo;
}
export interface LatLng { lat: number; lng: number }
/**
* Ranks markers by nearness to the point the camera is looking at.
*
* This is the default for layers with no severity signal of their own, and it
* exists because the obvious alternative is indefensible: trimming a static
* reference layer by raw array order means an alphabetically-ordered facility
* list quietly loses whatever sits past the cut β on the nuclear layer that is
* Zaporizhzhia. Nearness makes the trim view-relative instead of arbitrary, so
* a user always sees the sites in the region they are actually looking at and
* the rest arrive as they rotate or zoom toward them.
*
* The score is the cosine of the central angle (higher = nearer), which orders
* identically to great-circle distance without the `acos`.
*/
export function proximityRank<T>(focus: LatLng, position: (marker: T) => LatLng): (marker: T) => number {
const rad = Math.PI / 180;
const focusLat = focus.lat * rad;
const sinFocusLat = Math.sin(focusLat);
const cosFocusLat = Math.cos(focusLat);
return (marker) => {
const { lat, lng } = position(marker);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return -Infinity;
const markerLat = lat * rad;
return sinFocusLat * Math.sin(markerLat)
+ cosFocusLat * Math.cos(markerLat) * Math.cos((lng - focus.lng) * rad);
};
}
/** Top `cap` markers by rank then tie-break, preserving the group's own order. */
function takeTop<T>(
markers: readonly T[],
cap: number,
rank?: (marker: T) => number,
tieBreak?: (marker: T) => number,
): T[] {
if (markers.length <= cap) return markers.slice();
// No rank means the caller has no opinion, so the cut falls wherever the feed
// happened to order things. Callers rendering anything a user might depend on
// should pass a rank (severity, or `proximityRank`) rather than accept this.
if (!rank) return markers.slice(0, cap);
// A non-finite score sorts last rather than poisoning the comparator with NaN.
const finite = (value: number): number => (Number.isFinite(value) ? value : -Infinity);
// Rank desc, then tie-break desc, then original index asc β compared in order
// rather than summed, so a coarse rank never has to be weighted against a fine
// tie-break (magnitude 5.1 vs 5.2 stays decisive; equal scores fall to nearness).
const keep = markers
.map((marker, index) => ({
marker,
index,
score: finite(rank(marker)),
tie: tieBreak ? finite(tieBreak(marker)) : 0,
}))
.sort((a, b) => (b.score - a.score) || (b.tie - a.tie) || (a.index - b.index))
.slice(0, cap)
.sort((a, b) => a.index - b.index);
return keep.map(entry => entry.marker);
}
/**
* Applies the per-layer cap, then the global ceiling, returning the markers to
* render plus a report of what was withheld so the UI can disclose it.
*/
export function selectGlobeMarkers<T>(
groups: readonly GlobeMarkerGroup<T>[],
budget: GlobeMarkerBudget,
): GlobeMarkerSelection<T> {
const exempt = groups.filter(group => group.exempt);
const budgeted = groups.filter(group => !group.exempt && group.markers.length > 0);
const cap = fairShareCap(
budgeted.map(group => group.markers.length),
budget.total,
budget.perLayer,
);
const markers: T[] = [];
// Totalled across every group sharing a layer key, so a layer whose vessel
// feed is trimmed still reports its untrimmed flights in the same figure.
const perLayer = new Map<string, GlobeLayerTruncation>();
for (const group of budgeted) {
const kept = takeTop(group.markers, cap, group.rank, group.tieBreak);
const running = perLayer.get(group.layer) ?? { shown: 0, total: 0 };
running.shown += kept.length;
running.total += group.markers.length;
perLayer.set(group.layer, running);
markers.push(...kept);
}
for (const group of exempt) {
markers.push(...group.markers);
}
const truncated: Record<string, GlobeLayerTruncation> = {};
for (const [layer, counts] of perLayer) {
if (counts.shown < counts.total) truncated[layer] = counts;
}
return { markers, truncated };
}
|