feat(mobile): add the phone-tuned Cesium globe engine

createGlobe() implements the Globe API in mobile/DESIGN.md: Esri day
imagery blended with NASA Black Marble night lights across the
terminator, Google 3D tiles below 25 km when keyed, a single-pass
grade with half-res bloom and lenses, an adaptive quality governor,
live layers on the repo's /api routes and parsers, time scrubbing,
picking, AR contact queries and live situation cards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 05:27:33 +00:00
1 parent 35ffcedcad
commit a8b7680c7a
19 files changed
+5816

No files matched your search

+915
View File
@@ -0,0 +1,915 @@
/**
* God's Eye mobile globe engine: createGlobe(container, options) → Globe API
* (mobile/DESIGN.md §10).
*
* Cesium tuned for phones: no widgets, request-render mode (0 fps at rest),
* an adaptive pixel-ratio governor, a single-full-res-pass post chain, and
* live layers drawn with batched billboards and polylines (never Entities,
* never Cesium labels).
*/
import * as Cesium from 'cesium';
import { createImagery } from './imagery.js';
import { createPostChain, LENSES } from './post.js';
import { createGovernor } from './quality.js';
import { buildLayers, FAMILIES } from './layers/catalog.js';
import { buildSituations } from './situations.js';
import { createContactIndex } from './contacts.js';
import { glyph } from './glyphs.js';
import { subsolarPoint } from './geo.js';
import { AIRPORTS } from './places.js';
import {
createCctvFeed,
createCyclonesFeed,
createEonetFeed,
createFiresFeed,
createFlightsFeed,
createFloodFeed,
createLaunchesFeed,
createMilitaryFeed,
createQuakesFeed,
createTleFeed,
createVesselsFeed,
} from './feeds.js';
import { applyModelAtmosphereWorkaround } from '../../src/app/atmosphereCompat.js';
export { LENSES, FAMILIES };
export const DEFAULT_LAYERS = Object.freeze([
'flights',
'iss',
'earthquakes',
'cyclones',
'fires',
'floods',
'launches',
]);
const LABEL_PRIORITY = [
'squawk',
'iss',
'cyclones',
'launches',
'military',
'earthquakes',
'floods',
'volcanoes',
'helicopters',
'flights',
'ga',
'vessels',
'tankers',
'ports',
'satellites',
'fires',
'cctv',
'starlink',
];
const PLANETARY_LABELS = new Set([
'squawk',
'iss',
'cyclones',
'launches',
'earthquakes',
'floods',
]);
const DATA_CREDITS = {
flights: 'Flights: OpenSky Network (adsb.lol regional fallback)',
military: 'Military aircraft: adsb.lol (ODbL)',
helicopters: 'Aircraft: OpenSky Network, adsb.lol',
ga: 'Aircraft: OpenSky Network',
squawk: 'Aircraft: OpenSky Network, adsb.lol',
vessels: 'AIS: AISStream.io',
tankers: 'AIS: AISStream.io',
satellites: 'Orbits: CelesTrak GP data, SGP4 via satellite.js',
iss: 'Orbits: CelesTrak GP data',
starlink: 'Orbits: CelesTrak GP data',
launches: 'Launches: The Space Devs · Launch Library 2',
earthquakes: 'Earthquakes: U.S. Geological Survey',
fires: 'Fires: NASA FIRMS / NASA EONET',
cyclones: 'Cyclones: NOAA NHC / CPHC, NASA EONET',
volcanoes: 'Volcanoes: NASA EONET',
floods:
'Bhote Koshi: GeoPera river centerline (CC BY-NC 4.0), Vantor (Maxar) Open Data',
cctv: 'Cameras: public agency traffic camera feeds (see per-camera provider)',
};
function viewerOptions(creditContainer, options) {
return {
animation: false,
baseLayer: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
navigationHelpButton: false,
sceneModePicker: false,
selectionIndicator: false,
timeline: false,
vrButton: false,
creditContainer,
scene3DOnly: true,
requestRenderMode: true,
maximumRenderTimeChange: 60,
msaaSamples: 1,
orderIndependentTranslucency: false,
shadows: false,
useBrowserRecommendedResolution: true,
contextOptions: {
webgl: {
alpha: false,
antialias: false,
depth: true,
stencil: false,
powerPreference: 'high-performance',
preserveDrawingBuffer: Boolean(options.preserveDrawingBuffer),
},
},
};
}
function tuneScene(viewer) {
const scene = viewer.scene;
applyModelAtmosphereWorkaround(scene);
scene.backgroundColor = Cesium.Color.fromCssColorString('#05070B');
scene.highDynamicRange = false;
scene.fog.enabled = true;
scene.fog.density = 2.0e-4;
scene.fog.screenSpaceErrorFactor = 4;
scene.sun.show = true;
// Cesium's default sun (intensity 2) clips bright deserts and ice.
scene.light = new Cesium.SunLight({ intensity: 1.4 });
scene.moon.show = true;
scene.skyBox.show = true;
const sky = scene.skyAtmosphere;
sky.show = true;
sky.atmosphereLightIntensity = 22;
sky.saturationShift = 0.05;
sky.brightnessShift = -0.02;
sky.hueShift = -0.01;
sky.perFragmentAtmosphere = false; // per-vertex is plenty on a phone
const globe = scene.globe;
globe.showGroundAtmosphere = true;
globe.atmosphereLightIntensity = 9;
globe.atmosphereBrightnessShift = 0.0;
globe.atmosphereSaturationShift = 0.08;
globe.showWaterEffect = false;
globe.depthTestAgainstTerrain = false;
globe.preloadSiblings = false;
globe.loadingDescendantLimit = 12;
globe.tileCacheSize = 120;
globe.showSkirts = true;
const ssc = scene.screenSpaceCameraController;
ssc.inertiaSpin = 0.93;
ssc.inertiaTranslate = 0.92;
ssc.inertiaZoom = 0.82;
ssc.minimumZoomDistance = 120;
ssc.maximumZoomDistance = 45_000_000;
ssc.bounceAnimationTime = 2.2;
ssc.enableCollisionDetection = true;
ssc.maximumMovementRatio = 0.1;
}
/**
* @param {HTMLElement} container
* @param {{quality?:'auto'|'low'|'high', layers?:string[], lens?:string,
* terrain?:boolean, observer?:{lat:number,lon:number},
* googleApiKey?:string, cesiumToken?:string, preserveDrawingBuffer?:boolean}} [options]
*/
export async function createGlobe(container, options = {}) {
if (!container) throw new TypeError('createGlobe needs a container element');
const opts = {
quality: 'auto',
lens: 'cinematic',
terrain: true,
...options,
};
// ── Host & viewer ────────────────────────────────────────────────
if (getComputedStyle(container).position === 'static')
container.style.position = 'relative';
const host = document.createElement('div');
host.className = 'gev-globe';
host.style.cssText =
'position:absolute;inset:0;overflow:hidden;touch-action:none;background:#05070B;';
container.appendChild(host);
const creditContainer = document.createElement('div');
creditContainer.style.display = 'none';
const viewer = new Cesium.Viewer(host, viewerOptions(creditContainer, opts));
const scene = viewer.scene;
const camera = scene.camera;
const canvas = viewer.canvas;
canvas.style.outline = 'none';
tuneScene(viewer);
// ── Events ───────────────────────────────────────────────────────
const listeners = new Map();
const emit = (event, payload) => {
for (const fn of listeners.get(event) || []) {
try {
fn(payload);
} catch (error) {
console.error(`[globe] ${event} listener`, error);
}
}
};
const requestRender = () => {
if (!viewer.isDestroyed()) scene.requestRender();
};
// ── Clock ────────────────────────────────────────────────────────
const clock = viewer.clock;
let live = true;
clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK;
clock.shouldAnimate = true;
clock.currentTime = Cesium.JulianDate.now();
const timeMs = () => Cesium.JulianDate.toDate(clock.currentTime).getTime();
// ── Surface, post, quality ───────────────────────────────────────
const env = import.meta.env || {};
const imagery = createImagery(viewer, {
terrain: opts.terrain,
google: {
apiKey: opts.googleApiKey ?? env.GOOGLE_MAPS_API_KEY,
ionToken: opts.cesiumToken ?? env.CESIUM_ION_TOKEN,
cacheBytes:
opts.quality === 'low' ? 128 * 1024 * 1024 : 256 * 1024 * 1024,
},
});
const post = createPostChain(scene);
const governor = createGovernor(viewer, {
quality: opts.quality,
post,
onChange: (q) => emit('quality', q),
});
// ── Feeds & layers ───────────────────────────────────────────────
let active = true;
let lastView = null;
const eonet = createEonetFeed();
const feeds = {
flights: createFlightsFeed(() => lastView?.center),
military: createMilitaryFeed(),
vessels: createVesselsFeed(),
visual: createTleFeed('visual'),
stations: createTleFeed('stations'),
gps: createTleFeed('gps-ops'),
starlink: createTleFeed('starlink'),
launches: createLaunchesFeed(),
quakes: createQuakesFeed(),
eonet,
fires: createFiresFeed(eonet),
cyclones: createCyclonesFeed(eonet),
cctv: createCctvFeed(),
flood: createFloodFeed(),
};
let layersTimer = null;
const ctx = {
viewer,
scene,
requestRender,
active: () => active && document.visibilityState !== 'hidden',
timeMs,
isLive: () => live,
layersChanged() {
if (layersTimer) return;
layersTimer = setTimeout(() => {
layersTimer = null;
emit('layers', api.layers());
}, 60);
},
};
const layers = buildLayers(ctx, feeds);
// ── Selection visuals ────────────────────────────────────────────
const selectionRoot = scene.primitives.add(new Cesium.PrimitiveCollection());
const ringCollection = selectionRoot.add(new Cesium.BillboardCollection());
const trailCollection = selectionRoot.add(new Cesium.PolylineCollection());
const ringGlyph = glyph('ring');
const ring = ringCollection.add({
image: ringGlyph.image,
imageId: ringGlyph.imageId,
show: false,
scale: 1,
color: Cesium.Color.fromCssColorString('#FFD166'),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
let selected = null; // { layer, key, pick }
function drawSelection() {
if (!selected) {
ring.show = false;
trailCollection.removeAll();
return;
}
const layer = layers.get(selected.layer);
const where = layer?.locate?.(selected.key);
if (!where) {
ring.show = false;
return;
}
ring.position = Cesium.Cartesian3.fromDegrees(
where[0],
where[1],
where[2] || 0,
);
ring.show = true;
trailCollection.removeAll();
const trail = layer.trail?.(selected.key) || [];
if (trail.length > 1) {
trailCollection.add({
positions: trail.map(([lo, la, h]) =>
Cesium.Cartesian3.fromDegrees(lo, la, h || 0),
),
width: 4,
material: Cesium.Material.fromType('PolylineGlow', {
color: Cesium.Color.fromCssColorString('#FFD166').withAlpha(0.8),
glowPower: 0.18,
taperPower: 0.6,
}),
});
}
}
function resolveSelectable(id) {
if (id == null) return null;
if (typeof id === 'object' && id.layer)
return { layer: id.layer, key: String(id.id ?? id.key) };
const s = String(id);
const colon = s.indexOf(':');
if (colon > 0 && layers.has(s.slice(0, colon)))
return { layer: s.slice(0, colon), key: s.slice(colon + 1) };
for (const layer of layers.values()) {
if (!layer.on) continue;
if (layer.pick?.(s)) return { layer: layer.id, key: s };
}
return null;
}
// ── Camera → LOD, Google 3D, 'camera' events ─────────────────────
const scratchCarto = new Cesium.Cartographic();
function computeView() {
const heightM = camera.positionCartographic.height;
let center = null;
const mid = new Cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
);
const hit = camera.pickEllipsoid(mid, scene.globe.ellipsoid);
if (hit) {
Cesium.Cartographic.fromCartesian(
hit,
scene.globe.ellipsoid,
scratchCarto,
);
center = {
lon: Cesium.Math.toDegrees(scratchCarto.longitude),
lat: Cesium.Math.toDegrees(scratchCarto.latitude),
};
} else {
const c = camera.positionCartographic;
center = {
lon: Cesium.Math.toDegrees(c.longitude),
lat: Cesium.Math.toDegrees(c.latitude),
};
}
let rect = null;
if (heightM < 8_000_000) {
const r = camera.computeViewRectangle(scene.globe.ellipsoid);
if (r)
rect = {
west: Cesium.Math.toDegrees(r.west),
south: Cesium.Math.toDegrees(r.south),
east: Cesium.Math.toDegrees(r.east),
north: Cesium.Math.toDegrees(r.north),
};
}
return { heightM, center, rect };
}
let viewTimer = null;
function updateView() {
viewTimer = null;
lastView = computeView();
imagery.onHeight(lastView.heightM);
for (const layer of layers.values()) if (layer.on) layer.onView(lastView);
emit('camera', {
lon: lastView.center.lon,
lat: lastView.center.lat,
height: lastView.heightM,
heading: Cesium.Math.toDegrees(camera.heading),
pitch: Cesium.Math.toDegrees(camera.pitch),
});
requestRender();
}
function scheduleView() {
if (!viewTimer) viewTimer = setTimeout(updateView, 180);
}
camera.percentageChanged = 0.02;
const removeCameraChanged = camera.changed.addEventListener(scheduleView);
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
clearTimeout(viewTimer);
updateView();
});
// Layers re-read the view whenever they redraw.
const refreshLayerViews = () => {
if (!lastView) lastView = computeView();
for (const layer of layers.values()) if (layer.on) layer.onView(lastView);
};
for (const layer of layers.values()) {
if (!layer.available) continue;
const original = layer.refresh.bind(layer);
layer.refresh = async () => {
await original();
if (lastView) layer.onView(lastView);
if (selected?.layer === layer.id) drawSelection();
};
}
// ── Picking ──────────────────────────────────────────────────────
const handler = new Cesium.ScreenSpaceEventHandler(canvas);
handler.setInputAction((movement) => {
const picked = scene.pick(movement.position, 30, 30);
const id = picked?.id;
if (id && typeof id === 'object' && id.layer && layers.has(id.layer)) {
api.select({ layer: id.layer, id: id.key });
emit('pick', selected?.pick ?? null);
} else {
if (selected) api.select(null);
emit('pick', null);
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// ── 1 Hz motion tick (dead reckoning, satellites, selection) ─────
const tick = setInterval(() => {
if (!ctx.active()) return;
const now = Date.now();
const clockMs = timeMs();
let moved = false;
for (const layer of layers.values())
if (layer.on && layer.onTick(now, live, clockMs)) moved = true;
if (selected) {
drawSelection();
moved = true;
}
if (moved) requestRender();
}, 1000);
// ── Resize / visibility ──────────────────────────────────────────
const resizeObserver = new ResizeObserver(() => {
viewer.resize();
scheduleView();
requestRender();
});
resizeObserver.observe(container);
const onVisibility = () => {
if (document.visibilityState === 'visible' && active) {
if (live) clock.currentTime = Cesium.JulianDate.now();
for (const layer of layers.values()) if (layer.on) layer.kick();
requestRender();
}
};
document.addEventListener('visibilitychange', onVisibility);
// ── Time ─────────────────────────────────────────────────────────
let timeTimer = null;
function applyTime() {
timeTimer = null;
const ms = timeMs();
for (const layer of layers.values()) if (layer.on) layer.onTime(ms, live);
if (selected) drawSelection();
requestRender();
}
// ── Camera helpers ───────────────────────────────────────────────
function groundHeight(lon, lat) {
const h = scene.globe.getHeight(Cesium.Cartographic.fromDegrees(lon, lat));
return Number.isFinite(h) ? Math.max(0, h) : 0;
}
function flyTo({
lon,
lat,
height = 2_000_000,
heading = 0,
pitch = -90,
duration,
targetHeight,
} = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat))
return Promise.reject(new TypeError('flyTo needs lon and lat'));
const reduceMotion = globalThis.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
const seconds = reduceMotion ? 0 : (duration ?? undefined);
const h = Cesium.Math.toRadians(heading);
const p = Cesium.Math.toRadians(Math.max(-90, Math.min(-5, pitch)));
return new Promise((resolve) => {
const done = () => {
updateView();
resolve();
};
if (pitch <= -89.5) {
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
orientation: { heading: h, pitch: p, roll: 0 },
duration: seconds,
complete: done,
cancel: done,
});
return;
}
const ground = Number.isFinite(targetHeight)
? targetHeight
: groundHeight(lon, lat);
const range = height / Math.sin(-p);
camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(lon, lat, ground),
0,
),
{
offset: new Cesium.HeadingPitchRange(h, p, range),
duration: seconds,
complete: done,
cancel: done,
},
);
requestRender();
});
}
function homeView() {
// Centre just past a terminator so day, dusk and city lights share the
// first frame: of the two terminators, the one whose night side holds
// more of the world's busiest airports (a cheap proxy for city lights).
const sun = subsolarPoint(timeMs());
const score = (centerLon) => {
let n = 0;
for (const ap of AIRPORTS) {
const fromCenter = Math.abs(((ap.lon - centerLon + 540) % 360) - 180);
const fromSun = Math.abs(((ap.lon - sun.lon + 540) % 360) - 180);
if (fromCenter < 70 && fromSun > 95) n++;
}
return n;
};
const dusk = ((sun.lon + 62 + 540) % 360) - 180;
const dawn = ((sun.lon - 62 + 540) % 360) - 180;
const lon = score(dusk) >= score(dawn) ? dusk : dawn;
// Fit the disc to ~92 % of the narrow screen dimension, read from the
// live projection (Cesium applies `fov` to the narrower side).
const pm = camera.frustum.projectionMatrix;
const tanHalf = Math.min(1 / pm[0], 1 / pm[5]);
const radius = Math.atan(0.92 * tanHalf);
const height = 6_371_000 / Math.sin(radius) - 6_371_000;
return {
lon,
lat: Math.max(-20, Math.min(30, sun.lat + 15)),
height: Math.max(9_000_000, Math.min(30_000_000, height)),
heading: 0,
pitch: -90,
};
}
// ── API ──────────────────────────────────────────────────────────
const contactsNearImpl = createContactIndex(feeds, { active: ctx.active });
const api = {
flyTo,
home({ duration } = {}) {
return flyTo({ ...homeView(), duration });
},
layers() {
return [...layers.values()].map((layer) => layer.info());
},
setLayer(id, on) {
const layer = layers.get(id);
if (!layer) throw new RangeError(`Unknown layer: ${id}`);
if (!layer.available) return false;
layer.setOn(on);
if (on) {
if (lastView) layer.onView(lastView);
layer.onTime(timeMs(), live);
} else if (selected?.layer === id) api.select(null);
return true;
},
setFamily(family, on) {
for (const layer of layers.values())
if (layer.family === family && layer.available)
api.setLayer(layer.id, on);
},
setTime(date) {
if (date == null) {
live = true;
clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK;
clock.shouldAnimate = true;
clock.currentTime = Cesium.JulianDate.now();
applyTime();
emit('time', { live: true, time: new Date() });
return;
}
const ms = date instanceof Date ? date.getTime() : Number(date);
if (!Number.isFinite(ms))
throw new TypeError('setTime needs a Date, epoch ms or null');
live = false;
clock.shouldAnimate = false;
clock.clockStep = Cesium.ClockStep.TICK_DEPENDENT;
clock.currentTime = Cesium.JulianDate.fromDate(new Date(ms));
// Coalesce a fast scrub into ~20 layer updates a second.
if (!timeTimer) timeTimer = setTimeout(applyTime, 50);
requestRender();
emit('time', { live: false, time: new Date(ms) });
},
time() {
return { live, time: new Date(timeMs()) };
},
timeWindow() {
const now = Date.now();
return {
start: new Date(now - 72 * 3600_000),
end: new Date(now + 120 * 3600_000),
now: new Date(now),
};
},
setLens(id) {
post.setLens(id);
imagery.setNightMode(id === 'night' || id === 'nvg');
emit('lens', { id });
},
lens() {
return post.lens;
},
async focus(situation, { solo = false } = {}) {
if (!situation) return;
if (solo)
for (const layer of layers.values())
if (layer.on && !situation.layers?.includes(layer.id))
layer.setOn(false);
for (const id of situation.layers || [])
if (layers.has(id)) api.setLayer(id, true);
if (Number.isFinite(situation.time?.at))
api.setTime(new Date(situation.time.at));
else if (!live) api.setTime(null);
const cam = situation.camera || {};
await flyTo({
lon: cam.lon ?? situation.lon,
lat: cam.lat ?? situation.lat,
height: cam.height ?? 200_000,
heading: cam.heading ?? 0,
pitch: cam.pitch ?? -60,
duration: cam.duration,
targetHeight: cam.targetHeight,
});
},
select(id) {
const target = resolveSelectable(id);
if (!target) {
selected = null;
drawSelection();
requestRender();
return null;
}
const pick = layers.get(target.layer).pick(target.key);
if (!pick) {
selected = null;
drawSelection();
return null;
}
selected = { ...target, pick };
drawSelection();
requestRender();
return pick;
},
on(event, fn) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event).add(fn);
return () => api.off(event, fn);
},
off(event, fn) {
listeners.get(event)?.delete(fn);
},
pause() {
if (!active) return;
active = false;
viewer.useDefaultRenderLoop = false;
},
resume() {
if (active) return;
active = true;
viewer.useDefaultRenderLoop = true;
if (live) clock.currentTime = Cesium.JulianDate.now();
for (const layer of layers.values()) if (layer.on) layer.kick();
scheduleView();
requestRender();
},
contactsNear(lon, lat, km = 50) {
return contactsNearImpl(lon, lat, km, { timeMs: timeMs() }).map(
({
kind,
id,
title,
lon: lo,
lat: la,
altM,
headingDeg,
speedMps,
distanceKm,
}) => ({
kind,
id,
title,
lon: lo,
lat: la,
altM,
headingDeg,
speedMps,
distanceKm,
}),
);
},
situations(extra = {}) {
return buildSituations(
{ feeds, timeMs },
{ observer: extra.observer ?? opts.observer },
);
},
credits() {
const list = imagery.credits();
for (const layer of layers.values())
if (layer.on && DATA_CREDITS[layer.id])
list.push({
id: `data-${layer.id}`,
text: DATA_CREDITS[layer.id],
html: DATA_CREDITS[layer.id],
});
return list;
},
/** Screen position (CSS px) of a point, or null when behind the globe. */
project(lon, lat, altM = 0) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, altM);
const occluder = new Cesium.EllipsoidalOccluder(
scene.globe.ellipsoid,
camera.positionWC,
);
if (!occluder.isPointVisible(position)) return null;
const p = scene.cartesianToCanvasCoordinates(position);
return p ? { x: p.x, y: p.y } : null;
},
/**
* Up to `max` contacts worth an HTML label right now (DESIGN.md §5: no
* Cesium labels, ≤ 40 visible): on-screen glyphs, selected first, then
* by layer priority and distance from the screen centre.
*/
labels(max = 40) {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const heightM = camera.positionCartographic.height;
const planetary = heightM > 3_000_000;
const occluder = new Cesium.EllipsoidalOccluder(
scene.globe.ellipsoid,
camera.positionWC,
);
const scratch = new Cesium.Cartesian2();
const candidates = [];
for (const layer of layers.values()) {
const glyphs =
layer.on && layer.renderer?.glyphMode && layer.renderer.glyphs;
if (!glyphs) continue;
// Semantic zoom for words too: from orbit only the headline layers.
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
const rank = LABEL_PRIORITY.indexOf(layer.id);
for (let i = 0; i < glyphs.length; i++) {
const bb = glyphs.get(i);
if (!occluder.isPointVisible(bb.position)) continue;
const p = scene.cartesianToCanvasCoordinates(bb.position, scratch);
if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue;
const isSelected =
selected?.layer === layer.id && selected?.key === bb.id.key;
const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h);
candidates.push({
layer,
key: bb.id.key,
x: p.x,
y: p.y,
score: (isSelected ? -100 : 0) + (rank < 0 ? 20 : rank) + d * 4,
});
}
}
candidates.sort((a, b) => a.score - b.score);
// Greedy placement: a label never overlaps one already placed.
const placed = [];
const out = [];
for (const c of candidates) {
if (out.length >= max) break;
const pick = c.layer.pick(c.key);
if (!pick) continue;
if (
planetary &&
pick.kind === 'earthquake' &&
!(pick.meta?.magnitude >= 5.5)
)
continue;
const box = {
x0: c.x + 6,
x1: c.x + 20 + pick.title.length * 6.2,
y0: c.y - 10,
y1: c.y + 10,
};
if (
placed.some(
(b) =>
box.x0 < b.x1 && box.x1 > b.x0 && box.y0 < b.y1 && box.y1 > b.y0,
)
)
continue;
placed.push(box);
out.push({
id: pick.id,
layer: c.layer.id,
kind: pick.kind,
title: pick.title,
x: c.x,
y: c.y,
});
}
return out;
},
quality() {
return governor.state();
},
setQuality(q) {
governor.setQuality(q);
},
requestRender,
get viewer() {
return viewer;
},
destroy() {
clearInterval(tick);
clearTimeout(viewTimer);
clearTimeout(layersTimer);
clearTimeout(timeTimer);
resizeObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
removeCameraChanged();
removeMoveEnd();
handler.destroy();
for (const layer of layers.values()) layer.destroy();
governor.destroy();
post.destroy();
viewer.destroy();
host.remove();
listeners.clear();
},
};
// ── Boot ─────────────────────────────────────────────────────────
const home = homeView();
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(home.lon, home.lat, home.height),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
});
api.setLens(opts.lens);
lastView = computeView();
for (const id of opts.layers ?? DEFAULT_LAYERS)
if (layers.has(id)) api.setLayer(id, true);
refreshLayerViews();
requestRender();
// Listeners attach after this resolves: hand them the starting state.
setTimeout(() => {
emit('quality', governor.state());
emit('layers', api.layers());
}, 0);
return api;
}