Merge origin/main into fix/antimeridian-traffic-bounds

This commit is contained in:
Sameh Khamis committed 2026-09-20 17:09:53 -07:00
commit 2f16e34cad
724 files changed
+193369 -25564

No files matched your search

File diff suppressed because it is too large. Load diff
+16
View File
@@ -710,3 +710,19 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
assert.equal(calls.add, 0);
assert.equal(engine.count(), 0);
});
test('only an explicit navigation request permits resolving distant annotation targets', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = throwingRendererHarness();
renderer.destroy = () => {};
const received = [];
const engine = createAnnotationEngine({ viewer: {}, renderer,
resolveTarget: async (options) => { received.push(options); return null; },
});
await engine.annotate([{ type: 'point', target: 'Remote landmark' }]);
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true });
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
-213
View File
@@ -1,213 +0,0 @@
/**
* @module annotationGeoJson
* @description Pure conversion between the runtime annotation model and a GeoJSON
* FeatureCollection — a durable, testable interchange format for saving, exporting,
* importing, sharing, and fixture-testing annotations. It is NOT a renderer input:
* the Cesium/SVG renderers keep consuming runtime annotation objects. This module is
* deliberately Cesium-free and dependency-free so it can run in tests and any context.
*
* Mapping (one Feature per mark; project fields namespaced `gev:`):
* pin | highlight | label -> Point (anchor)
* arrow -> LineString (anchor -> to)
* route -> LineString (the routed path)
* area (with a ring) -> Polygon (the footprint ring; centroid in `gev:anchor`)
* area (no ring) -> Point (degenerate area; renders as a marker)
*
* Coordinates are GeoJSON positions `[lon, lat]`, or `[lon, lat, height]` when a height
* is present (anchor / route / arrow). Area ring vertices are 2D `[lon, lat]` (the runtime
* ring has no per-vertex height). Conversions are LOSSLESS for the semantic fields; transient
* render state (alpha/bornAt/createdAt/expiring) is intentionally NOT carried — an importer
* re-initializes it.
*/
const VALID_TYPES = new Set(['pin', 'highlight', 'label', 'arrow', 'route', 'area']);
/** {lon,lat,height?} -> GeoJSON position, or null if not a finite lon/lat. */
function toPosition(p) {
if (!p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat)) return null;
return Number.isFinite(p.height) ? [p.lon, p.lat, p.height] : [p.lon, p.lat];
}
/** GeoJSON position -> {lon,lat,height?}, or null if malformed. */
function fromPosition(c) {
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
return Number.isFinite(c[2]) ? { lon: c[0], lat: c[1], height: c[2] } : { lon: c[0], lat: c[1] };
}
/** Mean of a runtime ring (`[[lon,lat],...]`), used as a fallback area anchor on import. */
function ringCentroid(ring) {
if (!Array.isArray(ring) || ring.length === 0) return null;
let sx = 0;
let sy = 0;
for (const [lon, lat] of ring) { sx += lon; sy += lat; }
return { lon: sx / ring.length, lat: sy / ring.length };
}
/**
* Convert one runtime annotation object to a GeoJSON Feature.
* @param {object} anno - A runtime annotation (as produced by the engine).
* @returns {object|null} A GeoJSON Feature, or null if `anno` can't form a valid geometry.
*/
export function annotationToFeature(anno) {
if (!anno || typeof anno !== 'object' || !VALID_TYPES.has(anno.type)) return null;
const type = anno.type;
const properties = {
'gev:type': type,
'gev:id': anno.id ?? null,
'gev:label': anno.label ?? null,
'gev:color': anno.color ?? null,
'gev:ttlMs': anno.ttlMs ?? null,
};
let geometry = null;
if (type === 'route') {
const coords = (Array.isArray(anno.path) ? anno.path : []).map(toPosition).filter(Boolean);
if (coords.length < 2) return null;
geometry = { type: 'LineString', coordinates: coords };
properties['gev:mode'] = anno.mode ?? null;
properties['gev:distanceM'] = anno.distanceM ?? null;
properties['gev:durationS'] = anno.durationS ?? null;
properties['gev:fallback'] = Boolean(anno.fallback);
} else if (type === 'arrow') {
const from = toPosition(anno.anchor);
const to = toPosition(anno.to);
if (!from || !to) return null;
geometry = { type: 'LineString', coordinates: [from, to] };
} else if (type === 'area' && Array.isArray(anno.ring) && anno.ring.length >= 3) {
const ring = [];
for (const pair of anno.ring) {
if (!Array.isArray(pair) || !Number.isFinite(pair[0]) || !Number.isFinite(pair[1])) return null;
ring.push([pair[0], pair[1]]);
}
// GeoJSON linear rings must be explicitly closed (first === last).
const first = ring[0];
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
geometry = { type: 'Polygon', coordinates: [ring] };
properties['gev:footprintKind'] = anno.footprintKind ?? null;
properties['gev:buildingHeight'] = anno.buildingHeight ?? null;
properties['gev:synthesized'] = Boolean(anno.synthesized);
const anchor = toPosition(anno.anchor);
if (anchor) properties['gev:anchor'] = anchor; // preserve the exact centroid, don't recompute
} else {
// pin / highlight / label, or a degenerate `area` with no ring -> a Point at the anchor.
const anchor = toPosition(anno.anchor);
if (!anchor) return null;
geometry = { type: 'Point', coordinates: anchor };
if (type === 'area') {
properties['gev:footprintKind'] = anno.footprintKind ?? null;
properties['gev:synthesized'] = Boolean(anno.synthesized);
}
}
return { type: 'Feature', geometry, properties };
}
/**
* Convert a GeoJSON Feature back to a runtime annotation object (semantic fields only —
* the importer adds render state). Fails CLOSED: returns null for any malformed feature,
* unknown `gev:type`, or geometry that doesn't match the declared type.
* @param {object} feature - A GeoJSON Feature produced by {@link annotationToFeature}.
* @returns {object|null}
*/
export function featureToAnnotation(feature) {
if (!feature || feature.type !== 'Feature' || !feature.geometry || !feature.properties) return null;
const g = feature.geometry;
const p = feature.properties;
const type = p['gev:type'];
if (!VALID_TYPES.has(type)) return null;
const base = {
type,
id: p['gev:id'] ?? null,
label: p['gev:label'] ?? null,
color: p['gev:color'] ?? 'primary',
ttlMs: p['gev:ttlMs'] ?? null,
};
if (type === 'route') {
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length < 2) return null;
const path = g.coordinates.map(fromPosition);
if (path.some((x) => !x)) return null;
return {
...base,
anchor: path[0],
to: null,
ring: null,
path,
mode: p['gev:mode'] ?? null,
distanceM: p['gev:distanceM'] ?? null,
durationS: p['gev:durationS'] ?? null,
fallback: Boolean(p['gev:fallback']),
};
}
if (type === 'arrow') {
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length !== 2) return null;
const from = fromPosition(g.coordinates[0]);
const to = fromPosition(g.coordinates[1]);
if (!from || !to) return null;
return { ...base, anchor: from, to, ring: null };
}
if (g.type === 'Polygon') {
if (type !== 'area' || !Array.isArray(g.coordinates) || !Array.isArray(g.coordinates[0])) return null;
const raw = g.coordinates[0];
if (raw.length < 4) return null; // a closed triangle is the minimum (4 positions)
const ring = [];
for (const c of raw) {
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
ring.push([c[0], c[1]]);
}
// Drop the GeoJSON closing duplicate to match the runtime ring (not explicitly closed).
const f = ring[0];
const l = ring[ring.length - 1];
if (ring.length > 3 && f[0] === l[0] && f[1] === l[1]) ring.pop();
const anchor = fromPosition(p['gev:anchor']) || ringCentroid(ring);
return {
...base,
anchor,
to: null,
ring,
footprintKind: p['gev:footprintKind'] ?? null,
buildingHeight: p['gev:buildingHeight'] ?? null,
synthesized: Boolean(p['gev:synthesized']),
};
}
// Point geometry — pin / highlight / label, or a degenerate area.
if (g.type !== 'Point') return null;
const anchor = fromPosition(g.coordinates);
if (!anchor) return null;
const out = { ...base, anchor, to: null, ring: null };
if (type === 'area') {
out.footprintKind = p['gev:footprintKind'] ?? null;
out.buildingHeight = null;
out.synthesized = Boolean(p['gev:synthesized']);
}
return out;
}
/**
* Convert an array of runtime annotations to a GeoJSON FeatureCollection.
* Marks that can't form a valid geometry are dropped (not thrown).
* @param {Array<object>} annotations
* @returns {{type:'FeatureCollection', features: object[]}}
*/
export function annotationsToFeatureCollection(annotations) {
const features = (Array.isArray(annotations) ? annotations : [])
.map(annotationToFeature)
.filter(Boolean);
return { type: 'FeatureCollection', features };
}
/**
* Convert a GeoJSON FeatureCollection back to runtime annotation objects.
* Malformed features are skipped (fail-closed per feature), never throwing.
* @param {object} collection
* @returns {Array<object>}
*/
export function featureCollectionToAnnotations(collection) {
if (!collection || collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) return [];
return collection.features.map(featureToAnnotation).filter(Boolean);
}
-118
View File
@@ -1,118 +0,0 @@
// Pure round-trip + fail-closed tests for the annotation <-> GeoJSON interchange.
// Run with: npm test (node --test). No framework, no Cesium, no browser.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
annotationToFeature,
featureToAnnotation,
annotationsToFeatureCollection,
featureCollectionToAnnotations,
} from './annotationGeoJson.js';
// A round-trip preserves the semantic fields (transient render state is intentionally not carried).
const roundTrip = (anno) => featureToAnnotation(annotationToFeature(anno));
test('point (pin/highlight/label) round-trips, height preserved', () => {
const pin = { type: 'pin', id: 'anno-1', label: 'ILM', color: 'primary', ttlMs: null,
anchor: { lon: -77.9, lat: 34.27, height: 12 }, to: null, ring: null };
const f = annotationToFeature(pin);
assert.equal(f.geometry.type, 'Point');
assert.deepEqual(f.geometry.coordinates, [-77.9, 34.27, 12]);
assert.equal(f.properties['gev:type'], 'pin');
assert.deepEqual(roundTrip(pin), pin);
const hl = { type: 'highlight', id: 'anno-2', label: 'spot', color: 'amber', ttlMs: 30000,
anchor: { lon: 2.29, lat: 48.85 }, to: null, ring: null };
assert.deepEqual(roundTrip(hl), hl); // no height -> 2D position
});
test('area polygon round-trips: ring closed in GeoJSON, un-closed on import; centroid preserved', () => {
const area = { type: 'area', id: 'anno-3', label: 'Marina', color: 'primary', ttlMs: null,
anchor: { lon: -122.434, lat: 37.804 }, to: null,
ring: [[-122.44, 37.80], [-122.43, 37.81], [-122.42, 37.80], [-122.43, 37.79]],
footprintKind: 'area', buildingHeight: null, synthesized: false };
const f = annotationToFeature(area);
assert.equal(f.geometry.type, 'Polygon');
const gjRing = f.geometry.coordinates[0];
assert.equal(gjRing.length, 5); // 4 vertices + explicit closing point
assert.deepEqual(gjRing[0], gjRing[gjRing.length - 1]); // closed
assert.deepEqual(f.properties['gev:anchor'], [-122.434, 37.804]);
assert.deepEqual(roundTrip(area), area); // ring back to 4, anchor from gev:anchor
});
test('synthesized + building props are preserved', () => {
const synth = { type: 'area', id: 'anno-4', label: 'around', color: 'primary', ttlMs: null,
anchor: { lon: -97.74, lat: 30.27 }, to: null,
ring: [[-97.75, 30.27], [-97.74, 30.28], [-97.73, 30.27]],
footprintKind: 'area', buildingHeight: null, synthesized: true };
const f = annotationToFeature(synth);
assert.equal(f.properties['gev:synthesized'], true);
assert.equal(roundTrip(synth).synthesized, true);
const bldg = { type: 'area', id: 'anno-5', label: 'Pentagon', color: 'primary', ttlMs: null,
anchor: { lon: -77.056, lat: 38.871 }, to: null,
ring: [[-77.057, 38.870], [-77.055, 38.872], [-77.054, 38.870]],
footprintKind: 'building', buildingHeight: 24, synthesized: false };
assert.equal(roundTrip(bldg).footprintKind, 'building');
assert.equal(roundTrip(bldg).buildingHeight, 24);
});
test('route path round-trips with mode/distance/duration', () => {
const path = [{ lon: -122.4, lat: 37.7, height: 5 }, { lon: -122.39, lat: 37.71, height: 6 }, { lon: -122.38, lat: 37.72, height: 7 }];
const route = { type: 'route', id: 'anno-6', label: '590 m · 8 min', color: 'primary', ttlMs: null,
anchor: { lon: -122.4, lat: 37.7, height: 5 }, to: null, ring: null,
path, mode: 'foot', distanceM: 590, durationS: 480, fallback: false };
const f = annotationToFeature(route);
assert.equal(f.geometry.type, 'LineString');
assert.equal(f.geometry.coordinates.length, 3);
assert.deepEqual(roundTrip(route), route);
});
test('arrow round-trips (anchor -> to)', () => {
const arrow = { type: 'arrow', id: 'anno-7', label: '2 km', color: 'primary', ttlMs: null,
anchor: { lon: -122.43, lat: 37.80, height: 3 }, to: { lon: -122.40, lat: 37.81, height: 4 }, ring: null };
const f = annotationToFeature(arrow);
assert.equal(f.geometry.type, 'LineString');
assert.equal(f.geometry.coordinates.length, 2);
assert.deepEqual(roundTrip(arrow), arrow);
});
test('degenerate area (no ring) -> Point, round-trips', () => {
const ptArea = { type: 'area', id: 'anno-8', label: 'X', color: 'primary', ttlMs: null,
anchor: { lon: 10, lat: 20 }, to: null, ring: null, footprintKind: null, buildingHeight: null, synthesized: false };
const f = annotationToFeature(ptArea);
assert.equal(f.geometry.type, 'Point');
const back = roundTrip(ptArea);
assert.equal(back.type, 'area');
assert.equal(back.ring, null);
assert.deepEqual(back.anchor, { lon: 10, lat: 20 });
});
test('FeatureCollection round-trips a mixed set', () => {
const annos = [
{ type: 'pin', id: 'a', label: 'p', color: 'primary', ttlMs: null, anchor: { lon: 1, lat: 2 }, to: null, ring: null },
{ type: 'area', id: 'b', label: 'q', color: 'amber', ttlMs: null, anchor: { lon: 3, lat: 4 }, to: null,
ring: [[3, 4], [4, 5], [5, 4]], footprintKind: 'area', buildingHeight: null, synthesized: false },
];
const fc = annotationsToFeatureCollection(annos);
assert.equal(fc.type, 'FeatureCollection');
assert.equal(fc.features.length, 2);
assert.deepEqual(featureCollectionToAnnotations(fc), annos);
});
test('malformed input fails CLOSED (returns null / skips)', () => {
assert.equal(featureToAnnotation(null), null);
assert.equal(featureToAnnotation({}), null);
assert.equal(featureToAnnotation({ type: 'NotAFeature' }), null);
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: {} }), null); // no gev:type
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { 'gev:type': 'route' } }), null); // route needs LineString
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] }, properties: { 'gev:type': 'arrow' } }), null); // arrow needs exactly 2
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1]]] }, properties: { 'gev:type': 'area' } }), null); // <4 positions
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: ['x', 0] }, properties: { 'gev:type': 'pin' } }), null); // non-finite
// unknown type on the annotation side too
assert.equal(annotationToFeature({ type: 'blob', anchor: { lon: 0, lat: 0 } }), null);
assert.equal(annotationToFeature(null), null);
// collection helpers never throw on junk
assert.deepEqual(featureCollectionToAnnotations({ type: 'X' }), []);
assert.deepEqual(annotationsToFeatureCollection('nope'), { type: 'FeatureCollection', features: [] });
});
File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
/**
* Manual whiteboard drawing: the pure half.
*
* The voice whiteboard resolves NAMES to geometry. This module is for the other
* way in: a person clicks the vertices themselves. It holds a draw session (the
* shape being drawn and its vertices), decides when a shape is finishable, and
* turns a finished session into the SAME annotation spec the engine already
* accepts (`type: area | route | pin`, geometry supplied, `manual: true`), so a
* hand-drawn mark renders, persists, de-dups and clears exactly like a spoken
* one.
*
* A vertex is a lon/lat and nothing more. The world renderer drapes every mark
* onto the photoreal surface (`clampToGround` + `CESIUM_3D_TILE`), so there is
* no such thing as a vertex at a height here: the height under the click is
* used for the live preview and is deliberately dropped at finish.
*
* No Cesium, no DOM — importable under `node --test`. The Cesium/DOM half is
* `drawTool.js`.
*/
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
* Hard ceiling on vertices in one shape. Every vertex is a live preview entity
* and a position in the finished geometry, so a stuck mouse button or a script
* must not be able to grow one shape without limit.
*/
export const MAX_VERTICES = 512;
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
export const MIN_PATH_LENGTH_M = 1;
export const MIN_AREA_M2 = 1;
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
const s = String(shape || '').toLowerCase();
if (s === 'line' || s === 'path' || s === 'route') return 'line';
if (s === 'pin' || s === 'point' || s === 'marker') return 'pin';
return 'area';
}
/** @param {string} [shape] @returns {{shape: 'area'|'line'|'pin', vertices: Array<{lon:number, lat:number, height?:number}>}} */
export function createDrawSession(shape = 'area') {
return { shape: normalizeShape(shape), vertices: [] };
}
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
}
/**
* Add a vertex. A vertex that is not a finite lon/lat is refused; one within
* MIN_VERTEX_SEPARATION_M of the previous vertex is treated as the same click
* (the second half of a double-click) and refused as a duplicate. A pin holds
* exactly one vertex: a later click moves it.
* @returns {{added: boolean, reason?: 'invalid'|'duplicate'}}
*/
export function addVertex(
session,
vertex,
{ minSeparationM = MIN_VERTEX_SEPARATION_M } = {},
) {
if (!session || !isFiniteCoordinate(vertex))
return { added: false, reason: 'invalid' };
const v = {
lon: vertex.lon,
lat: vertex.lat,
height: Number.isFinite(vertex.height) ? vertex.height : 0,
};
if (session.shape === 'pin') {
session.vertices = [v];
return { added: true };
}
if (session.vertices.length >= MAX_VERTICES)
return { added: false, reason: 'full' };
const last = session.vertices[session.vertices.length - 1];
if (last && greatCircleM(last, v) < minSeparationM)
return { added: false, reason: 'duplicate' };
session.vertices.push(v);
return { added: true };
}
/** A usable click position: finite, and on the globe rather than past its edges. */
export function isFiniteCoordinate(vertex) {
if (!vertex) return false;
const { lon, lat } = vertex;
return (
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lon) <= 180 &&
Math.abs(lat) <= 90
);
}
/** Remove the last vertex. @returns {boolean} whether one was removed */
export function removeLastVertex(session) {
if (!session?.vertices?.length) return false;
session.vertices.pop();
return true;
}
/**
* Why a session can or cannot become an annotation.
* - `too-few`: not enough vertices yet.
* - `degenerate`: enough vertices, but they describe nothing — three collinear
* points enclose no area, and a line that doubles back on itself has no
* length. Drawing one of those and calling it a mark would put an invisible
* entity on the board that the person cannot see, select or explain.
* @returns {'ok'|'too-few'|'degenerate'|'invalid'}
*/
export function finishReason(session) {
if (!session || !Array.isArray(session.vertices)) return 'invalid';
if (session.vertices.some((vertex) => !isFiniteCoordinate(vertex)))
return 'invalid';
if (session.vertices.length < (MIN_VERTICES[session.shape] || 1))
return 'too-few';
if (
session.shape === 'line' &&
pathLengthM(session.vertices) < MIN_PATH_LENGTH_M
)
return 'degenerate';
if (session.shape === 'area' && ringAreaM2(session.vertices) < MIN_AREA_M2)
return 'degenerate';
return 'ok';
}
/** @returns {boolean} whether the session can become an annotation right now */
export function canFinish(session) {
return finishReason(session) === 'ok';
}
/** Length of an open path in metres. */
export function pathLengthM(vertices) {
let m = 0;
for (let i = 1; i < (vertices?.length || 0); i += 1)
m += greatCircleM(vertices[i - 1], vertices[i]);
return m;
}
/**
* The same vertices with longitudes made CONTINUOUS relative to the first one,
* so planar maths does not tear at the antimeridian. A small shape straddling
* 180° has longitudes like [179.999, -179.999]; subtracting those raw gives
* 359.998° of width instead of 0.002°, which is how a 25,000 m² rectangle
* measured 4,461 km² and reported its centre on the Greenwich meridian.
*
* Values may leave the [-180, 180] range on purpose — that is what "continuous"
* means. Re-wrap with `wrapLongitude` before handing one back as a coordinate.
* @param {Array<{lon:number, lat:number}>} vertices
*/
export function unwrapLongitudes(vertices) {
if (!vertices?.length) return [];
const reference = vertices[0].lon;
return vertices.map((vertex) => {
let lon = vertex.lon;
while (lon - reference > 180) lon -= 360;
while (lon - reference < -180) lon += 360;
return { ...vertex, lon };
});
}
/** A continuous longitude brought back into [-180, 180]. */
export function wrapLongitude(lon) {
if (!Number.isFinite(lon)) return lon;
// A value already in range is returned UNCHANGED rather than pushed through
// the modulo, which is only exact in binary for some inputs: 0.0005 came back
// as 0.0004999999999881766. The seam is the only place the arithmetic is
// needed, so it is the only place that pays for it.
if (lon >= -180 && lon < 180) return lon;
const value = ((((lon + 180) % 360) + 360) % 360) - 180;
return Object.is(value, -0) ? 0 : value;
}
/** Planar shoelace area of a ring in square metres (local metre grid; fine at whiteboard scale). */
export function ringAreaM2(vertices) {
if (!vertices || vertices.length < 3) return 0;
const ring = unwrapLongitudes(vertices);
const lat0 = ring.reduce((s, v) => s + v.lat, 0) / ring.length;
const kx = 111320 * Math.cos((lat0 * Math.PI) / 180);
const ky = 111320;
let twice = 0;
for (let i = 0; i < ring.length; i += 1) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
twice += a.lon * kx * (b.lat * ky) - b.lon * kx * (a.lat * ky);
}
return Math.abs(twice) / 2;
}
/** Vertex-average centroid of a ring, {lon, lat}, safe across the antimeridian. */
export function ringCentroid(vertices) {
if (!vertices?.length) return null;
const ring = unwrapLongitudes(vertices);
return {
lon: wrapLongitude(ring.reduce((s, v) => s + v.lon, 0) / ring.length),
lat: ring.reduce((s, v) => s + v.lat, 0) / ring.length,
};
}
/** Distance or area, formatted for a label suffix. */
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
}
return '';
}
/**
* The annotation spec for a finished session, in the shape `annotationEngine.annotate()`
* takes. Null when the session cannot finish. Geometry is supplied outright and
* `manual: true` tells the engine to skip name resolution.
*
* The per-vertex height from the click is dropped here, deliberately and in one
* place: the world renderer drapes areas and routes onto the photoreal surface,
* so a height carried this far would be discarded further downstream instead,
* silently. What the person gets is the outline they drew, lying on the
* surface under it.
* @param {object} session
* @param {{label?: string, color?: string}} [opts]
*/
export function finishSpec(session, { label = '', color = 'primary' } = {}) {
if (!canFinish(session)) return null;
const text = String(label || '').trim();
const pts = session.vertices.map((v) => [v.lon, v.lat]);
if (session.shape === 'area') {
// CLOSED explicitly. A polygon fill closes itself, but the outline beside it
// is a polyline, and an open ring draws every edge except the one back to
// the first vertex — the shape reads as a shape with one side missing.
return {
type: 'area',
manual: true,
ring: closeRing(pts),
label: text || null,
color,
};
}
if (session.shape === 'line') {
return {
type: 'route',
manual: true,
path: pts,
label: text || null,
color,
};
}
const [lon, lat] = pts[0];
return {
type: 'pin',
manual: true,
latitude: lat,
longitude: lon,
label: text || null,
color,
};
}
/** A ring whose last position repeats its first, so an outline has no gap. */
export function closeRing(pairs) {
if (!Array.isArray(pairs) || pairs.length < 3) return pairs;
const [firstLon, firstLat] = pairs[0];
const [lastLon, lastLat] = pairs[pairs.length - 1];
if (firstLon === lastLon && firstLat === lastLat) return pairs;
return [...pairs, [firstLon, firstLat]];
}
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
}
+332
View File
@@ -0,0 +1,332 @@
// Pure tests for the manual draw session. Run with: npm test (node --test). No Cesium, no DOM.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createDrawSession,
addVertex,
removeLastVertex,
canFinish,
finishSpec,
normalizeShape,
pathLengthM,
ringAreaM2,
ringCentroid,
formatMeasure,
drawHint,
finishReason,
isFiniteCoordinate,
closeRing,
unwrapLongitudes,
wrapLongitude,
MAX_VERTICES,
MIN_VERTICES,
} from './drawMode.js';
test('shapes normalize to area, line or pin, and unknown words fall back to area', () => {
assert.equal(normalizeShape('route'), 'line');
assert.equal(normalizeShape('marker'), 'pin');
assert.equal(normalizeShape('polygon'), 'area');
assert.equal(normalizeShape(undefined), 'area');
});
test('an area needs three vertices, a line two, a pin one', () => {
const area = createDrawSession('area');
addVertex(area, { lon: -97.74, lat: 30.27 });
addVertex(area, { lon: -97.73, lat: 30.27 });
assert.equal(canFinish(area), false);
addVertex(area, { lon: -97.73, lat: 30.28 });
assert.equal(canFinish(area), true);
assert.equal(MIN_VERTICES.line, 2);
assert.equal(MIN_VERTICES.pin, 1);
});
test('a second click within half a metre is the tail of a double-click, not a vertex', () => {
const line = createDrawSession('line');
assert.deepEqual(addVertex(line, { lon: 0, lat: 0 }), { added: true });
assert.deepEqual(addVertex(line, { lon: 0.000001, lat: 0 }), {
added: false,
reason: 'duplicate',
});
assert.deepEqual(addVertex(line, { lon: 'x', lat: 0 }), {
added: false,
reason: 'invalid',
});
assert.equal(line.vertices.length, 1);
});
test('a pin keeps exactly one vertex: a later click moves it', () => {
const pin = createDrawSession('pin');
addVertex(pin, { lon: 1, lat: 1 });
addVertex(pin, { lon: 2, lat: 2 });
assert.equal(pin.vertices.length, 1);
assert.deepEqual(pin.vertices[0], { lon: 2, lat: 2, height: 0 });
});
test('backspace removes the last vertex and reports whether it did', () => {
const s = createDrawSession('line');
assert.equal(removeLastVertex(s), false);
addVertex(s, { lon: 0, lat: 0 });
assert.equal(removeLastVertex(s), true);
assert.equal(s.vertices.length, 0);
});
test('finishSpec yields the engine spec shapes with manual geometry, or null when unfinished', () => {
const area = createDrawSession('area');
assert.equal(finishSpec(area), null);
[
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
].forEach(([lon, lat]) => addVertex(area, { lon, lat }));
assert.deepEqual(finishSpec(area, { label: ' Zilker ', color: 'amber' }), {
type: 'area',
manual: true,
// CLOSED: the last position repeats the first so the outline polyline
// draws all three sides of the triangle, not two.
ring: [
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
[-97.74, 30.27],
],
label: 'Zilker',
color: 'amber',
});
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0.01, lat: 0 });
assert.deepEqual(finishSpec(line), {
type: 'route',
manual: true,
path: [
[0, 0],
[0.01, 0],
],
label: null,
color: 'primary',
});
const pin = createDrawSession('pin');
addVertex(pin, { lon: 151.2, lat: -33.9 });
assert.deepEqual(finishSpec(pin, { label: 'A shed' }), {
type: 'pin',
manual: true,
latitude: -33.9,
longitude: 151.2,
label: 'A shed',
color: 'primary',
});
});
test('length and area come out in metres on a local grid', () => {
const km = pathLengthM([
{ lon: 0, lat: 0 },
{ lon: 0, lat: 0.009 },
]);
assert.ok(km > 990 && km < 1010, `1 km of latitude, got ${km}`);
const square = [
{ lon: 0, lat: 0 },
{ lon: 0.001, lat: 0 },
{ lon: 0.001, lat: 0.001 },
{ lon: 0, lat: 0.001 },
];
const m2 = ringAreaM2(square);
assert.ok(m2 > 12000 && m2 < 12800, `~111 m square, got ${m2}`);
assert.deepEqual(ringCentroid(square), { lon: 0.0005, lat: 0.0005 });
});
test('the measure and the hint follow the shape and the vertex count', () => {
const area = createDrawSession('area');
assert.equal(drawHint(area), 'Click 3 more points.');
[
{ lon: 0, lat: 0 },
{ lon: 0.01, lat: 0 },
{ lon: 0.01, lat: 0.01 },
].forEach((v) => addVertex(area, v));
assert.match(formatMeasure(area), /ha$|km²$|m²$/);
assert.match(drawHint(area), /double-click or Enter to finish/);
const pin = createDrawSession('pin');
assert.equal(drawHint(pin), 'Click where the pin goes.');
assert.equal(drawHint(null), 'Pick a shape, then click the map.');
});
test('a vertex must be a finite coordinate on the globe', () => {
assert.equal(isFiniteCoordinate({ lon: 12, lat: -4 }), true);
assert.equal(isFiniteCoordinate({ lon: 0, lat: 90 }), true);
for (const bad of [
null,
{},
{ lon: NaN, lat: 0 },
{ lon: 0, lat: Infinity },
{ lon: 181, lat: 0 },
{ lon: 0, lat: -90.5 },
{ lon: '10', lat: 10 },
]) {
assert.equal(isFiniteCoordinate(bad), false, JSON.stringify(bad));
}
const line = createDrawSession('line');
assert.deepEqual(addVertex(line, { lon: 200, lat: 0 }), {
added: false,
reason: 'invalid',
});
assert.equal(line.vertices.length, 0);
});
test('one shape holds at most MAX_VERTICES points', () => {
const line = createDrawSession('line');
for (let i = 0; i < MAX_VERTICES + 25; i += 1)
addVertex(line, { lon: i * 0.001, lat: 0 });
assert.equal(line.vertices.length, MAX_VERTICES);
assert.deepEqual(addVertex(line, { lon: 99, lat: 1 }), {
added: false,
reason: 'full',
});
assert.match(drawHint(line), /512-point limit reached/);
// A pin replaces its one vertex forever: the ceiling cannot strand it.
const pin = createDrawSession('pin');
for (let i = 0; i < MAX_VERTICES + 5; i += 1)
assert.equal(addVertex(pin, { lon: i * 0.01, lat: 0 }).added, true);
assert.equal(pin.vertices.length, 1);
});
test('a shape that encloses nothing is refused, with a reason', () => {
// Three points on one meridian are not an area.
const collinear = createDrawSession('area');
for (const lat of [0, 0.001, 0.002]) addVertex(collinear, { lon: 0, lat });
assert.equal(collinear.vertices.length, 3);
assert.equal(finishReason(collinear), 'degenerate');
assert.equal(canFinish(collinear), false);
assert.equal(finishSpec(collinear), null);
assert.match(drawHint(collinear), /in a line/);
// Moving one point off that line makes it a shape again.
collinear.vertices[2].lon = 0.001;
assert.equal(finishReason(collinear), 'ok');
assert.ok(finishSpec(collinear));
// A line whose two ends are a handful of centimetres apart has no length.
const stub = createDrawSession('line');
addVertex(stub, { lon: 0, lat: 0 });
addVertex(stub, { lon: 0.000007, lat: 0 }, { minSeparationM: 0.01 });
assert.equal(stub.vertices.length, 2);
assert.equal(finishReason(stub), 'degenerate');
assert.equal(finishSpec(stub), null);
assert.match(drawHint(stub), /no length/);
// And the states in between are named, not lumped together.
assert.equal(finishReason(createDrawSession('area')), 'too-few');
assert.equal(finishReason(null), 'invalid');
});
test('a finished area is a CLOSED ring, so its outline has no missing side', () => {
const area = createDrawSession('area');
for (const [lon, lat] of [
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
])
addVertex(area, { lon, lat });
const spec = finishSpec(area);
assert.equal(
spec.ring.length,
4,
'three clicked vertices become four ring positions',
);
assert.deepEqual(
spec.ring.at(-1),
spec.ring[0],
'the last position repeats the first',
);
// The renderer draws the outline as a polyline over exactly these positions:
// an open ring would draw two of the triangle's three sides.
assert.equal(spec.ring.length - 1, area.vertices.length);
// A line is NOT closed — it is a path, and joining its ends would invent a
// segment the person never drew.
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0.01, lat: 0 });
assert.equal(finishSpec(line).path.length, 2);
// closeRing is idempotent: finishing an already-closed ring adds nothing.
const closed = closeRing([
[0, 0],
[1, 0],
[1, 1],
[0, 0],
]);
assert.equal(closed.length, 4);
});
test('a shape straddling the antimeridian measures what it looks like', () => {
// 0.002 degrees square at the equator — about 223 m a side, so ~49,600 m2 —
// sitting across 180. Raw longitude arithmetic reads that 0.002-degree width
// as 359.998 degrees: the area came out in the thousands of square
// kilometres and the centroid landed on the Greenwich meridian, half a world
// from the shape it belongs to.
const rectangle = [
{ lon: 179.999, lat: -0.001 },
{ lon: -179.999, lat: -0.001 },
{ lon: -179.999, lat: 0.001 },
{ lon: 179.999, lat: 0.001 },
];
const area = ringAreaM2(rectangle);
assert.ok(
area > 45_000 && area < 55_000,
`a ~223 m x ~223 m patch is ~49,600 m2, got ${Math.round(area)} m2`,
);
assert.ok(area < 1e6, 'and nowhere near a square kilometre');
const centre = ringCentroid(rectangle);
assert.ok(
Math.abs(Math.abs(centre.lon) - 180) < 0.01,
`the centre belongs on the antimeridian, got lon ${centre.lon}`,
);
assert.ok(Math.abs(centre.lat) < 0.01, `got lat ${centre.lat}`);
// Length was already safe — haversine is periodic in the longitude delta —
// and this pins that so a "fix" cannot break it.
const across = pathLengthM([
{ lon: 179.999, lat: 0 },
{ lon: -179.999, lat: 0 },
]);
assert.ok(
across > 150 && across < 300,
`about 222 m, got ${Math.round(across)} m`,
);
// The same shape away from the seam still measures the same.
const inland = rectangle.map((v) => ({ lon: v.lon - 179.999, lat: v.lat }));
assert.ok(
Math.abs(ringAreaM2(inland) - area) < 1,
'position must not change size',
);
// And the ordinary case is untouched.
const austin = [
{ lon: -97.75, lat: 30.26 },
{ lon: -97.74, lat: 30.26 },
{ lon: -97.74, lat: 30.27 },
];
const centroid = ringCentroid(austin);
assert.ok(Math.abs(centroid.lon + 97.7433) < 0.001);
assert.ok(Math.abs(centroid.lat - 30.2633) < 0.001);
});
test('longitude unwrapping is continuous and re-wraps to one canonical range', () => {
const unwrapped = unwrapLongitudes([
{ lon: 179.9, lat: 0 },
{ lon: -179.9, lat: 0 },
]);
assert.equal(unwrapped[0].lon, 179.9);
assert.ok(
Math.abs(unwrapped[1].lon - 180.1) < 1e-9,
`continuous neighbour, got ${unwrapped[1].lon}`,
);
assert.equal(wrapLongitude(180.1).toFixed(4), (-179.9).toFixed(4));
assert.equal(wrapLongitude(-190), 170);
assert.equal(wrapLongitude(10), 10);
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
+546
View File
@@ -0,0 +1,546 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
*
* Two ownership rules make it safe to share the scene with the layers:
*
* - While a session is open the tool HOLDS THE POINTER
* (`src/data/inputOwnership.js`), and every ambient selection handler yields,
* so a vertex clicked on top of an aircraft or a camera is a vertex and
* nothing else. It is one claim consulted by all of them, not a list of
* exceptions inside each.
* - Cesium's Viewer binds its OWN left click (select the entity under the
* pointer) and double click (track it) on the viewer's handler, outside every
* layer and therefore outside the shared claim. Both are borrowed for the
* session and given back on the way out, and `destroy()` gives back every
* listener, the Cesium handler, the preview data source and the window
* handle, so the tool can be turned off or the shell disposed without leaving
* anything behind.
*
* A vertex carries the height the click landed on — a roof, a hillside — and
* that height is what the live preview hangs on, so the rubber band follows the
* surface under the pointer instead of sinking to sea level. The world renderer
* drapes finished areas and routes onto the surface, so the height is dropped
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
addVertex,
createDrawSession,
drawHint,
finishReason,
finishSpec,
normalizeShape,
removeLastVertex,
} from './drawMode.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
let handler = null;
let lease = null;
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
// one of those must not write its outcome over the newer state.
let generation = 0;
const previewEntities = [];
// Every DOM listener this tool adds, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
// remove a source that had not been attached yet and leave the attachment to
// land afterwards — a preview data source nobody owns. Chain the removal onto
// the attachment instead of racing it.
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const setHint = (text) => {
if (hint) hint.textContent = text;
};
// ---- preview ---------------------------------------------------------
const vertexPositions = () =>
session.vertices.map((v) =>
Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
);
const previewLine = dataSource.entities.add({
show: false,
polyline: {
positions: new Cesium.CallbackProperty(() => {
if (!session) return [];
const pts = vertexPositions();
if (cursor && session.shape !== 'pin') pts.push(cursor);
if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]);
return pts;
}, false),
width: 3,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9),
dashLength: 16,
}),
depthFailMaterial: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35),
dashLength: 16,
}),
clampToGround: false,
},
});
const syncPreview = () => {
if (destroyed) return;
previewEntities.forEach((e) => dataSource.entities.remove(e));
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
setHint(drawHint(null));
viewer.scene.requestRender();
return;
}
const stroke = Cesium.Color.fromCssColorString(
PREVIEW[color] || PREVIEW.primary,
);
previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.9),
dashLength: 16,
});
previewLine.show = session.shape !== 'pin';
for (const v of session.vertices) {
previewEntities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
point: {
pixelSize: 8,
color: stroke,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
);
}
setHint(drawHint(session));
viewer.scene.requestRender();
};
// ---- vertices from clicks --------------------------------------------
const worldAt = (position) => {
const canvas = viewer.scene.canvas;
const w = canvas.clientWidth || canvas.width || 1;
const h = canvas.clientHeight || canvas.height || 1;
return pickWorldFromScreen(viewer, position.x / w, position.y / h);
};
const onClick = (event) => {
if (!session || destroyed) return;
const p = worldAt(event.position);
if (!p) return;
const { added, reason } = addVertex(session, p);
if (added) {
syncPreview();
return;
}
// A click that changed nothing still deserves an answer when the reason is
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
const p = worldAt(event.endPosition);
cursor = p
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
: null;
viewer.scene.requestRender();
};
// ---- finish / cancel -------------------------------------------------
const finish = async () => {
if (destroyed || !session) return null;
const reason = finishReason(session);
if (reason !== 'ok') {
// 'too-few' is just "keep clicking" and the hint already says so;
// a degenerate or off-globe shape needs to be told why it was refused.
if (reason !== 'too-few') setHint(drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
persist: true,
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
return null;
}
};
const cancel = () => {
if (!session) return;
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
};
/** Wipe the board: the shape in progress AND every mark already placed. */
const clearAll = () => {
generation += 1;
cursor = null;
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
};
// ---- keys: only while drawing, never while typing in another field ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
};
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
)
return false;
return (
t === document.body || t === viewer.scene.canvas || t.tagName === 'CANVAS'
);
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
// keeps its own Enter — see enterBelongsToDrawing.
if (event.key === 'Enter') {
if (session?.vertices?.length && enterBelongsToDrawing(event)) {
event.preventDefault();
void finish();
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
if (session?.vertices.length) cancel();
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
}
}
};
// ---- mode on / off -----------------------------------------------------
function setActive(next) {
if (destroyed || next === active) return active;
if (next) {
lease = claimPointer(DRAW_POINTER_OWNER);
if (!lease) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
return active;
}
}
active = next;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
document.body.classList.toggle('gev-drawing', active);
if (active) {
session = createDrawSession(shape);
bindSceneHandler();
syncPreview();
} else {
generation += 1;
session = null;
cursor = null;
releaseSceneHandler();
releasePointer(lease);
lease = null;
syncPreview();
}
return active;
}
function bindSceneHandler() {
if (handler) return;
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(() => {
void finish();
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
// single click picks an entity into `viewer.selectedEntity`, the double
// click tracks it. Neither goes through a layer, so neither can be fixed by
// the shared pointer claim — they have to be borrowed outright for the
// session and given back on the way out. Borrowing the single click is what
// stops a vertex placed on a contact from also selecting it.
const stock = viewer.screenSpaceEventHandler;
savedSingleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
listen(document, 'keydown', onKey, true);
}
function releaseSceneHandler() {
if (handler) {
handler.destroy();
handler = null;
}
if (savedSingleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedSingleClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedSingleClick = null;
}
if (savedDoubleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
savedDoubleClick = null;
}
// Drop just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
target.removeEventListener(type, listener, options);
domListeners.splice(i, 1);
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
}
});
setHint(drawHint(null));
const api = {
get active() {
return active;
},
get shape() {
return shape;
},
get session() {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
},
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
const r = addVertex(session, { lon, lat, height });
if (r.added) syncPreview();
return r.added;
},
finish,
cancel,
clearAll,
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
return {
active,
destroyed,
sceneHandler: Boolean(handler),
domListeners: domListeners.length,
previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
pointerOwner: pointerOwner(),
// The viewer's OWN click actions. While a session is open both are
// borrowed (absent here); after it closes both are back. A harness can
// compare the restored functions by identity with what it captured
// before, which is the only way to prove they were given back rather
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
};
},
/** Resolves once every deferred teardown step has run. */
whenSettled() {
return attaching;
},
destroy() {
if (destroyed) return attaching;
// Supersede any finish still in flight before anything is torn down.
generation += 1;
if (active) setActive(false);
destroyed = true;
releaseSceneHandler();
releasePointer(lease);
lease = null;
for (const [target, type, listener, options] of domListeners.splice(0)) {
target.removeEventListener(type, listener, options);
}
previewEntities.length = 0;
dataSource.entities.removeAll();
// Wait for the pending add() before removing: a destroy() in the same
// tick as init would otherwise remove nothing and let the attachment land
// behind it.
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed — nothing to detach from */
}
});
document.body.classList.remove('gev-drawing');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching;
},
};
window.__gevDrawTool = api;
return api;
}
/** How many draw previews are attached — more than one means a leaked tool. */
function countPreviewDataSources(viewer) {
const sources = viewer?.dataSources;
if (!sources) return 0;
let found = 0;
for (let index = 0; index < sources.length; index += 1) {
if (sources.get(index)?.name === PREVIEW_DATA_SOURCE_NAME) found += 1;
}
return found;
}
+218
View File
@@ -0,0 +1,218 @@
// STRUCTURAL contracts only: where files live, which ids the markup carries,
// what the docs may claim. Things a running test cannot observe.
//
// Everything about the tool's BEHAVIOUR — the pointer lease, borrowing and
// restoring the viewer's own click actions, teardown, the Enter key, preview
// heights, Clear — is asserted against the running module in
// `drawToolBehaviour.test.mjs`, not by reading this file's source.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const tool = () => read('src/annotations/drawTool.js');
test('the application composition wires the draw tool and owns its teardown', () => {
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
drawAt > engineAt,
'initDrawTool must follow initAnnotations and receive its engine',
);
assert.match(
tools,
/import \{ initDrawTool \} from '\.\.\/annotations\/drawTool\.js';/,
);
// The tool holds a pointer claim, DOM listeners and a data source, so the
// application lifetime disposes it — not whoever last pressed the button.
const deferAt = tools.indexOf('defer(() => drawTool?.destroy());');
assert.ok(deferAt > drawAt, 'the shell must defer the draw tool teardown');
});
test('the engine resolves manual geometry before any name resolution', () => {
const engine = read('src/annotations/annotationEngine.js');
const resolveAt = engine.indexOf(
'async function resolveSpec(spec, signal) {',
);
const manualAt = engine.indexOf(
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);',
resolveAt,
);
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
assert.ok(
manualAt > resolveAt && manualAt < routeAt,
'manual specs must short-circuit ahead of the route/arrow/area resolvers',
);
assert.match(
engine,
/if \(mode === 'manual'\) return baseLabel \? `\$\{baseLabel\} — \$\{dist\}` : dist;/,
'a drawn line reports length, never a walk time',
);
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/display-controls.html');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
);
}
for (const shape of ['area', 'line', 'pin']) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
`shape button ${shape} missing`,
);
}
for (const id of ids) {
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
);
}
});
test('the DISPLAY rail styles live in their current owner, not the shim', () => {
// The PR this came from was written against the pre-refactor layout, where
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
]) {
assert.ok(
controls.includes(rule),
`${rule} must live in src/ui/styles/controls.css`,
);
}
});
test('nothing promises a GeoJSON export for drawn shapes', () => {
for (const file of [
'src/annotations/drawTool.js',
'src/annotations/drawMode.js',
]) {
assert.doesNotMatch(
read(file),
/GeoJSON/i,
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
section,
/GeoJSON/i,
`${file} still claims a GeoJSON export for drawn shapes`,
);
}
});
test('the docs do not promise a vertex height the renderer would discard', () => {
// Areas and routes are DRAPED, so a per-vertex height is not a placement.
// That the finished spec actually drops it is asserted against the running
// module in drawToolBehaviour.test.mjs; this only guards the wording.
assert.doesNotMatch(tool(), /lands on the roof/);
assert.doesNotMatch(
read('docs/CURRENT-STATE.md'),
/a vertex on a roof lands on the roof/,
);
});
test('draped marks classify onto terrain as well as 3D tiles', () => {
// CESIUM_3D_TILE alone rendered nothing on a keyless boot, where Cesium's own
// globe carries the imagery and there are no tiles to classify against. The
// rendered proof is in scripts/qa-draw-tool.mjs, which counts stroke pixels
// against a keyless server; this stops the constant being narrowed again.
const renderer = read('src/annotations/worldAnnotationRenderer.js');
assert.match(renderer, /const CLASSIFY = Cesium\.ClassificationType\.BOTH;/);
assert.doesNotMatch(
renderer,
/classificationType: Cesium\.ClassificationType\.CESIUM_3D_TILE/,
);
});
test('the draw modules are registered for formatting and boundary checks', () => {
const formatScope = JSON.parse(read('scripts/format-scope.json'));
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawMode.test.mjs',
'src/annotations/drawTool.js',
'src/annotations/drawTool.test.mjs',
'src/data/inputOwnership.js',
]) {
assert.ok(
formatScope.includes(file),
`${file} must be in the formatting scope`,
);
}
const boundaries = JSON.parse(read('scripts/package-boundaries.json'));
const owners = (file) =>
Object.entries(boundaries)
.filter(([, group]) => group.modules?.includes(file))
.map(([name]) => name);
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawTool.js',
'src/data/inputOwnership.js',
]) {
assert.ok(
owners(file).length > 0,
`${file} must be owned by at least one boundary group`,
);
}
// Every layer that consults the shared claim has to own the module it reads,
// or its bundle fails the boundary check.
for (const group of ['alpr-cameras', 'vessel-layer', 'satellites-layer']) {
assert.ok(
boundaries[group]?.modules.includes('src/data/inputOwnership.js'),
`${group} consults the pointer claim and must own the module`,
);
}
const exports = JSON.parse(read('package.json')).exports;
assert.equal(exports['./annotations'], './src/annotations/index.js');
});
+467
View File
@@ -0,0 +1,467 @@
// Behavioural tests for the draw tool: the module is driven against fake DOM
// and viewer doubles and its OBSERVABLE effects are asserted — which lease it
// holds, which viewer input actions it borrowed and gave back, what it leaves
// behind when destroyed. Nothing here reads the module's source text.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import {
isPointerFree,
pointerOwner,
claimPointer,
releasePointer,
resetPointerOwnership,
} from '../data/inputOwnership.js';
/* ── DOM doubles ───────────────────────────────────────────────────────── */
class FakeClassList {
constructor() {
this.names = new Set();
}
add(...names) {
for (const n of names) this.names.add(n);
}
remove(...names) {
for (const n of names) this.names.delete(n);
}
contains(name) {
return this.names.has(name);
}
toggle(name, force) {
const on = force === undefined ? !this.names.has(name) : Boolean(force);
if (on) this.names.add(name);
else this.names.delete(name);
return on;
}
}
class FakeElement {
constructor(tagName = 'DIV', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.attributes = new Map();
this.textContent = '';
this.value = '';
this.children = [];
this.listeners = [];
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
}
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(entry) => entry.type === type && entry.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
}
querySelectorAll(selector) {
if (selector === '.pp-mode-btn[data-shape]')
return this.children.filter((child) => child.dataset.shape);
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
return [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
click() {
return this.emit('click');
}
closest(selector) {
// Only what the tool asks: "am I inside an activatable control?"
const activatable = ['button', 'a', 'select', '[role="button"]', '[role="radio"]'];
if (!activatable.some((part) => selector.includes(part))) return null;
return this.tagName === 'BUTTON' || this.tagName === 'A' || this.tagName === 'SELECT'
? this
: null;
}
/** Deliver an event to this element's own listeners. */
emit(type, event = {}) {
const payload = {
type,
target: this,
preventDefault() {},
stopImmediatePropagation() {},
...event,
};
for (const entry of [...this.listeners])
if (entry.type === type) entry.listener(payload);
return payload;
}
}
function fakeDom() {
const byId = new Map();
const make = (id, tagName = 'DIV') => {
const element = new FakeElement(tagName, id);
byId.set(id, element);
return element;
};
make('draw-toggle', 'BUTTON');
const modeRow = make('draw-mode-row');
for (const shape of ['area', 'line', 'pin']) {
const button = new FakeElement('BUTTON');
button.dataset.shape = shape;
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
const body = new FakeElement('BODY');
const documentDouble = {
body,
getElementById: (id) => byId.get(id) ?? null,
listeners: [],
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
},
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(entry) => entry.type === type && entry.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
},
emit(type, event = {}) {
const payload = {
type,
target: body,
preventDefault() {},
stopImmediatePropagation() {},
...event,
};
for (const entry of [...this.listeners])
if (entry.type === type) entry.listener(payload);
return payload;
},
};
return { byId, documentDouble };
}
/* ── viewer double ─────────────────────────────────────────────────────── */
function fakeViewer() {
const canvas = new FakeElement('CANVAS');
canvas.clientWidth = 800;
canvas.clientHeight = 600;
const actions = new Map();
const stockHandler = {
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
};
const attached = [];
const pendingAdds = [];
const dataSources = {
get length() {
return attached.length;
},
get: (index) => attached[index],
add(source) {
// Cesium resolves this on a later tick — the whole point of finding 7.
const promise = Promise.resolve().then(() => {
attached.push(source);
return source;
});
pendingAdds.push(promise);
return promise;
},
remove(source) {
const at = attached.indexOf(source);
if (at >= 0) attached.splice(at, 1);
return at >= 0;
},
};
return {
scene: { canvas, requestRender() {} },
screenSpaceEventHandler: stockHandler,
dataSources,
_attached: attached,
_actions: actions,
};
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
return {
calls,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
},
clear() {
calls.clear += 1;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
const annotations = fakeAnnotations();
const previousDocument = globalThis.document;
const previousWindow = globalThis.window;
globalThis.document = documentDouble;
globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations });
return {
tool,
viewer,
annotations,
byId,
documentDouble,
element: (id) => byId.get(id),
restore() {
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
},
};
}
const LEFT_CLICK = Cesium.ScreenSpaceEventType.LEFT_CLICK;
const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
assert.equal(isPointerFree(), true);
h.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
assert.equal(h.tool.active, true);
h.element('draw-toggle').emit('click');
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
assert.equal(h.tool.active, false);
} finally {
h.restore();
}
});
test('Draw refuses to start when something else holds the pointer', async () => {
const h = harness();
try {
const other = claimPointer('directions');
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
assert.match(h.element('draw-hint').textContent, /directions/);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
} finally {
h.restore();
}
});
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
const first = harness();
try {
first.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw');
// The replacement takes over before the old one finishes tearing down.
first.tool.setActive(false);
const second = harness();
try {
second.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw');
await first.tool.destroy();
assert.equal(
pointerOwner(),
'draw',
'the live session still owns the pointer after the old one is destroyed',
);
assert.equal(second.tool.active, true);
} finally {
second.restore();
}
} finally {
first.restore();
}
});
test('both stock viewer click actions are borrowed for the session and given back', async () => {
const h = harness();
try {
const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.element('draw-toggle').emit('click');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
undefined,
"the viewer's own selecting click must not run while drawing",
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
undefined,
);
h.element('draw-toggle').emit('click');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the exact function is restored, not a replacement',
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
stockDouble,
);
} finally {
h.restore();
}
});
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
// Enter while the Clear button has keyboard focus means "press Clear".
h.documentDouble.emit('keydown', { key: 'Enter', target: h.element('draw-clear') });
await Promise.resolve();
assert.equal(
h.annotations.calls.annotate.length,
0,
'a focused button keeps its own Enter',
);
assert.equal(h.tool.session.vertices.length, 3, 'and the shape is untouched');
// Enter from the page body (the canvas has focus in practice) finishes it.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the map finishes');
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
} finally {
h.restore();
}
});
test('the live preview hangs at the height the click landed on', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.setShape('line');
h.tool.addVertex(-97.75, 30.26, 187.5);
h.tool.addVertex(-97.74, 30.26, 212.25);
await h.tool.whenSettled();
const source = h.viewer._attached[0] ?? null;
assert.ok(source, 'the preview data source is attached');
const points = source.entities.values.filter((entity) => entity.point);
assert.equal(points.length, 2, 'one preview dot per vertex');
const heights = points.map((entity) => {
const position = entity.position.getValue(Cesium.JulianDate.now());
return Cesium.Cartographic.fromCartesian(position).height;
});
assert.ok(Math.abs(heights[0] - 187.5) < 0.5, `got ${heights[0]}`);
assert.ok(Math.abs(heights[1] - 212.25) < 0.5, `got ${heights[1]}`);
// …and the finished shape is still flattened, deliberately: the renderer
// drapes, so a height carried further would be discarded out of sight.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
const spec = h.annotations.calls.annotate[0].specs[0];
assert.deepEqual(spec.path, [
[-97.75, 30.26],
[-97.74, 30.26],
]);
} finally {
h.restore();
}
});
test('destroy in the same tick as init still removes the preview data source', async () => {
const h = harness();
try {
// Nothing has been attached yet — dataSources.add() resolves later.
assert.equal(h.viewer._attached.length, 0, 'the add is still pending');
await h.tool.destroy();
await Promise.resolve();
assert.equal(
h.viewer._attached.length,
0,
'the preview must not be left attached behind the teardown',
);
} finally {
h.restore();
}
});
test('destroy releases the pointer, the handler, the listeners and the stock actions', async () => {
const h = harness();
try {
const stockClick = () => 'viewer picks and selects';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
await h.tool.destroy();
await Promise.resolve();
assert.equal(isPointerFree(), true, 'the pointer is free');
const after = h.tool.diagnostics();
assert.equal(after.destroyed, true);
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.element('draw-toggle').listeners.length, 0);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
} finally {
h.restore();
}
});
test('Clear wipes the board and the shape in progress', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
assert.equal(h.tool.session.vertices.length, 2);
h.element('draw-clear').emit('click');
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
} finally {
h.restore();
}
});
+10 -2
View File
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy);
}
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) {
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
}
/**
+17 -3
View File
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null, placeSearch }) {
export function initAnnotations({
viewer,
tileset = null,
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch });
const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
window.__gevAnnotations = engine;
return engine;
}
File diff suppressed because it is too large. Load diff
+254 -71
View File
@@ -37,9 +37,9 @@ const PALETTE = {
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
// longer collapse into an overlapping blob when their anchors project close together.
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const RING_OUTER_R = 34;
const RING_INNER_R = 18;
const DOT_R = 5;
@@ -51,16 +51,22 @@ function markScale(h) {
return 1 - t * (1 - MARK_SCALE_MIN);
}
export function createScreenAnnotationRenderer(viewer, {
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {}) {
export function createScreenAnnotationRenderer(
viewer,
{
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {},
) {
injectStyles();
const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer);
const scene = viewer.scene;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { anno, group, parts }
const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3();
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
return cached.h;
}
try {
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
}
}
}
} catch { /* tiles not ready */ }
} catch {
/* tiles not ready */
}
const fallback = cached ? cached.h : 0;
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
trimHeightCache();
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c, stroke: c,
fill: c,
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly);
parts.dots = anno.path.map(() => {
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
const dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: '4',
});
group.appendChild(dot);
return dot;
});
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
} else {
// pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
parts.ringOuter = svgEl('circle', {
class: 'gev-anno-ring gev-pulse',
fill: 'none',
stroke: c,
'stroke-width': '2',
r: '34',
});
parts.ringInner = svgEl('circle', {
class: 'gev-anno-ring',
fill: c,
'fill-opacity': '0.12',
stroke: c,
'stroke-width': '2.5',
r: '18',
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.ringOuter);
group.appendChild(parts.ringInner);
}
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
parts.dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: anno.type === 'label' ? '4' : '5',
});
group.appendChild(parts.dot);
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
parts.leader = svgEl('line', {
class: 'gev-anno-leader',
stroke: c,
'stroke-width': '1.5',
'stroke-opacity': '0.7',
});
group.appendChild(parts.leader);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
// any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener('transitionend', (e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
}, { once: true });
drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
},
{ once: true },
);
}
projectAll();
} catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id);
try { group.remove(); } catch { /* never inserted */ }
try {
group.remove();
} catch {
/* never inserted */
}
if (records.size === 0) heightCache.clear();
throw error;
}
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
const world = Cesium.Cartesian3.fromDegrees(
lon,
lat,
groundHeight(lon, lat),
);
// Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
return null;
// Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null;
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
scratch,
);
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
// is unioned for aircraft/satellites that also own a native tracked graphic.
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const _scratchTrackedWin = new Cesium.Cartesian2();
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const world = typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
const baseH = bb.height?.getValue?.(now) ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
if (sbd)
scale = nearFarValue(
sbd,
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
);
const hw = (baseW * scale) / 2;
const hh = (baseH * scale) / 2;
const bbLeft = win.x - hw;
const bbRight = win.x + hw;
const bbTop = win.y - hh;
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
if (![left, right, top, bottom].every(Number.isFinite)) return null;
const m = TRACKED_BBOX_MARGIN;
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
return {
left: left - m,
right: right + m,
top: top - m,
bottom: bottom + m,
};
}
function projectAll() {
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
const mScale = markScale(
viewer.camera.positionCartographic?.height ?? 1000,
);
for (const rec of records.values()) {
const { anno, group, parts } = rec;
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
if (anno.type === 'area' && parts.poly) {
const pts = [];
let cx = 0; let cy = 0; let n = 0;
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x; cy += p.y; n++;
cx += p.x;
cy += p.y;
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
} else {
group.style.display = 'none';
}
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
const b = project(anno.to.lon, anno.to.lat);
if (a && b) {
// gentle arc so the connector reads as a drawn gesture, not a ruler line
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
const dx = b.x - a.x; const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / len) * bow;
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
parts.path.setAttribute(
'd',
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
);
group.style.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
if (parts.label)
positionCallout(
parts.label,
mx + (ctrlX - mx) * 0.5,
my + (ctrlY - my) * 0.5 - 8,
true,
);
} else {
group.style.display = 'none';
}
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
// dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => {
const p = projected[i];
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
else { dot.style.display = 'none'; }
if (p) {
dot.setAttribute('cx', p.x.toFixed(1));
dot.setAttribute('cy', p.y.toFixed(1));
dot.style.display = '';
} else {
dot.style.display = 'none';
}
});
group.style.display = '';
// label at the middle waypoint
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
const mid =
projected.filter(Boolean)[
Math.floor(projected.filter(Boolean).length / 2)
];
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else {
group.style.display = 'none';
}
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
if (p) {
group.style.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
if (parts.dot) {
parts.dot.setAttribute('cx', p.x.toFixed(1));
parts.dot.setAttribute('cy', p.y.toFixed(1));
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
}
if (parts.ringOuter) {
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
parts.ringOuter.setAttribute(
'r',
(RING_OUTER_R * mScale).toFixed(1),
);
}
if (parts.ringInner) {
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
parts.ringInner.setAttribute(
'r',
(RING_INNER_R * mScale).toFixed(1),
);
}
if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
const lx = p.x + 18 * mScale;
const ly = p.y - 34 * mScale;
positionCallout(parts.label, lx, ly, false);
if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1));
parts.leader.setAttribute(
'y2',
(ly + parts.label.height).toFixed(1),
);
parts.label._leader = parts.leader; // so de-collision can re-point it
}
}
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
const placed = [];
for (const rec of records.values()) {
const cal = rec.parts.label;
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
}
}
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
let overlapping = false;
if (trackedRect) {
let bb = null;
try { bb = group.getBBox(); } catch { bb = null; }
try {
bb = group.getBBox();
} catch {
bb = null;
}
if (bb && (bb.width !== 0 || bb.height !== 0)) {
// HYSTERESIS: a mark that's already hidden must clear the rect by
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
// motion), which was the source of the flicker.
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
overlapping =
bb.x <= trackedRect.right + m &&
bb.x + bb.width >= trackedRect.left - m &&
bb.y <= trackedRect.bottom + m &&
bb.y + bb.height >= trackedRect.top - m;
}
}
const target = overlapping ? 0 : 1;
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) {
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
group.setAttribute(
'opacity',
String((anno.alpha ?? 1) * rec._trackedFade),
);
}
}
}
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
rec.group.classList.remove('gev-in');
rec.group.classList.add('gev-out');
const node = rec.group;
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
records.delete(anno.id);
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
}
function destroy() {
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
try { layer.remove(); } catch { /* gone */ }
try {
scene.postRender.removeEventListener(onPostRender);
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
}
records.clear();
heightCache.clear();
}
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
node.appendChild(rect);
node.appendChild(accent);
node.appendChild(t);
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
const callout = {
node,
rect,
text: t,
accent,
width: 0,
height: 26,
sized: false,
};
// size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout));
return callout;
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
callout.width = w;
callout.height = h;
callout.sized = true;
} catch { /* not laid out yet */ }
} catch {
/* not laid out yet */
}
}
function positionCallout(callout, x, y, center) {
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x;
callout._x = tx;
callout._y = y;
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
callout.node.setAttribute(
'transform',
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
);
}
/**
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) {
a._y = b._y + b.height + 5;
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
a.node.setAttribute(
'transform',
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
);
if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1));
+216 -109
View File
@@ -30,7 +30,20 @@ const PALETTE = {
red: '#ff6b6b',
};
const CLASSIFY = Cesium.ClassificationType.CESIUM_3D_TILE;
/**
* What a draped mark is allowed to paint onto.
*
* This was CESIUM_3D_TILE, which is right with Google's photoreal tiles under
* the camera and wrong everywhere else: on a keyless boot (Esri or OSM imagery
* on Cesium's own globe) there are no 3D tiles to classify against, so every
* area fill, area outline, route and arrow rendered to nothing. The labels
* showed and the geometry did not, which reads as a broken feature rather than
* an unsupported surface — and it hit spoken annotations exactly as hard as
* hand-drawn ones.
*
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
*/
const CLASSIFY = Cesium.ClassificationType.BOTH;
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
export function createWorldAnnotationRenderer(viewer) {
@@ -45,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered();
function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
return Cesium.Color.fromCssColorString(
PALETTE[anno.color] || PALETTE.primary,
);
}
// Target-ring radius (meters) scaled to camera height so it reads at any
@@ -73,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = [];
anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
@@ -81,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
},
}));
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}));
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
classificationType: CLASSIFY,
},
}));
entities.push(dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
anno.ring.flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, {
alpha: anno.synthesized ? 0.1 : 0.2,
pulse: true,
}),
),
classificationType: CLASSIFY,
},
}),
);
entities.push(
dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({
color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// A real path (street-following) draped on the 3D tiles, with dashes that
// FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]),
);
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
@@ -175,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
];
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(
liveColor(anno, base, { alpha: 0.95 }),
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) {
const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
};
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}),
);
}
} else {
// pin / highlight / label — a camera-proportional target ring + a marker.
@@ -200,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
// Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
outline: false,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(
anno.anchor.lon,
anno.anchor.lat,
),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false,
classificationType: CLASSIFY,
},
}),
);
}
entities.push(labelMarker(anno, base, { point: true }));
}
@@ -219,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
function labelMarker(anno, base, { point }) {
return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
point: point
? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: undefined,
label: anno.label ? labelGraphic(anno, base) : undefined,
});
}
@@ -243,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true,
backgroundColor: new Cesium.CallbackProperty(
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
() =>
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
0.72 * (anno.alpha ?? 1),
),
false,
),
backgroundPadding: new Cesium.Cartesian2(8, 5),
@@ -323,7 +401,7 @@ let _flowFabricRegistered = false;
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
* can build the material the render pipeline uses. Constructing one Material with the
* fabric caches it under its type name. */
function ensureFlowFabricRegistered() {
export function ensureFlowFabricRegistered() {
if (_flowFabricRegistered) return;
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
_flowFabricRegistered = true;
@@ -339,13 +417,21 @@ function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
definitionChanged: { get() { return this._definitionChanged; } },
isConstant: {
get() {
return false;
},
}, // re-evaluated each frame → it animates
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow';
@@ -381,10 +467,17 @@ function decimateRing(ring, n) {
* tiles under those points aren't loaded yet.
*/
function sampleGroundOutside(scene, ring) {
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latS = 111320;
@@ -394,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
}
const out = maxR * 1.5 + 12;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[0.7, 0.7],
[-0.7, -0.7],
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS;
@@ -405,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch { /* tile not ready */ }
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
@@ -420,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
function bufferRing(ring, meters) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latScale = 111320;
+59
View File
@@ -0,0 +1,59 @@
const CONTROL_LAYER_IDS = Object.freeze({
trafficLayer: 'traffic',
flightsLayer: 'flights',
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
aisLiveVesselsLayer: 'ais-live-vessels',
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
});
/** Capture the ordered application instances and their serialization metadata. */
export function createLayerCatalog(layers, metadata) {
if (!Array.isArray(layers) || !Array.isArray(metadata))
throw new TypeError(
'Layer instances and serialization metadata are required',
);
const byId = new Map();
for (const layer of layers) {
if (typeof layer?.id !== 'string' || !layer.id || byId.has(layer.id))
throw new TypeError(`Invalid or duplicate catalog layer: ${layer?.id}`);
byId.set(layer.id, layer);
}
const ids = new Set();
for (const entry of metadata) {
if (!byId.has(entry?.id) || ids.has(entry.id))
throw new TypeError(
`Unmatched or duplicate catalog metadata: ${entry?.id}`,
);
ids.add(entry.id);
}
if (ids.size !== byId.size)
throw new TypeError('Catalog metadata is incomplete');
return Object.freeze({
layers: Object.freeze([...layers]),
metadata: Object.freeze(
metadata.map((entry) => Object.freeze({ ...entry })),
),
get: (id) => byId.get(id),
});
}
/** Bind the current control surface to the exact instances registered by the app. */
export function catalogControlServices(catalog) {
if (!catalog?.get)
throw new TypeError('An application layer catalog is required');
return Object.fromEntries(
Object.entries(CONTROL_LAYER_IDS).map(([role, id]) => {
const layer = catalog.get(id);
if (!layer)
throw new TypeError(`Control layer missing from catalog: ${id}`);
return [role, layer];
}),
);
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLayerCatalog, catalogControlServices } from './catalog.js';
import { createApplicationData } from './data.js';
const metadata = (layers) =>
layers.map(({ id }) => ({ id, disposition: 'enabled-only' }));
test('catalog captures membership and metadata while preserving the supplied instances', () => {
const first = { id: 'first' };
const layers = [first];
const schema = metadata(layers);
const catalog = createLayerCatalog(layers, schema);
layers.push({ id: 'later' });
schema[0].disposition = 'invalid';
assert.deepEqual(catalog.layers, [first]);
assert.equal(catalog.get('first'), first);
assert.equal(catalog.get('later'), undefined);
assert.equal(catalog.metadata[0].disposition, 'enabled-only');
assert.throws(() => catalog.layers.push({ id: 'later' }), TypeError);
});
test('duplicate, missing and extra metadata fail before application registration', () => {
const layers = [{ id: 'first' }];
assert.throws(
() => createLayerCatalog([...layers, ...layers], metadata(layers)),
/duplicate/,
);
assert.throws(() => createLayerCatalog(layers, []), /incomplete/);
assert.throws(
() => createLayerCatalog(layers, metadata([{ id: 'other' }])),
/Unmatched/,
);
assert.throws(
() =>
createLayerCatalog(layers, [...metadata(layers), ...metadata(layers)]),
/duplicate/,
);
});
test('data setup seals the caller catalog before controls can restore and drains its exact instances', async (t) => {
const previous = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const attached = [];
const layers = [
{
id: 'one',
attachDataManager: (manager) => attached.push([...manager.layers.keys()]),
},
{ id: 'two' },
];
const releases = [];
let observed;
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: {
styleManager: {
attachDataManager(manager) {
assert.equal(manager.registrationsFinalized, true);
assert.equal(manager.layers.get('one').module, layers[0]);
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
for (const release of releases.reverse()) await release();
assert.equal(dataManager.layers.size, 0);
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
'flights',
'military',
'satellites',
'cctv',
'radio',
'bikeshare',
'transit',
'ais-live-vessels',
'military-awareness',
'military-installations',
'rocket-launches',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
const services = catalogControlServices(catalog);
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
/Control layer missing/,
);
});
+149
View File
@@ -0,0 +1,149 @@
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
import { createApplicationMilitary } from './layers/militaryFlights.js';
import { createApplicationVessels } from './layers/aisLiveVessels.js';
import { createApplicationCctv } from './layers/cctv.js';
import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
import { createApplicationAlpr } from './layers/alprCameras.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
military: ['getSnapshot'],
vessels: ['getSnapshot'],
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
radio: ['getDirectory', 'recordClick'],
traffic: [
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
bikeshare: ['getStations'],
installations: ['getMappedSites', 'searchNearby'],
satellites: ['readGroup'],
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
});
/** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails.
*/
export function createApplicationCatalog({
surface,
sources,
signal,
metadata = LAYER_STATE_REGISTRY,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
signal.throwIfAborted();
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
)
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
};
signal.addEventListener('abort', dispose, { once: true });
try {
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
});
const military = createApplicationMilitary({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
});
const vessels = createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
});
const installations = createApplicationInstallations({
surface,
source: sources.installations,
});
const satellites = createApplicationSatellites({
source: sources.satellites,
});
const catalog = createLayerCatalog(
[
createBhoteKoshiEventLayer(),
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
}),
flights,
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAlpr({ surface, source: sources.alpr }),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
vessels,
installations,
createApplicationAwareness({
flights,
military,
vessels,
installations,
}),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
feed: sources.firms,
}),
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
} catch (error) {
dispose();
throw error;
}
}
+119
View File
@@ -0,0 +1,119 @@
import { createSurfaceServices } from './surfaceServices.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
sources.military = {
getSnapshot: async () => {
throw new Error('Use identity acquisition');
},
async getIdentities(_query, { signal }) {
calls.push(signal);
return ids;
},
};
return sources;
}
test('catalogs construct distinct layers and classification from their supplied source', async (t) => {
const a = new AbortController();
const b = new AbortController();
t.after(() => {
a.abort();
b.abort();
});
const callsA = [];
const callsB = [];
const first = createApplicationCatalog({
sources: fixtureSources(['abc123'], callsA),
signal: a.signal,
surface: fixtureSurface(a.signal),
});
const second = createApplicationCatalog({
sources: fixtureSources(['def456'], callsB),
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1),
['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'],
);
assert.ok(first.get('bhote-koshi-2026'));
assert.ok(first.get('bhote-koshi-locator'));
const lifecycle = new LayerLifecycle({});
for (const layer of first.layers) lifecycle.register(layer);
const rows = lifecycle.getAll();
for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) {
assert.equal(
rows.find((row) => row.id === id)?.showInTogglePanel,
false,
`${id} remains registered for Scenes but is absent from Data Layers`,
);
assert.equal(typeof first.get(id).enable, 'function');
assert.equal(typeof first.get(id).setParams, 'function');
}
assert.equal(
rows.find((row) => row.id === 'flights')?.showInTogglePanel,
true,
'ordinary data layer entries remain visible',
);
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
);
for (const layer of first.layers)
assert.notEqual(layer, second.get(layer.id));
assert.equal(
catalogControlServices(first).satellitesLayer,
first.get('satellites'),
);
assert.equal(callsA.length, 0, 'construction must not acquire');
await first.militaryRegistry.refreshMilitaryRegistryIfStale();
await second.militaryRegistry.refreshMilitaryRegistryIfStale();
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), true);
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
});
test('invalid or already cancelled construction fails before classification can acquire', () => {
const lifetime = new AbortController();
assert.throws(
() =>
createApplicationCatalog({
sources: {},
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
/catalog source/,
);
lifetime.abort();
assert.throws(
() =>
createApplicationCatalog({
sources: createStandaloneLayerSources(),
signal: lifetime.signal,
}),
{ name: 'AbortError' },
);
});
function fixtureSurface(signal) {
return createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal,
eventTarget: null,
});
}
+51
View File
@@ -0,0 +1,51 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
loaderStatus,
Controls = StyleManager,
services,
catalog,
placeSearch,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
services: {
...services,
...operations.surface.controlServices,
searchAndFlyTo: operations.searchAndFlyTo,
fetchRegionalBrief: (...args) =>
operations.requests.regional.getBrief(...args),
...catalogControlServices(catalog),
},
requestServices: operations.requests,
mapStackController,
placeSearch,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer, {
weatherService: operations.requests.weather,
});
defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
}
return { styleManager, weatherEffects, cockpitCloudEffects };
}
+58
View File
@@ -0,0 +1,58 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
controls: { styleManager },
catalog,
allowQaRegistration,
onData,
defer,
}) {
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
});
defer(async () => {
await dataManager.destroyAll();
if (dataManager.layers.size)
throw new Error(
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register)
delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister)
delete window.__gevQaUnregisterLayer;
});
}
presentation.mount(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
return { dataManager, catalog, presentation };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Extract a human-readable error message from any thrown value.
* Handles Error objects, strings, and plain objects with message/error fields.
* @param {*} error — caught exception value
* @returns {string} best-effort error description
*/
export function describeError(error) {
if (!error) return 'Unknown initialization error';
if (error instanceof Error) {
if (error.message && error.message.trim()) return error.message.trim();
return error.name || 'Initialization error';
}
if (typeof error === 'string' && error.trim()) return error.trim();
if (typeof error === 'object') {
const maybeMessage = String(error.message || error.error || '').trim();
if (maybeMessage) return maybeMessage;
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== '{}') return serialized;
} catch {
// ignore serialization error
}
}
return String(error);
}
+85
View File
@@ -0,0 +1,85 @@
import { LayerPanel } from '../ui/layers.js';
import { governorRequestRender } from '../renderGovernor.js';
import { markDetectionSourcesChanged } from '../data/detection.js';
/** Own the layer panel and application reactions to lifecycle activity. */
export class LayerPresentation {
constructor(
manager,
{
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
if (change.type === 'status') this.refresh();
else if (change.type === 'destroy-all') this.destroy();
else {
const reason =
change.type === 'data-updated'
? `layer-tick:${change.layerId}`
: change.type === 'visibility-settled'
? 'layer-visibility'
: change.type === 'params-settled'
? `layer-params:${change.layerId}`
: null;
if (!reason) return;
requestRender(reason);
if (change.type !== 'params-settled') invalidateDetection(reason);
}
});
}
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
this.manager.setEnabled(id, enabled, options),
setLayerParams: (id, params, options) =>
this.manager.setLayerParams(id, params, options),
getRowControls: (id) => {
const module = this.manager.layers.get(id)?.module;
try {
return module?.getRowControls?.() || null;
} catch (error) {
console.warn(`[Data] ${id} getRowControls error:`, error);
return null;
}
},
hasRowControls: (id) =>
typeof this.manager.layers.get(id)?.module?.getRowControls ===
'function',
subscribeRowControls: (id, listener) => {
const module = this.manager.layers.get(id)?.module;
module?.setRowControlsListener?.(listener);
return () => module?.setRowControlsListener?.(null);
},
onHiddenRefresh: () => {
this.pendingVisible = true;
},
});
return this._panel;
}
mount(container) {
this.panel.mount(container);
}
refresh() {
this._panel?._refreshTogglePanel();
}
flushVisible() {
if (!this.pendingVisible) return;
this.pendingVisible = false;
this.refresh();
}
destroy() {
this._panel?.destroy();
this._panel = null;
this.pendingVisible = false;
this._unsubscribe?.();
this._unsubscribe = null;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { createVesselLayer } from '../../layers/vessels/index.js';
import * as context from '../../data/contextStore.js';
import * as trails from '../../data/trailRenderer.js';
import * as labels from '../../data/detectionDraw.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlay from '../../overlays/worldOverlay.js';
import * as geoid from '../../data/geoid.js';
import * as sprites from '../../data/spriteOrder.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
return createVesselLayer({
source,
options,
services: {
context,
trails,
labels,
picking,
overlay,
geoid,
sprites,
focus,
worldFocus,
render,
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import { registerWorldOverlayPaintLane } from '../../overlays/worldOverlay.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationAlpr({ surface, source }) {
const { groundFloor } = surface;
return createAlprCamerasLayer({
source,
services: {
render,
context,
picking,
groundFloor,
overlays: {
registerPaintLane: registerWorldOverlayPaintLane,
keyholeAlpha: keyholeLabelAlphaFromGeometry,
refreshReadout: refreshTrackedReadout,
subscribeMapStack(callback) {
window.addEventListener('gev:map-stack-changed', callback);
return () =>
window.removeEventListener('gev:map-stack-changed', callback);
},
},
},
});
}
+13
View File
@@ -0,0 +1,13 @@
import { createBikeshareLayer } from '../../layers/bikeshare/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) {
return createBikeshareLayer({
source,
services: { render, sprites, picking, overlays },
});
}
+28
View File
@@ -0,0 +1,28 @@
import { createCctvLayer } from '../../layers/cctv/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationCctv({ surface, source }) {
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
return createCctvLayer({
source,
services: {
sprites,
activation,
overlays,
locations,
picking,
terrain,
ground,
mesh,
focus,
render,
},
});
}
+12
View File
@@ -0,0 +1,12 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
}
+6
View File
@@ -0,0 +1,6 @@
import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire earthquake observations to the application overlay host. */
export function createApplicationEarthquakes(options) {
return createEarthquakesLayer({ overlayHost, ...options });
}
+36
View File
@@ -0,0 +1,36 @@
import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as focus from '../../worldFocus.js';
export const firmsServices = {
render,
sprites,
context,
picking,
overlays,
focus,
};
/** Bind a supplied fire feed to the application scene service owners. */
export function createApplicationFirms({ surface, ...options }) {
return createLayer({
...options,
icon: options.icon ?? '▲',
source: options.source ?? 'NASA FIRMS',
feed: options.feed,
services: { ...firmsServices, anchors: surface.anchors },
overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory:
options.screenSpaceEventHandlerFactory ??
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
});
}
+46
View File
@@ -0,0 +1,46 @@
import { createCivilFlightLayer } from '../../layers/flights/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as sprites from '../../data/spriteOrder.js';
import * as trails from '../../data/trailRenderer.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as camera from '../../data/trackedCamera.js';
import * as labels from '../../data/detectionDraw.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as readout from '../../data/trackedReadout.js';
import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
return createCivilFlightLayer({
source,
resolveAsset,
services: {
picking,
sprites,
trails,
aircraftPresentation,
camera,
militaryRegistry,
labels,
groundFloor,
meshFloor,
geoid,
focus,
readout,
context,
render,
groundSnap,
recession,
},
});
}
+23
View File
@@ -0,0 +1,23 @@
import { createAwarenessLayer } from '../../layers/awareness/index.js';
import * as navigation from '../../navigationPolicy.js';
import * as geometry from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/** Bind Contacts to the same catalog instances used for rendering. */
export function createApplicationAwareness({
flights,
military,
vessels,
installations,
}) {
return createAwarenessLayer({
services: {
flights,
military,
vessels,
installations,
navigation,
geometry,
render,
},
});
}
+46
View File
@@ -0,0 +1,46 @@
import { createMilitaryFlightLayer } from '../../layers/military/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as sprites from '../../data/spriteOrder.js';
import * as trails from '../../data/trailRenderer.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as camera from '../../data/trackedCamera.js';
import * as labels from '../../data/detectionDraw.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as readout from '../../data/trackedReadout.js';
import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
return createMilitaryFlightLayer({
source,
resolveAsset,
services: {
picking,
sprites,
trails,
aircraftPresentation,
camera,
militaryRegistry,
labels,
groundFloor,
meshFloor,
geoid,
focus,
readout,
context,
render,
groundSnap,
recession,
},
});
}
+13
View File
@@ -0,0 +1,13 @@
import { createInstallationsLayer } from '../../layers/installations/index.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({
source,
services: { render, context, ground, anchors, picking },
});
}
+10
View File
@@ -0,0 +1,10 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
export const overlayHost = Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
});
+14
View File
@@ -0,0 +1,14 @@
import { createRadioLayer } from '../../layers/radio/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationRadio({ surface, source }) {
const { groundFloor: ground } = surface;
return createRadioLayer({
source,
services: { ground, picking, overlays, globe, render },
});
}
+12
View File
@@ -0,0 +1,12 @@
import { createRocketLaunchesLayer } from '../../layers/launches/index.js';
import * as geometry from '../../celestialRing.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({ source, satellites }) {
return createRocketLaunchesLayer({
source,
services: { satellites, geometry, overlays, render },
});
}
+24
View File
@@ -0,0 +1,24 @@
import { createSatellitesLayer } from '../../layers/satellites/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as readout from '../../data/trackedReadout.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as layerState from '../../data/layerState.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationSatellites({ source }) {
return createSatellitesLayer({
source,
services: {
picking,
focus,
readout,
overlays,
context,
render,
layerState,
},
});
}
+13
View File
@@ -0,0 +1,13 @@
import * as Cesium from 'cesium';
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire cable source geometry to the application overlay host. */
export function createApplicationCables(options) {
return createSubmarineCableLayer({
overlayHost,
screenSpaceEventHandlerFactory: (canvas) =>
new Cesium.ScreenSpaceEventHandler(canvas),
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
...options,
});
}
+11
View File
@@ -0,0 +1,11 @@
import { createTrafficLayer } from '../../layers/traffic/index.js';
import * as credits from '../../data/dataCredits.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationTraffic({ source }) {
return createTrafficLayer({
source,
services: { credits, render },
});
}
+49
View File
@@ -0,0 +1,49 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import {
registerDynamicCredit,
transitFeedCredit,
} from '../../data/dataCredits.js';
/**
* Construct one layer using the application scene owners and the
* application's surface services.
*
* The floor comes from `surface.groundFloor` — the ONE instance aircraft,
* trails, fires and camera geometry stand on — and the rendered-mesh sampler
* from `surface.meshFloor`, so a mesh floor the application resolves for a
* cell is the floor transit draws on, and a transit-only session warms its
* own mesh floors. An earlier cut imported the legacy module-level singleton
* instead and could not see either.
*
* @param {{surface: object}} options
* @returns {object}
*/
export function createApplicationTransit({ surface, source }) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface;
return createTransitLayer({
source,
services: {
render,
sprites,
picking,
overlays,
credits: { registerDynamicCredit, transitFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
cachedGroundFloor: groundFloor.cachedGroundFloor,
coarseFloorCoord: groundFloor.coarseFloorCoord,
neighborFloorM: groundFloor.neighborFloorM,
warmGroundFloor: groundFloor.warmGroundFloor,
corridorFloorCells: groundFloor.corridorFloorCells,
allocateCorridorCells: groundFloor.allocateCorridorCells,
},
mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
},
});
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSurfaceServices } from '../surfaceServices.js';
import { createApplicationTransit } from './transit.js';
import { createApplicationCatalog } from '../constructCatalog.js';
import { createStandaloneLayerSources } from '../../standalone/layerSources.js';
/** A terrain source that answers every point with one ellipsoidal height. */
function fixtureSurface(signal, demM) {
return createSurfaceServices({
terrainSource: {
getHeights: async (chunk) => chunk.map(() => ({ ellipsoid: demM })),
},
signal,
eventTarget: null,
});
}
test('transit reads the mesh floor the application resolved, through the application surface', async (t) => {
// The floor transit draws on is the ONE the application constructs — the
// instance aircraft and camera geometry stand on — not a module-level copy.
// An earlier cut imported the legacy singleton and could not see a mesh
// floor the app had already resolved for a cell; it drew on bare earth.
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const surface = fixtureSurface(lifetime.signal, 20);
const layer = createApplicationTransit({ surface });
const height = layer._transitPartsForTest().height;
const lat = 42.3601;
const lon = -71.0589;
// Nothing warm yet: the layer has no floor and no prior to borrow.
assert.equal(height.requestHeight(lat, lon).height, null);
// The application resolves the DEM for that cell...
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
const lift = surface.groundFloor.GROUND_FLOOR_LIFT_M;
assert.equal(
height.requestHeight(lat, lon).height,
20 + lift,
'the DEM floor, lifted',
);
// ...and then a rendered-mesh sample lands on the same cell, seventeen
// metres above bare earth, the way a photogrammetric street sits.
assert.equal(
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 37),
true,
);
assert.equal(
surface.groundFloor.cachedGroundFloor(lat, lon),
37,
'the app now floors on the mesh',
);
assert.equal(
height.requestHeight(lat, lon).height,
37 + lift,
'and so does transit — the same number',
);
// Leaving the photoreal regime drops back to the DEM for everyone at once.
surface.groundFloor.setMeshFloorPreferred(false);
assert.equal(height.requestHeight(lat, lon).height, 20 + lift);
});
test('the catalog hands transit the same surface it hands everything else', async (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const surface = fixtureSurface(lifetime.signal, 5);
const catalog = createApplicationCatalog({
sources: createStandaloneLayerSources(),
signal: lifetime.signal,
surface,
});
const transit = catalog.get('transit');
assert.ok(transit, 'transit is in the catalog');
const lat = 30.2672;
const lon = -97.7431;
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 22);
assert.equal(
transit._transitPartsForTest().height.requestHeight(lat, lon).height,
22 + surface.groundFloor.GROUND_FLOOR_LIFT_M,
);
});
test('transit refuses to build without the application surface', () => {
assert.throws(() => createApplicationTransit({}), /surface/);
assert.throws(
() => createApplicationTransit({ surface: { groundFloor: {} } }),
/surface/,
);
});
+25
View File
@@ -0,0 +1,25 @@
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from '../data/contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
+49
View File
@@ -0,0 +1,49 @@
import { requireFeatureSource } from '../sources/featureSource.js';
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { searchAndFlyTo } from '../locations.js';
/** Assemble application operations from the caller's request services. */
export function createApplicationOperations({ requests, signal, eventTarget }) {
for (const [name, method] of Object.entries({
terrain: 'getHeights',
regional: 'getBrief',
weather: 'getConditions',
summary: 'summarize',
})) {
if (typeof requests?.[name]?.[method] !== 'function')
throw new TypeError(`Missing application request service: ${name}`);
}
const features = requireFeatureSource(
requests.features ??
createOverpassFeatureSource({
boundarySource: requests.boundaries,
signal,
}),
);
const surface = createSurfaceServices({
terrainSource: requests.terrain,
signal,
eventTarget,
});
const annotationResolver = createAnnotationResolver({
featureSource: features,
signal,
});
return Object.freeze({
requests,
surface,
annotationResolver,
searchAndFlyTo: (viewer, query, options = {}) =>
searchAndFlyTo(viewer, query, {
...options,
features,
signal:
signal && options.signal
? AbortSignal.any([signal, options.signal])
: signal || options.signal,
recoverNearView: annotationResolver.placesNearViewRecovery,
}),
});
}
+184
View File
@@ -0,0 +1,184 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationOperations } from './operations.js';
function requests(terrain, boundaries = { query: async () => [] }) {
return {
terrain,
boundaries,
regional: { getBrief: async () => ({}) },
weather: { getConditions: async () => ({}) },
summary: { summarize: async () => ({}) },
};
}
test('surface cache and stack listeners belong to the supplied application lifetime', async (t) => {
const a = new AbortController();
const b = new AbortController();
t.after(() => {
a.abort();
b.abort();
});
const target = new EventTarget();
let calls = 0;
const first = createApplicationOperations({
signal: a.signal,
eventTarget: target,
requests: requests({
async getHeights(points) {
calls++;
return points.map(() => ({ ellipsoid: 123 }));
},
}),
});
const second = createApplicationOperations({
signal: b.signal,
eventTarget: target,
requests: requests({
async getHeights(points) {
return points.map(() => ({ ellipsoid: 456 }));
},
}),
});
const point = { lat: 30, lon: -97 };
await first.surface.terrain.resolveEllipsoidalGround([point]);
await first.surface.terrain.resolveEllipsoidalGround([point]);
await second.surface.terrain.resolveEllipsoidalGround([point]);
assert.equal(calls, 1);
assert.equal(first.surface.groundFloor.cachedGroundFloor(30, -97), 123);
assert.equal(second.surface.groundFloor.cachedGroundFloor(30, -97), 456);
target.dispatchEvent(
new CustomEvent('gev:map-stack-changed', {
detail: { activeId: 'esri-imagery' },
}),
);
assert.equal(first.surface.groundFloor.meshFloorPreferred(), false);
a.abort();
assert.equal(first.surface.terrain.cachedEllipsoidalGround(30, -97), null);
target.dispatchEvent(
new CustomEvent('gev:map-stack-changed', {
detail: { activeId: 'photoreal' },
}),
);
assert.equal(
first.surface.groundFloor.meshFloorPreferred(),
false,
'disposed listener cannot update the old owner',
);
assert.equal(second.surface.groundFloor.meshFloorPreferred(), true);
});
test('a terrain reply after application abort cannot refill the cache or become a fallback', async () => {
const lifetime = new AbortController();
let respond;
let receivedSignal;
const operations = createApplicationOperations({
signal: lifetime.signal,
eventTarget: null,
requests: requests({
getHeights(_points, { signal }) {
receivedSignal = signal;
return new Promise((resolve) => {
respond = resolve;
});
},
}),
});
const pending = operations.surface.terrain.resolveEllipsoidalGround([
{ lat: 30, lon: -97 },
]);
lifetime.abort();
respond([{ ellipsoid: 123 }]);
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(receivedSignal.aborted, true);
assert.equal(
operations.surface.terrain.cachedEllipsoidalGround(30, -97),
null,
);
});
test('annotation lookup and cached outlines use the supplied source independently', async (t) => {
const priorWindow = globalThis.window;
globalThis.window = { setTimeout, clearTimeout };
const a = new AbortController();
const b = new AbortController();
t.after(() => {
a.abort();
b.abort();
if (priorWindow === undefined) delete globalThis.window;
else globalThis.window = priorWindow;
});
const calls = [0, 0];
const make = (signal, index) =>
createApplicationOperations({
signal,
eventTarget: null,
requests: requests(
{ getHeights: async () => [] },
{
query: async () => {
calls[index]++;
const delta = index * 0.0001;
return [
{
type: 'way',
id: index + 1,
tags: { building: 'yes', name: 'Source fixture hall' },
geometry: [
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
{ lat: 30 - 0.0001, lon: -97 + 0.0001 + delta },
{ lat: 30 + 0.0001, lon: -97 + 0.0001 + delta },
{ lat: 30 + 0.0001, lon: -97 - 0.0001 + delta },
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
],
},
];
},
},
),
});
const first = make(a.signal, 0);
const second = make(b.signal, 1);
const query = {
viewer: {},
target: 'Source fixture hall',
latitude: 30,
longitude: -97,
footprint: true,
entityKind: 'building',
};
const resultA = await first.annotationResolver.resolveAnnotationTarget(query);
const resultB =
await second.annotationResolver.resolveAnnotationTarget(query);
assert.ok(resultA.ring?.length >= 4);
assert.ok(resultB.ring?.length >= 4);
assert.notDeepEqual(resultA.ring, resultB.ring);
const before = calls[0];
const cached = await first.annotationResolver.resolveAnnotationTarget(query);
assert.deepEqual(cached.ring, resultA.ring);
assert.equal(calls[0], before);
a.abort();
await assert.rejects(
first.annotationResolver.resolveAnnotationTarget(query),
{ name: 'AbortError' },
);
});
test('invalid feature adapters fail before surface listeners are installed', () => {
let installed = 0;
assert.throws(
() =>
createApplicationOperations({
signal: new AbortController().signal,
requests: { ...requests({ getHeights: async () => [] }), features: {} },
eventTarget: {
addEventListener() {
installed++;
},
removeEventListener() {},
},
}),
/Missing feature operation/,
);
assert.equal(installed, 0);
});
+119
View File
@@ -0,0 +1,119 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js';
import {
uninstallRenderGovernor,
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
requestServices,
googleApiKey,
cesiumToken,
credits,
MapController = MapStackController,
mapOptions = {},
loaderStatus,
signal,
defer,
}) {
const operations = createApplicationOperations({
requests: requestServices,
signal,
});
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
defer(() => {
if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return;
if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__;
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
});
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
cesiumToken,
});
const tileset = photoreal.tileset;
// A provider can finish after cancellation; retain ownership of its result.
defer(() => {
if (tileset && !tileset.isDestroyed()) {
if (!viewer.scene.primitives.remove(tileset)) tileset.destroy();
}
});
signal.throwIfAborted();
if (tileset) {
viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else {
if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1);
console.warn(
'[Init] Google 3D Tiles unavailable, using the keyless globe:',
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender,
...mapOptions,
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(
new CustomEvent('gev:map-stack-changed', { detail: state }),
);
},
onError: (message) => console.warn('[MapStack]', message),
});
defer(() => mapStackController.destroy());
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', {
silent: true,
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController, operations };
}
+2
View File
@@ -0,0 +1,2 @@
// Compatibility entry; source contracts are owned by sources/.
export { createSourceSlot } from '../sources/sourceSlot.js';
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSourceSlot } from './sourceSlot.js';
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
let finish;
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
const pending = slot.source.read();
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
old();
finish('stale');
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(await slot.source.read(), 'replacement');
assert.equal(slot.source.attribution.name, 'Replacement');
stop();
assert.throws(() => slot.source.read(), /not configured/);
});
test('an invalid replacement leaves the working source intact', async () => {
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
assert.throws(() => slot.configure({}), TypeError);
assert.equal(await slot.source.read(), 42);
});
+43
View File
@@ -0,0 +1,43 @@
import flights from '../data/flights.js';
import military from '../data/militaryFlights.js';
import vessels from '../data/aisLiveVessels.js';
import { configureAlprSource } from '../data/alprCameras.js';
import { configureCctvSource } from '../data/cctv.js';
import { configureRadioSource } from '../data/radio.js';
import { configureTrafficSource } from '../data/traffic.js';
import { configureBikeshareSource } from '../data/bikeshare.js';
import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
cctv: configureCctvSource,
radio: configureRadioSource,
traffic: configureTrafficSource,
bikeshare: configureBikeshareSource,
installations: configureInstallationSource,
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
layers = {},
live = {},
signal,
defer,
}) {
for (const [name, source] of Object.entries(layers)) {
if (!configure[name]) throw new TypeError(`Unknown layer source: ${name}`);
defer(configure[name](source));
}
if (live.flights) flights.setSource(live.flights);
if (live.military) {
military.setSource(live.military);
defer(configureMilitaryRegistrySource(live.military, { signal }));
}
if (live.vessels) vessels.setSource(live.vessels);
}
export const liveLayers = Object.freeze({ flights, military, vessels });
+54
View File
@@ -0,0 +1,54 @@
import { initFirstRunExperience } from '../firstRunExperience.js';
/** Reveal welcome controls only after restoration and the loading transition. */
export function startApplicationChrome({
loadingScreen,
styleManager,
dataManager,
signal,
initializeWelcome = initFirstRunExperience,
initializeSettings,
}) {
let disposed = false;
let firstRun;
let revealTimer;
let resolveDelay;
const minimumDelay = new Promise((resolve) => {
resolveDelay = resolve;
});
const delayTimer = setTimeout(resolveDelay, 1000);
const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return;
firstRun = initializeWelcome?.({ styleManager, dataManager });
clearTimeout(revealTimer);
loadingScreen.removeEventListener('transitionend', revealFirstRun);
};
void Promise.all([styleManager.initialRestorePromise, minimumDelay])
.catch(() => {
/* Restoration reports its own outcome through the controls. */
})
.then(() => {
if (disposed || signal.aborted) return;
loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true,
});
revealTimer = setTimeout(revealFirstRun, 900);
});
const keySetup = Promise.resolve(
signal.aborted ? null : initializeSettings?.({ signal }),
);
// Own the pending initializer too; it must not reveal a dialog after abort.
void keySetup.catch(() =>
console.error('Provider settings initialization failed'),
);
return async () => {
disposed = true;
clearTimeout(delayTimer);
clearTimeout(revealTimer);
resolveDelay();
loadingScreen.removeEventListener('transitionend', revealFirstRun);
firstRun?.destroy();
(await keySetup.catch(() => null))?.destroy();
};
}
+37
View File
@@ -0,0 +1,37 @@
import { createTerrainHeights } from '../services/terrainHeights.js';
import { createGroundFloor } from '../services/groundFloor.js';
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import { createGroundSnap } from '../services/groundSnap.js';
import { createFireAnchors } from '../layers/firms/anchors.js';
/** Own the DEM, coarse floors and mesh samples used by one application. */
export function createSurfaceServices({
terrainSource,
signal,
eventTarget = globalThis.window,
}) {
const terrain = createTerrainHeights({ source: terrainSource, signal });
const groundFloor = createGroundFloor({ terrain, signal });
const meshFloor = createMeshFloorSampler({
groundFloor,
signal,
eventTarget,
});
return Object.freeze({
terrain,
groundFloor,
meshFloor,
groundSnap: Object.freeze({
createGroundSnap: () => createGroundSnap({ groundFloor }),
}),
anchors: createFireAnchors(groundFloor),
controlServices: Object.freeze({
cachedGroundFloor: groundFloor.cachedGroundFloor,
cachedMeshFloor: groundFloor.cachedMeshFloor,
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
meshFloorPreferred: groundFloor.meshFloorPreferred,
warmGroundFloor: groundFloor.warmGroundFloor,
sampleMeshFloorCells: meshFloor.sampleMeshFloorCells,
}),
});
}
+141
View File
@@ -0,0 +1,141 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
/** Attach scene tools, rendering listeners and the application debug handle. */
export function createApplicationTools({
scene,
controls,
data,
loadingScreen,
placeSearch,
voice = {},
startChrome,
onSceneDirector,
sceneDataPacks,
signal,
defer,
}) {
const { viewer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks,
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
dataManager.layers
.get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
viewer,
tileset,
placeSearch,
resolver: operations.annotationResolver,
});
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
},
);
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
data.presentation.flushVisible();
governorRequestRender('visibility-restore');
}
};
document.addEventListener('visibilitychange', syncVisibilitySuspension);
defer(() =>
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
);
defer(() => {
removeTrackingListener();
releaseContinuousRender('tracked-entity');
});
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
surfaceServices: operations.surface,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
const voiceCommands = initGevVoiceCommands({
...voice,
floorServices: operations.surface.groundFloor,
annotationResolver: operations.annotationResolver,
searchNavigation: operations.searchAndFlyTo,
signal,
placeSearch,
viewer,
styleManager,
dataManager,
sceneDirector,
annotations,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands)
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
}
+28 -20
View File
@@ -1,3 +1,5 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
@@ -9,7 +11,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const ui = readShellSource();
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
@@ -18,8 +20,8 @@ const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.
function body(source, pattern, label) {
const name = pattern.source.match(/^(\w+)/)?.[1];
if (source === ui && typeof StyleManager.prototype[name] === 'function') {
const method = StyleManager.prototype[name].toString();
if (source === ui && typeof shellMethod(name) === 'function') {
const method = shellMethod(name).toString();
return method.slice(method.indexOf(') {') + 3, -1);
}
const match = source.match(pattern);
@@ -30,7 +32,8 @@ function body(source, pattern, label) {
function ordered(source, needles, label) {
let previous = -1;
for (const needle of needles) {
const index = source.indexOf(needle, previous + 1);
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index;
@@ -103,7 +106,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
'Cockpit entry transaction',
);
ordered(transaction, [
'if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {',
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
], 'Cockpit entry transaction');
// No direct camera control: every mutation goes through a layer tracker or
@@ -174,7 +177,7 @@ test('accepted navigation releases through PR15-aware ownership before flight',
'explicit navigation',
);
ordered(run, [
'cockpitActive: !!this.cockpitView?.active',
'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
@@ -199,11 +202,11 @@ test('accepted navigation releases through PR15-aware ownership before flight',
test('validated voice camera destinations share the UI navigation authority facade', () => {
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager, 'camera', 'move_camera', navigate, releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'route', 'fly_route', navigate/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'fire', 'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, family\.kind, 'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'frame', 'frame_overhead'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
const trackedVoice = voice.slice(
voice.indexOf('async function trackEntity'),
voice.indexOf('async function frameOverhead'),
@@ -223,25 +226,25 @@ test('validated voice camera destinations share the UI navigation authority faca
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
'const start = () => {',
"const preserveCameraFlight = motion === 'orbit'",
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
const route = body(
cameraVerbs,
/export function flyRoute\(annoList, args = \{\}, floorFn = null, runNavigation = null, warmFn = null\) \{([\s\S]*?)\n\}/,
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route',
);
ordered(route, [
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
"return typeof runNavigation === 'function' ? runNavigation(start) : start();",
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
], 'route validation before handoff');
// The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one
// place that binds both.
assert.match(voice, /\(lat, lon\) => cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => warmGroundFloor\(cells\),/);
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
});
test('deferred search releases only after its final authority check', () => {
@@ -266,7 +269,7 @@ test('deferred search releases only after its final authority check', () => {
test('a direct globe gesture retires delayed camera and selection restore only', () => {
assert.match(
ui,
/this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) => this\._stampNavigation\(options\)\);/,
/this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) =>\s*this\.navigation\._stampNavigation\(options\),?\s*\);/,
);
assert.match(
ui,
@@ -300,21 +303,26 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._stampNavigation();',
'this._removeWorldRequestFocusListener?.();',
'this._layerBindings.stop();',
'this._navigation.destroy();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
});
test('teardown synchronously closes immediate camera entry points', () => {
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
ordered(LayerBindings.prototype.stop.toString(), [
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
], 'camera bindings close synchronously');
const navigation = body(
ui,
+538 -145
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,3 +1,4 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -73,7 +74,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur
assert.match(diagnostics[0][0], /before setEnabled await/);
assert.match(diagnostics[1][0], /after setEnabled await/);
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const uiSource = readShellSource();
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
});
@@ -114,6 +115,6 @@ test('a disposed UI cannot activate a camera when enabling finishes late', async
release();
await pending;
assert.equal(activations, 0);
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const uiSource = readShellSource();
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
});
+18 -7
View File
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
) {
if (!cameraId || typeof activate !== 'function') return false;
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}));
eventTarget.dispatchEvent(
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}),
);
return true;
}
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
* @returns {() => void} Listener disposer.
*/
export function registerCctvFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|| typeof listener !== 'function') return () => {};
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
let disposed = false;
return () => {
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
*/
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
const cameraId = event?.detail?.cameraId;
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
if (
typeof cameraId !== 'string' ||
!cameraId ||
typeof runExplicitFocus !== 'function'
) {
return false;
}
return runExplicitFocus(
() => cameraId,
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
(selectedId) =>
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -57,7 +58,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once
assert.strictEqual(added[0].callback, listener);
assert.strictEqual(removed[0].callback, added[0].callback);
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const uiSource = readShellSource();
assert.match(
uiSource,
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
+186 -56
View File
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
* floor through a band derived from the live keyhole radius.
*/
export function keyholeLabelAlpha(labelX, labelY, width, height) {
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
return keyholeLabelAlphaFromGeometry(
labelX,
labelY,
getKeyholeGeometry(width, height),
);
}
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
if (
!geometry ||
!(geometry.radius > 0) ||
!Number.isFinite(labelX) ||
!Number.isFinite(labelY)
)
return 0;
const feather = geometry.featherPx;
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
const distance = Math.hypot(
labelX - geometry.centerX,
labelY - geometry.centerY,
);
if (distance <= geometry.radius) return 1;
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
if (!(feather > 0) || distance >= geometry.radius + feather)
return keyholeOutsideOpacity;
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress;
}
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
* @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}}
*/
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
export function celestialScreenAngle(
rightComponent,
upComponent,
lastAngle = 0,
) {
const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return {
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
keyholeCenterY,
keyholeRadius,
} = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
const values = [
earthCenterX,
earthCenterY,
earthRadius,
keyholeCenterX,
keyholeCenterY,
keyholeRadius,
];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
return false;
const offset = Math.hypot(
earthCenterX - keyholeCenterX,
earthCenterY - keyholeCenterY,
);
const clearance = keyholeRadius - (offset + earthRadius);
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
return (
clearance >=
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
);
}
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if (
!Number.isFinite(cameraDistance)
|| cameraDistance <= earthRadiusM
|| !(viewportHeight > 0)
|| !Number.isFinite(fovy)
|| fovy <= 0
|| fovy >= Math.PI
) return null;
!Number.isFinite(cameraDistance) ||
cameraDistance <= earthRadiusM ||
!(viewportHeight > 0) ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
)
return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
const radius =
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
return Number.isFinite(radius) && radius > 0 ? radius : null;
}
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
if (
!Number.isFinite(distance) ||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
)
return null;
const toCenter = Cesium.Cartesian3.negate(
camera.positionWC,
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
Cesium.Cartesian3.ZERO,
scratchCenter,
);
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
return null;
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
const earthRadius = earthDiscScreenRadius(
distance,
height,
camera.frustum?.fovy,
);
if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height);
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow;
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
ctx.fillRect(
cx - radius - 4,
cy - radius - 4,
(radius + 4) * 2,
(radius + 4) * 2,
);
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
const ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
lineGradient.addColorStop(
0,
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
);
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
@@ -340,7 +391,8 @@ export class CelestialRing {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._onAutoDisable =
typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false;
this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0;
@@ -364,7 +416,9 @@ export class CelestialRing {
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each
@@ -394,18 +448,26 @@ export class CelestialRing {
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._sunMarker = document.createElement('span');
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
@@ -413,7 +475,7 @@ export class CelestialRing {
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker
this._moonMarker,
);
this.viewer.container.appendChild(this._root);
}
@@ -458,15 +520,24 @@ export class CelestialRing {
const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy;
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
if (
!(height > 0) ||
!cartographic ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
) {
return false;
}
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
const keyholeRadius = getKeyholeGeometry(
canvas.clientWidth || canvas.width,
height,
).radius;
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan(
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
@@ -480,7 +551,7 @@ export class CelestialRing {
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude
altitude,
),
orientation: {
heading: this.viewer.camera.heading,
@@ -500,7 +571,10 @@ export class CelestialRing {
/** Clear the backing canvas. */
_clear() {
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
@@ -512,12 +586,23 @@ export class CelestialRing {
/** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
const pixelsScale = Math.sqrt(
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
);
const dimensionScale =
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(
nativeDpr,
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
pixelsScale,
dimensionScale,
);
const bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * dpr));
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
@@ -536,14 +621,25 @@ export class CelestialRing {
_updateEphemeris(time) {
if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
time,
this._sunInertial,
);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
time,
this._moonInertial,
);
const matrix =
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
Cesium.Matrix3.multiplyByVector(
matrix,
this._moonInertial,
this._moonFixed,
);
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false;
@@ -574,8 +670,14 @@ export class CelestialRing {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
const maxRadiusX =
Math.abs(cos) > 1e-4
? (cx - margin) / Math.abs(cos)
: Number.POSITIVE_INFINITY;
const maxRadiusY =
Math.abs(sin) > 1e-4
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
@@ -596,14 +698,24 @@ export class CelestialRing {
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) {
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
this._sunCtx.clearRect(
0,
0,
this._sunCanvas.width,
this._sunCanvas.height,
);
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey;
}
if (outlineKey !== this._moonRenderKey) {
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
this._moonCtx.clearRect(
0,
0,
this._moonCanvas.width,
this._moonCanvas.height,
);
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey;
@@ -624,7 +736,9 @@ export class CelestialRing {
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
const nextVisible = disc
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
@@ -645,12 +759,12 @@ export class CelestialRing {
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle
this._sunAngle,
);
const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle
this._moonAngle,
);
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
@@ -660,7 +774,10 @@ export class CelestialRing {
const cx = width * 0.5;
const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX;
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
const rayInnerRadius = Math.min(
radius - 12,
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
@@ -671,16 +788,24 @@ export class CelestialRing {
// stroke. Near conjunction (such as a new moon), keep the true bearings but
// move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
< MARKER_COLLISION_ANGLE;
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity
this._moonOpacity,
);
this._debug = {
enabled: true,
@@ -698,7 +823,12 @@ export class CelestialRing {
/** Read-only geometry snapshot for browser QA. */
getDebugState() {
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
return this._debug
? {
...this._debug,
disc: this._debug.disc ? { ...this._debug.disc } : null,
}
: null;
}
/** Detach the render hook and remove all overlay DOM. */
+196
View File
@@ -0,0 +1,196 @@
// CITY PRESETS — the destination table, checked as data.
//
// CITY_POIS is written by hand and read directly by the camera, so a malformed
// row is not a crash: the pill lights up, the camera flies, and it frames the
// wrong thing. These checks are structural. They say a row is well formed —
// present keys, finite numbers, coordinates on Earth, a camera angle a camera
// can hold, a landmark inside the rectangle its own destination opens on — and
// say nothing about whether a destination is framed well.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
/** `id · POI name`, so a failure names the row instead of an index. */
const where = (id, poi) => `${id} · ${poi.name}`;
/** Longitude folded into [-180, 180]. */
const wrapLon = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
/**
* Whether a longitude lies in the west→east span, the way navigation reads it.
*
* A span that crosses the antimeridian has a west greater than its east
* (179 → -179), and everything outside that pair is what falls between them.
*/
export function lonWithin(lon, west, east) {
const value = wrapLon(lon);
const from = wrapLon(west);
const to = wrapLon(east);
return from <= to
? value >= from && value <= to
: value >= from || value <= to;
}
test('the table is not empty, and every destination offers at least one place', () => {
assert.ok(DESTINATIONS.length > 0);
for (const [id, city] of DESTINATIONS) {
assert.ok(
typeof city.name === 'string' && city.name.trim().length > 0,
`${id} has no name`,
);
assert.ok(
Array.isArray(city.pois) && city.pois.length > 0,
`${id} has no POIs`,
);
}
});
test('every POI carries the keys the camera reads', () => {
for (const [id, city] of DESTINATIONS) {
for (const poi of city.pois) {
for (const key of REQUIRED_POI_KEYS) {
assert.notEqual(
poi[key],
undefined,
`${where(id, poi)} is missing ${key}`,
);
}
// A blank name reaches the pill as an unlabelled button.
assert.ok(
typeof poi.name === 'string' && poi.name.trim().length > 0,
`${id} has a place with no name`,
);
}
}
});
test('every coordinate is a finite point on Earth', () => {
for (const [id, city] of DESTINATIONS) {
for (const poi of city.pois) {
assert.ok(
Number.isFinite(poi.lat) && Math.abs(poi.lat) <= 90,
`${where(id, poi)} lat ${poi.lat}`,
);
assert.ok(
Number.isFinite(poi.lon) && Math.abs(poi.lon) <= 180,
`${where(id, poi)} lon ${poi.lon}`,
);
}
}
});
test('every camera angle is one a camera can hold', () => {
for (const [id, city] of DESTINATIONS) {
for (const poi of city.pois) {
// Looking down, not up and not along the horizon: these are aerial frames.
assert.ok(
Number.isFinite(poi.pitch) && poi.pitch < 0 && poi.pitch >= -90,
`${where(id, poi)} pitch ${poi.pitch}`,
);
assert.ok(
Number.isFinite(poi.heading) && poi.heading >= 0 && poi.heading < 360,
`${where(id, poi)} heading ${poi.heading}`,
);
// A non-finite altitude is a camera at no distance at all.
assert.ok(
Number.isFinite(poi.alt) && poi.alt > 0,
`${where(id, poi)} alt ${poi.alt}`,
);
}
}
});
test('every viewBounds is a finite rectangle on Earth', () => {
for (const [id, city] of DESTINATIONS) {
assert.ok(city.viewBounds, `${id} has no viewBounds`);
const { southwest: sw, northeast: ne } = city.viewBounds;
for (const [corner, point] of [
['southwest', sw],
['northeast', ne],
]) {
assert.ok(
Number.isFinite(point?.lat) && Math.abs(point.lat) <= 90,
`${id} ${corner} lat ${point?.lat}`,
);
assert.ok(
Number.isFinite(point?.lng) && Math.abs(point.lng) <= 180,
`${id} ${corner} lng ${point?.lng}`,
);
}
// Swapped latitudes produce an inverted-but-plausible box: the framing code
// still runs, and the camera frames nothing. Longitudes are not ordered,
// because a span that crosses the antimeridian runs west > east.
assert.ok(sw.lat < ne.lat, `${id} viewBounds latitudes are inverted`);
assert.notEqual(
wrapLon(sw.lng),
wrapLon(ne.lng),
`${id} viewBounds has no width`,
);
}
});
test('every POI lies inside the frame its own destination opens on', () => {
// The failure this exists for: a landmark the destination lists but its
// overview never shows.
for (const [id, city] of DESTINATIONS) {
const { southwest: sw, northeast: ne } = city.viewBounds;
for (const poi of city.pois) {
assert.ok(
poi.lat >= sw.lat &&
poi.lat <= ne.lat &&
lonWithin(poi.lon, sw.lng, ne.lng),
`${where(id, poi)} at ${poi.lat}, ${poi.lon} is outside ${id}'s viewBounds`,
);
}
}
});
test('every LOCATIONS row matches the destination it names', () => {
assert.equal(LOCATIONS.length, DESTINATIONS.length);
for (const [index, [id, city]] of DESTINATIONS.entries()) {
assert.deepEqual(LOCATIONS[index], {
id,
name: city.name,
lat: city.pois[0].lat,
lon: city.pois[0].lon,
});
}
});
test('containment follows a span across the antimeridian', () => {
// No current destination crosses 180°, so the wrapped case is exercised here
// rather than left to whoever adds the first one. southwest (-1, 179) to
// northeast (1, -179) is a real 2° box straddling the line.
const west = 179;
const east = -179;
assert.equal(lonWithin(179.5, west, east), true);
assert.equal(lonWithin(-179.5, west, east), true);
assert.equal(lonWithin(180, west, east), true);
assert.equal(lonWithin(0, west, east), false);
assert.equal(lonWithin(178.9, west, east), false);
assert.equal(lonWithin(-178.9, west, east), false);
// An ordinary span is unaffected by the wrapping.
assert.equal(lonWithin(-97.7, -97.95, -97.55), true);
assert.equal(lonWithin(-98.4, -97.95, -97.55), false);
// A whole POI checked against a synthetic destination, the way the table
// check reads it.
const destination = {
viewBounds: {
southwest: { lat: -1, lng: west },
northeast: { lat: 1, lng: east },
},
pois: [{ name: 'Dateline overlook', lat: 0, lon: 179.5 }],
};
const { southwest: sw, northeast: ne } = destination.viewBounds;
for (const poi of destination.pois) {
assert.ok(
poi.lat >= sw.lat &&
poi.lat <= ne.lat &&
lonWithin(poi.lon, sw.lng, ne.lng),
'a landmark inside a wrapped view must read as inside',
);
}
});
+141 -57
View File
@@ -1,5 +1,9 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
} from './weatherEffectsMath.js';
const WEATHER_REFRESH_MS = 5 * 60_000;
const CLOUD_FRAME_MS = 1000 / 12;
@@ -116,28 +120,47 @@ function compileShader(gl, type, source) {
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
const error =
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
gl.deleteShader(shader);
throw new Error(error);
}
function greatCircleM(a, b) {
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
);
}
/** Return whether cockpit weather must refresh for elapsed time or movement. */
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
export function cockpitWeatherRefreshDue({
nowMs,
fetchedAt,
anchor,
point,
hasWeather,
}) {
if (!hasWeather) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
return (
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
);
}
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
@@ -165,7 +188,8 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
*/
export class CockpitCloudEffectsController {
constructor(viewer) {
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
this.weatherService = weatherService;
this.viewer = viewer;
this.canvas = document.createElement('canvas');
this.canvas.id = 'cockpit-cloud-effects';
@@ -190,18 +214,24 @@ export class CockpitCloudEffectsController {
this.pending = null;
this.abort = null;
this.destroyed = false;
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize();
this.onCockpitMode = (event) => {
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
if (active && this.enabled) this.start();
else this.stop();
};
this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled;
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
this.setEnabled(
typeof requested === 'boolean' ? requested : !this.enabled,
);
};
window.addEventListener('resize', this.onResize);
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
@@ -220,9 +250,11 @@ export class CockpitCloudEffectsController {
}
emitEnabledState() {
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}));
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}),
);
}
setEnabled(enabled) {
@@ -230,7 +262,9 @@ export class CockpitCloudEffectsController {
this.enabled = next;
try {
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
} catch { /* best effort */ }
} catch {
/* best effort */
}
if (!next) {
this.stop();
} else if (document.body.classList.contains('cockpit-mode')) {
@@ -253,7 +287,11 @@ export class CockpitCloudEffectsController {
if (!gl) throw new Error('WebGL unavailable');
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
FRAGMENT_SHADER,
);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
@@ -261,15 +299,18 @@ export class CockpitCloudEffectsController {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
throw new Error(
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
);
}
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1,
]), gl.STATIC_DRAW);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
@@ -291,7 +332,8 @@ export class CockpitCloudEffectsController {
resize() {
if (!this.gl) return;
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
if (this.canvas.width === size.width && this.canvas.height === size.height)
return;
this.canvas.width = size.width;
this.canvas.height = size.height;
this.gl.viewport(0, 0, size.width, size.height);
@@ -311,7 +353,13 @@ export class CockpitCloudEffectsController {
}
start() {
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
if (
this.destroyed ||
!this.enabled ||
this.frame !== null ||
this.suspended
)
return;
if (!this.gl) {
this.initializeRenderer();
this.resize();
@@ -351,16 +399,21 @@ export class CockpitCloudEffectsController {
}
async refresh() {
if (this.destroyed || !this.enabled || this.pending || this.suspended
|| !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
this.pending ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
) {
return this.pending;
}
const point = this.cameraPoint();
if (!point) return null;
if (
this.weather
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
this.weather &&
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
) {
this.applyWeather(this.weather, point.altitudeM);
return this.weather;
@@ -368,15 +421,17 @@ export class CockpitCloudEffectsController {
this.abort?.abort();
this.abort = new AbortController();
const params = new URLSearchParams({
latitude: point.latitude.toFixed(5),
longitude: point.longitude.toFixed(5),
});
this.pending = fetch(`/api/weather-effects?${params}`, { signal: this.abort.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Cloud weather unavailable (${response.status})`);
const payload = await response.json();
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
const pending = this.weatherService.getConditions(
point.latitude,
point.longitude,
{ signal: this.abort.signal },
);
const request = this.abort;
this.pending = pending
.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
if (!payload?.weather)
throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather;
this.fetchedAt = Date.now();
this.anchor = point;
@@ -385,6 +440,7 @@ export class CockpitCloudEffectsController {
return payload.weather;
})
.catch((error) => {
if (request.signal.aborted || this.abort !== request) return;
if (error?.name !== 'AbortError') {
this.targetStrength = 0;
this.canvas.dataset.sourceStatus = 'unavailable';
@@ -392,8 +448,10 @@ export class CockpitCloudEffectsController {
return null;
})
.finally(() => {
this.pending = null;
this.abort = null;
if (this.abort === request) {
this.pending = null;
this.abort = null;
}
});
return this.pending;
}
@@ -401,12 +459,17 @@ export class CockpitCloudEffectsController {
applyWeather(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather);
const altitude = weatherAltitudeFactors(altitudeM);
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
this.targetStrength = profile.available
? profile.cloud * altitude.cloud
: 0;
this.windDirectionDeg = profile.windDirectionDeg || 0;
this.windStrength = profile.wind || 0;
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
if (
this.reducedMotion &&
document.body.classList.contains('cockpit-mode')
) {
this.strength = this.targetStrength;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
@@ -416,7 +479,11 @@ export class CockpitCloudEffectsController {
}
tick(timeMs) {
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
!document.body.classList.contains('cockpit-mode')
) {
this.stop();
return;
}
@@ -433,20 +500,27 @@ export class CockpitCloudEffectsController {
if (timeMs - this.lastRefreshCheckMs >= 1000) {
this.lastRefreshCheckMs = timeMs;
const point = this.cameraPoint();
if (point && cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})) void this.refresh();
if (
point &&
cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})
)
void this.refresh();
}
const blend = this.reducedMotion ? 1 : 0.08;
this.strength += (this.targetStrength - this.strength) * blend;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
if (
visible &&
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
) {
this.lastFrameMs = timeMs;
this.render(timeMs / 1000);
} else if (!visible) {
@@ -470,9 +544,16 @@ export class CockpitCloudEffectsController {
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
const windScale = 0.008 + this.windStrength * 0.022;
gl.useProgram(this.program);
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
gl.uniform2f(
this.locations.resolution,
this.canvas.width,
this.canvas.height,
);
gl.uniform1f(this.locations.time, timeSec);
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
gl.uniform1f(
this.locations.strength,
Math.min(1, Math.max(0, this.strength)),
);
gl.uniform2f(
this.locations.wind,
Math.sin(windRadians) * windScale,
@@ -519,12 +600,15 @@ export class CockpitCloudEffectsController {
this.stop();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
window.removeEventListener(
'gev:cockpit-weather-toggle',
this.onEnabledChange,
);
if (this.program && this.gl) this.gl.deleteProgram(this.program);
this.canvas.remove();
}
}
export function initCockpitCloudEffects(viewer) {
return new CockpitCloudEffectsController(viewer);
export function initCockpitCloudEffects(viewer, options) {
return new CockpitCloudEffectsController(viewer, options);
}
+31 -15
View File
@@ -1,3 +1,5 @@
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
@@ -26,11 +28,11 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'));
const ui = readShellSource();
const css = readStylesheet(path.join(ROOT, 'style.css'));
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8');
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8');
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
@@ -43,7 +45,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
);
const topCenterActions = html.match(/<nav id="top-center-actions"[\s\S]*?<\/nav>/);
assert.ok(topCenterActions, 'Top-center globe actions are missing');
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="reset-globe-view"/);
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="tilt-map-view"[\s\S]*?id="north-up-view"[\s\S]*?id="reset-globe-view"/);
assert.equal((html.match(/id="clear-selected-layers"/g) || []).length, 1, 'Clear Layers must have one DOM owner');
assert.equal((html.match(/id="reset-globe-view"/g) || []).length, 1, 'Reset Globe must have one DOM owner');
assert.equal((html.match(/id="cockpit-reset-globe"/g) || []).length, 1, 'Cockpit Reset must have one DOM owner');
@@ -202,7 +204,8 @@ test('programmatic Context layer changes cannot bypass explicit expansion policy
});
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
const parseIndex = ui.indexOf('this._initialShareState = this.shareLinkManager.parseInitialHash();');
const parseIndex = ui.indexOf('this._shareRestoration.attachLinks(this.shareLinkManager);');
assert.match(shellMethod('attachLinks').toString(), /this\._initialShareState = shareLinkManager\.parseInitialHash\(\)/);
const panelChromeIndex = ui.indexOf('this._initPanelChrome();');
assert.ok(parseIndex >= 0, 'initial share state must be parsed during UI construction');
assert.ok(
@@ -210,11 +213,11 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
'share state must be known before panel chrome can read recipient-local preferences',
);
assert.equal(
(ui.match(/this\.shareLinkManager\.parseInitialHash\(\)/g) || []).length,
(ui.match(/shareLinkManager\.parseInitialHash\(\)/g) || []).length,
1,
'startup must parse the incoming share exactly once',
);
const panelChrome = ui.match(/_initPanelChrome\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/);
const panelChrome = { 1: shellMethod('_initPanelChrome').toString() };
assert.ok(panelChrome, 'panel chrome initializer is missing');
assert.match(
panelChrome[1],
@@ -224,7 +227,7 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
});
test('Cockpit owns a focused shared Display portal and compact Radio controls', () => {
const hiddenRule = css.match(/body\.cockpit-mode :is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
const hiddenRule = css.match(/body\.cockpit-mode\s*:is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
assert.ok(hiddenRule, 'Cockpit hidden-chrome rule is missing');
assert.match(css, /body\.cockpit-mode #right-context-rail\s*\{\s*display:\s*none\s*!important;/);
assert.match(css, /body\.cockpit-mode #left-panel-stack > #scene-panel\s*\{\s*display:\s*none\s*!important;/);
@@ -237,13 +240,22 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(html, /id="clear-selected-layers"[^>]*aria-label="Clear selected data layers"/);
assert.match(html, /id="reset-globe-view"[^>]*aria-label="Reset to full globe view"/);
assert.match(css, /#top-center-actions\s*\{[\s\S]*?left:\s*50%;[\s\S]*?display:\s*flex;[\s\S]*?transform:\s*translateX\(-50%\)/);
assert.match(
css,
/@media \(max-width: 720px\)\s*\{[\s\S]*?#top-center-actions\s*\{[\s\S]*?right:\s*16px;[\s\S]*?left:\s*auto;[\s\S]*?transform:\s*none;/,
);
assert.match(css, /@media \(max-width: 720px\)\s*\{[\s\S]*?#style-indicator\s*\{\s*display:\s*none;/);
assert.match(
css,
/@media \(max-width: 520px\)\s*\{[\s\S]*?#title-bar h1 > span:last-child,[\s\S]*?#title-bar \.subtitle\s*\{\s*display:\s*none;/,
);
assert.match(css, /body\.ui-clean-view #top-center-actions/);
assert.match(css, /body\.recording-mode #top-center-actions/);
assert.match(
css,
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
);
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /this\._running = true;\s*this\._previewRun = preview;\s*if \(preview\) this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
@@ -396,7 +408,9 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
}
const callback = ui.match(/onEntered: \(\) => \{([\s\S]*?)\n \},\n onExited:/);
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
const callback = { 1: shellMethod('enterCockpit').toString() };
assert.ok(callback, 'Cockpit onEntered callback is missing');
assert.match(
callback[1],
@@ -425,7 +439,9 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
'normal Context must not reopen over Cockpit',
);
const exitCallback = ui.match(/onExited: \(\) => \{([\s\S]*?)\n \},\n restoreTrackingFrame:/);
assert.match(ui, /onExited: \(\) => this\.exitPanels\(\)/);
assert.match(ui, /exitPanels: \(\) => this\._panelChrome\.exitCockpit\(\)/);
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
assert.match(
exitCallback[1],
@@ -463,11 +479,11 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
test('Cockpit hides the complete top-center globe action group', () => {
assert.match(
css,
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
/body\.cockpit-mode\s*:is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
);
assert.match(
css,
/body\.cockpit-mode :is\(#clear-selected-layers, #share-btn, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.cockpit-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#share-btn,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
'Cockpit must hide each map-only globe action even if its group layout is disturbed',
);
});
@@ -763,7 +779,7 @@ test('Global Context uses its dedicated right rail without a duplicate Data Laye
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/);
assert.match(fs.readFileSync(path.join(ROOT, 'src', 'app', 'layerPresentation.js'), 'utf8'), /getLayers: \(\) => this\.manager\.getAll\(\)/);
assert.match(html, /id="global-context-panel"/);
assert.match(html, /id="global-context-flights-btn"/);
assert.match(html, /id="global-context-missions-btn"/);
+32 -15
View File
@@ -8,7 +8,11 @@
*/
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
export function restoreAircraftTrackingOwner(
layer,
id,
{ origin = 'programmatic' } = {},
) {
if (!layer?.trackById || !id) return false;
layer.stopTracking?.({ origin });
return Boolean(layer.trackById(id, { origin }));
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
rollbackTarget = undefined,
selectionOrigin = 'programmatic',
}) {
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) => target?.layerId && target?.id
? `${target.layerId}:${target.id}`
: null;
const currentTarget = aircraftTrackingTarget(
cockpitView?.readAircraftInfo?.(),
);
const restoreTarget =
rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) =>
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
let activeLayer = currentLayer;
let activeTarget = currentTarget;
let entryError = null;
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
if (selectingTarget) {
activeLayer = selectedLayer;
activeTarget = selectedTarget;
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
if (
!selectedLayer.trackById?.(selectedTarget.id, {
origin: selectionOrigin,
})
) {
entryError = new Error(
'Selected aircraft could not be tracked for Cockpit entry',
);
}
}
if (!entryError) entered = Boolean(cockpitView.enter());
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
// programmatic rollback would fix the camera while leaving the failed
// target durable in local/share state.
activeLayer?.stopTracking?.({ origin: selectionOrigin });
if (restoreTarget && !restoreAircraftTrackingOwner(
rollbackLayer,
restoreTarget.id,
{ origin: selectionOrigin },
)) {
entryError ||= new Error('Prior aircraft tracking could not be restored');
if (
restoreTarget &&
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
origin: selectionOrigin,
})
) {
entryError ||= new Error(
'Prior aircraft tracking could not be restored',
);
}
} catch (error) {
entryError ||= error instanceof Error ? error : new Error(String(error));
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
return {
entered,
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
error: entered
? null
: entryError?.message || 'Cockpit entry was unavailable',
};
}
+15 -4
View File
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
* there is nothing to do.
*/
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
export function contactsDetectionExitPlan(
restore,
current,
styleOwnsDetection = false,
) {
if (!restore || styleOwnsDetection) return null;
const saved = normalizeDetectionState(restore);
const now = normalizeDetectionState(current);
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
return null;
return saved;
}
@@ -119,7 +124,11 @@ export function applyContactsDetection({
if (plan.turnOn) applyPreset();
return { restore: plan.restore, changed: plan.turnOn };
}
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
const plan = contactsDetectionExitPlan(
restore,
getState(),
styleOwnsDetection,
);
if (plan) restoreState(plan);
return { restore: null, changed: Boolean(plan) };
}
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
return {
mode: owned.mode ?? liveMode,
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
densityPct: Number.isFinite(owned.densityPct)
? owned.densityPct
: liveDensityPct,
};
}
+3 -5
View File
@@ -1,3 +1,4 @@
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
// Contacts, detections should just turn on, and they should stay on in Cockpit
@@ -28,7 +29,7 @@ import {
import { canonicalizeDensity } from './data/detectionPolicy.js';
// Follow the UI wiring and its extracted preset definitions.
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
const uiSource = readShellSource()
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
/**
@@ -140,10 +141,7 @@ test('the Contacts preset is the very object the military styles apply', () => {
});
test('the shared preset applier ignores the style override flag — the caller owns it', () => {
const applier = uiSource.slice(
uiSource.indexOf('_applyDetectionPreset(det) {'),
uiSource.indexOf('Applies the global post-processing baseline'),
);
const applier = shellMethod('_applyDetectionPreset').toString();
assert.ok(applier.length > 0);
assert.doesNotMatch(applier, /_detectionUserOverridden/);
// The STYLE path still gates on it; Contacts deliberately does not.
+125 -72
View File
@@ -1,5 +1,11 @@
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
flights: new Set([
'military-awareness',
'flights',
'military',
'ais-live-vessels',
'military-installations',
]),
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
/** Preserve explicit layer intent that completes while a stale restore is queued. */
export function recordContextRestoreExplicitChange({ restoreState, change }) {
if (
!restoreState?.enabledLayerIds
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
) return false;
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
!restoreState?.enabledLayerIds ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
)
return false;
if (!restoreState.explicitLayerStates)
restoreState.explicitLayerStates = new Map();
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
else restoreState.enabledLayerIds.delete(change.layerId);
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
}
/** Settle every explicit Context-restore replay without losing semantic failures. */
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
export async function settleContextIntentReplay({
restoreState,
setEnabled,
notificationToken = null,
}) {
if (restoreState?.cancelled) return null;
const entries = [...(restoreState?.explicitLayerStates || [])];
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
})
)));
const failedIndexes = results.flatMap((result, index) => (
result.status === 'rejected' || result.value === false ? [index] : []
));
const results = await Promise.allSettled(
entries.map(([layerId, enabled]) =>
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
}),
),
);
const failedIndexes = results.flatMap((result, index) =>
result.status === 'rejected' || result.value === false ? [index] : [],
);
if (failedIndexes.length === 0) return null;
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
const rejected = failedIndexes
.map((index) => results[index])
.find((result) => result.status === 'rejected');
const error = rejected?.reason instanceof Error
? rejected.reason
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
error.failedLayerIds = [...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
])];
const error =
rejected?.reason instanceof Error
? rejected.reason
: new Error(
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
);
error.failedLayerIds = [
...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
]),
];
return error;
}
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
export function mergeContextTransitionErrors(primaryError, secondaryError) {
if (!primaryError) return secondaryError || null;
if (!secondaryError) return primaryError;
primaryError.failedLayerIds = [...new Set([
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
])];
primaryError.failedLayerIds = [
...new Set([
...(Array.isArray(primaryError.failedLayerIds)
? primaryError.failedLayerIds
: []),
...(Array.isArray(secondaryError.failedLayerIds)
? secondaryError.failedLayerIds
: []),
]),
];
return primaryError;
}
/** Settle a user-facing Context action and convert every failure form to false. */
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
export async function settleUserFacingContextAction({
operation,
onFailure,
falseIsFailure = true,
}) {
try {
const result = await operation();
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
if (falseIsFailure && result === false)
throw new Error('Context transition did not complete');
return result;
} catch (error) {
try {
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
* one into `userAdded` makes exit restoration re-enable the mode layer the
* user just turned off.
*/
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
'military-awareness',
'rocket-launches',
]);
/**
* Leave a Context transaction: publish the settled coordination flag, then
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
if (!owner) return;
const wasChanging = Boolean(owner._contextModeChanging);
owner._contextModeChanging = Boolean(changing);
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
if (wasChanging && !owner._contextModeChanging)
owner._syncContextModeButtons?.();
}
/**
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
* @param {object|null} input.change DataLayerManager visibility notification.
* @returns {boolean} Whether the active Context bundle should be disabled.
*/
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
export function shouldExitContextForLayerChange({
contextMode,
globalContextEnabled,
change,
}) {
if (
change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|| change.layerId === 'military-awareness'
|| CONTEXT_COMPANIONS.has(change.layerId)
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
change.layerId === 'military-awareness' ||
CONTEXT_COMPANIONS.has(change.layerId)
) {
return false;
}
const active = Boolean(globalContextEnabled || contextMode);
if (!active) return false;
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
const dependencies =
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
if (change.enabled) return false;
return Boolean(contextMode && dependencies.has(change.layerId));
}
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
*/
export function shouldCaptureContextSession(change) {
return Boolean(
['visibility-requested', 'visibility-will-change'].includes(change?.type)
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& change.enabled
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
change.enabled &&
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
);
}
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
*/
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
return Boolean(
clearInFlight
&& change?.type === 'visibility-requested'
&& change.layerId === 'rocket-launches'
&& change.enabled === true
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& Number.isInteger(change.intentEpoch)
clearInFlight &&
change?.type === 'visibility-requested' &&
change.layerId === 'rocket-launches' &&
change.enabled === true &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
Number.isInteger(change.intentEpoch),
);
}
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
* @returns {boolean} Whether the snapshot's userAdded set was modified.
*/
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
export function recordContextSessionUserChange({
snapshot,
change,
effectiveContextMode = null,
}) {
if (
!snapshot?.userAdded
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
!snapshot?.userAdded ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
) {
return false;
}
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
snapshot.userAdded.add(change.layerId);
return true;
}
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
if (
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
!isCompanion
)
return false;
snapshot.userAdded.add(change.layerId);
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
return true;
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
* @param {string|null} [input.layerName] User-facing layer name.
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
*/
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
export function contextLayerEnableBlockReason({
contextMode,
change,
layerName = null,
}) {
if (
contextMode !== 'space-missions'
|| change?.enabled !== true
|| contextAllowedLayerIds('space-missions').has(change.layerId)
contextMode !== 'space-missions' ||
change?.enabled !== true ||
contextAllowedLayerIds('space-missions').has(change.layerId)
) {
return null;
}
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
* @param {object|null} input.change Manager visibility notification.
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
*/
export function spaceMissionEntryCancellationDisposition({
change,
}) {
export function spaceMissionEntryCancellationDisposition({ change }) {
if (
change?.type !== 'visibility-cancelled'
|| change.layerId !== 'rocket-launches'
|| change.enabled !== true
) return 'ignore';
change?.type !== 'visibility-cancelled' ||
change.layerId !== 'rocket-launches' ||
change.enabled !== true
)
return 'ignore';
if (
change.cancellationReason === 'superseded'
&& Number.isInteger(change.successorIntentEpoch)
&& change.successorIntentEpoch > change.intentEpoch
&& change.successorEnabled === true
) return 'replacement';
change.cancellationReason === 'superseded' &&
Number.isInteger(change.successorIntentEpoch) &&
change.successorIntentEpoch > change.intentEpoch &&
change.successorEnabled === true
)
return 'replacement';
return 'restore';
}
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
flightsEnabled,
militaryEnabled,
}) {
return contextMode === 'flights'
&& !contextModeChanging
&& Boolean(flightsEnabled)
&& Boolean(militaryEnabled);
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
);
}
/**
+2 -1
View File
@@ -1,3 +1,4 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { connectContextManager } from './ui/contextSubscriptions.js';
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
@@ -23,7 +24,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
const src = readShellSource();
const handler = _handleContextLayerChange.toString();
+4 -2
View File
@@ -1,3 +1,5 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
@@ -11,8 +13,8 @@ import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
+2 -1
View File
@@ -1,3 +1,4 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = readStylesheet(path.join(ROOT, 'style.css'));
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const ui = readShellSource();
/*
* Required-attribution keep-out pin.
+6 -2
View File
@@ -3,7 +3,9 @@
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
);
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false;
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
// A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; });
control.addEventListener('blur', () => {
spacePressed = false;
});
};
const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false');
+414 -67
View File
@@ -8,90 +8,415 @@
*/
const HELI = new Set([
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
'EC20',
'EC25',
'EC30',
'EC35',
'EC45',
'EC55',
'AS50',
'AS55',
'AS65',
'AS32',
'A109',
'A119',
'A139',
'A169',
'A189',
'B06',
'B06T',
'B407',
'B412',
'B427',
'B429',
'B430',
'B505',
'S76',
'S92',
'S61',
'S64',
'H60',
'H500',
'MD52',
'MD60',
'R22',
'R44',
'R66',
'EXEC',
'EXPL',
'GAZL',
'LYNX',
'NH90',
'PUMA',
'SCAV',
'UH1',
'B105',
'B212',
'B214',
'B222',
'AC',
'H47',
'H64',
]);
const QUAD = new Set([
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
'B741',
'B742',
'B743',
'B744',
'B748',
'B74S',
'B74R',
'B74D',
'A388',
'A342',
'A343',
'A345',
'A346',
'A124',
'C5M',
'A225',
'IL96',
'B52',
'A140',
]);
const WIDE = new Set([
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
'C17', 'K35R', // military heavies that read as widebodies
'A306',
'A30B',
'A310',
'A332',
'A333',
'A338',
'A339',
'A359',
'A35K',
'B762',
'B763',
'B764',
'B772',
'B77L',
'B773',
'B77W',
'B778',
'B779',
'B788',
'B789',
'B78X',
'MD11',
'IL86',
'DC10',
'L101',
'A337',
'B767',
'B777',
'B787',
'C17',
'K35R', // military heavies that read as widebodies
]);
const TPROP = new Set([
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
'DH8A',
'DH8B',
'DH8C',
'DH8D',
'AT43',
'AT44',
'AT45',
'AT46',
'AT72',
'AT73',
'AT75',
'AT76',
'SF34',
'SB20',
'SW3',
'SW4',
'E110',
'E120',
'C208',
'C212',
'C408',
'PC12',
'B190',
'BE20',
'B350',
'B300',
'JS31',
'JS32',
'JS41',
'D228',
'D328',
'F50',
'F27',
'ATP',
'TBM7',
'TBM8',
'TBM9',
'TBM0',
'PC6',
'C441',
'C425',
'DHC6',
'DHC7',
'C130',
'AN12',
'AN26',
'AN32',
'SH36',
'CVLT',
'SAAB',
'A400',
]);
const GLIDER = new Set([
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
'DISC',
'DUOD',
'VENT',
'NIMB',
'NIM3',
'NIM4',
'JANS',
'ARCE',
'DG40',
'DG80',
'DG1T',
'DG30',
'DG50',
'LS3',
'LS4',
'LS6',
'LS7',
'LS8',
'STD3',
'G103',
'G102',
'G104',
'PW5',
'PW6',
'L13',
'L23',
'L33',
'PIK',
'PEGA',
'KEST',
'TWIN',
'AS33',
'ASW',
'ASG',
'ASK',
'VENS',
'GLID',
'MOSQ',
'DIMO',
]);
const LIGHT = new Set([
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
'C150',
'C152',
'C162',
'C172',
'C72R',
'C175',
'C177',
'C180',
'C182',
'C185',
'C188',
'C206',
'C207',
'C210',
'C310',
'C337',
'SR20',
'SR22',
'S22T',
'PA18',
'PA24',
'PA28',
'P28A',
'P28B',
'P28R',
'PA32',
'P32R',
'PA34',
'PA38',
'PA44',
'PA46',
'DA20',
'DA40',
'DA42',
'DA62',
'BE33',
'BE35',
'BE36',
'BE58',
'BE76',
'BE19',
'BE23',
'BE24',
'M20P',
'M20T',
'AA1',
'AA5',
'GLAS',
'COL4',
'RV4',
'RV6',
'RV7',
'RV8',
'RV9',
'RV10',
'RV14',
'GA8',
'G115',
'BL8',
'CH7',
]);
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
// Citation II GLB + a slimmer glyph read apart from airliners.
const BIZJET = new Set([
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
'PRM1', 'BE40', 'ASTR', 'WW24',
'C500',
'C501',
'C510',
'C525',
'C25A',
'C25B',
'C25C',
'C25M',
'C550',
'C551',
'C560',
'C56X',
'C650',
'C680',
'C68A',
'C700',
'C750',
'CL30',
'CL35',
'CL60',
'GLF2',
'GLF3',
'GLF4',
'GLF5',
'GLF6',
'GA5C',
'GA6C',
'G150',
'G280',
'GL5T',
'GL7T',
'GLEX',
'LJ23',
'LJ24',
'LJ25',
'LJ31',
'LJ35',
'LJ40',
'LJ45',
'LJ55',
'LJ60',
'LJ70',
'LJ75',
'FA10',
'FA20',
'FA50',
'FA7X',
'FA8X',
'F900',
'F2TH',
'H25A',
'H25B',
'H25C',
'HDJT',
'E50P',
'E55P',
'E545',
'E550',
'PC24',
'PRM1',
'BE40',
'ASTR',
'WW24',
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
]);
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
const UAV = new Set([
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
'Q1',
'Q4',
'Q9',
'MQ1',
'MQ4',
'MQ9',
'RQ4',
'TB2',
'SHDW',
'HERN',
]);
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
const FASTJET = new Set([
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
'T38', 'HAWK', 'L39', 'M346', 'T7A',
'F16',
'F15',
'F18',
'FA18',
'F14',
'F22',
'F35',
'F4',
'F5',
'A10',
'AV8B',
'TYPH',
'EUFI',
'RFAL',
'RAFL',
'GRIP',
'JAS39',
'TOR',
'MIR2',
'M2000',
'SU27',
'SU30',
'SU33',
'SU34',
'SU35',
'SU57',
'MG29',
'MIG29',
'MG31',
'J20',
'T38',
'HAWK',
'L39',
'M346',
'T7A',
]);
/** OpenSky extended-states integer category → class. */
const OPENSKY_CATEGORY = {
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
8: 'helicopter', // Rotorcraft
9: 'glider',
};
/** ADS-B emitter-category string → class. */
const EMITTER_CATEGORY = {
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
A1: 'light',
A2: 'light',
A3: 'airliner',
A4: 'airliner',
A5: 'widebody',
A6: 'fastjet',
A7: 'helicopter',
B1: 'glider',
};
export function classifyAircraft({ typeCode, category } = {}) {
const code = String(typeCode || '').trim().toUpperCase();
const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) {
if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
if (LIGHT.has(code)) return 'light';
return 'airliner'; // known type code, not in a special set → default jet
}
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
const cat = String(category || '').trim().toUpperCase();
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
return OPENSKY_CATEGORY[category];
const cat = String(category || '')
.trim()
.toUpperCase();
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
return 'airliner';
}
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
bizjet: 0.72, uav: 0.75,
light: 0.62,
glider: 0.58,
turboprop: 0.86,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.82,
fastjet: 0.8,
bizjet: 0.72,
uav: 0.75,
};
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
* real per-class models land in CLASS_MODEL_URL. */
export const CLASS_SCALE_3D = {
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
bizjet: 0.8, uav: 0.8,
light: 0.75,
glider: 0.75,
turboprop: 0.85,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.8,
fastjet: 0.8,
bizjet: 0.8,
uav: 0.8,
};
/** Per-class glTF — all the shared airplane today; drop real assets in here.
* NOTE for future models: each asset may need its own heading offset (the
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
export const CLASS_MODEL_URL = {
light: '/models/airplane.glb', glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
light: '/models/airplane.glb',
glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb',
airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb',
quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb',
fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb',
uav: '/models/airplane.glb',
};
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
export const CLASS_MODEL_REAL = {
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
};
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false,
includeHidden = false,
} = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
}
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5,
scaleFloor: 0.45,
alphaFloor: 0.35,
combinedAlphaFloor: 0.20,
combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides };
}
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|| cameraHeightM >= tuning.globeViewBlendEndM) {
function aircraftRecessionFactorsResolved(
cameraDistanceM,
cameraHeightM,
tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1;
result.alpha = 1;
result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result;
}
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const rawT =
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeRawT =
(cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
}
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.scaleFloor))
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor))
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
}
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
}
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.earthRadiusM))
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved(
cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (!Number.isFinite(billboard.color?.alpha)
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero.
*/
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
export function applyAircraftModelTreatment({
model,
baseColor,
alpha,
params,
}) {
const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha)
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha);
return 1;
}
+4 -25
View File
@@ -1,17 +1,8 @@
import { createVesselLayer } from '../layers/vessels/index.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import * as context from './contextStore.js';
import * as trails from './trailRenderer.js';
import * as labels from './detectionDraw.js';
import * as picking from './pickRegistry.js';
import * as overlay from '../overlays/worldOverlay.js';
import * as geoid from './geoid.js';
import * as sprites from './spriteOrder.js';
import * as focus from './focusDeemphasis.js';
import * as worldFocus from '../worldFocus.js';
import * as render from '../renderGovernor.js';
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
const aisLiveVesselsLayer = createVesselLayer({
import { createAisStreamSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
}),
@@ -19,18 +10,6 @@ const aisLiveVesselsLayer = createVesselLayer({
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS,
},
services: {
context,
trails,
labels,
picking,
overlay,
geoid,
sprites,
focus,
worldFocus,
render,
},
});
export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js';
export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError;
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aisStreamRows,
ingestAisStreamEnvelope,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
+15
View File
@@ -0,0 +1,15 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createOverpassAlprSource } from '../layers/alpr/index.js';
export * from '../layers/alpr/index.js';
const slot = createSourceSlot(
createOverpassAlprSource(),
['fetch'],
'ALPR source',
);
export const configureAlprSource = slot.configure;
export default createApplicationAlpr({
surface: defaultSurface,
source: slot.source,
});
File diff suppressed because it is too large. Load diff
+110 -40
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
flights: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: [
'callsign',
'icao24',
'originCountry',
'operator',
'routeOrigin',
'routeDestination',
'aircraftClass',
],
flags: ['military', 'onGround'],
},
military: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
flags: ['military', 'onGround'],
},
'ais-live-vessels': {
numeric: ['speedKts', 'courseDeg'],
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
flags: [],
},
'local-firms': {
numeric: ['frp'],
text: ['confidence', 'satellite'],
flags: [],
},
earthquakes: {
numeric: ['magnitude', 'depthKm'],
text: ['place'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
const got = r[field];
if (got === null || got === undefined) return false;
switch (op) {
case 'gt': return Number(got) > Number(value);
case 'gte': return Number(got) >= Number(value);
case 'lt': return Number(got) < Number(value);
case 'lte': return Number(got) <= Number(value);
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
default: return true;
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
}
});
}
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& pointInRing(resolved.ring, r.lat, r.lon));
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
}
if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center;
const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
}
return records;
}
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null;
async function query(spec = {}) {
const layers = (spec.followUp && lastResult)
? null // follow-up: re-filter the remembered set, no re-snapshot
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
const layers =
spec.followUp && lastResult
? null // follow-up: re-filter the remembered set, no re-snapshot
: Array.isArray(spec.layers) && spec.layers.length
? spec.layers
: ['flights'];
// 1) Source records
let records;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// camera made the two disagree — a parked, high-altitude camera answered
// "46 within 250 km" while the panel showed a far larger contact-centred
// count. Same question, two numbers.
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
? scope.center
: null;
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
const explicitCenter =
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
const subject = explicitCenter
? null
: providers.getContextSubject?.() || null;
const view = providers.getViewContext();
// Only a subject that actually SUPPLIED the centre may name it. A
// subject present but without usable coordinates silently fell back to
// the camera while the label still read "within 250 km of <contact>" —
// a count centred on one place, reported as centred on another, with
// nothing in the payload to show which.
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
const subjectCenter =
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') {
const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view';
} else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
it.distanceKm =
Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
}
}
if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|| String(a.id).localeCompare(String(b.id)));
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
items.sort(
(a, b) =>
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
String(a.id).localeCompare(String(b.id)),
);
if (sortBy === 'distance')
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
}
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length,
items: top,
truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel,
coverage: {
layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
lastResult = { items, coverage: result.coverage };
return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return {
query,
reset() { lastResult = null; },
hasMemory() { return Boolean(lastResult); },
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}
File diff suppressed because it is too large. Load diff
+396
View File
@@ -0,0 +1,396 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
const media = createBhoteKoshiEmbeddedMedia({
viewer: {},
documentRef: { createElement: unexpected },
globalRef: {
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
setTimeout: unexpected,
requestAnimationFrame: unexpected,
},
facebookLoader: unexpected,
youtubeLoader: unexpected,
xLoader: unexpected,
});
for (const sourceUrl of [
'https://www.youtube.com/watch?v=abcdefghijk',
'https://www.facebook.com/watch/?v=123456789',
'https://x.com/example/status/123456789',
]) {
const options = { observation: { media: { sourceUrl } }, autoplay: true };
assert.equal(media.warm(options), false, 'no hidden preload');
assert.equal(media.show(options), false, 'retain the existing fallback card');
assert.equal(media.play(), false);
assert.equal(media.pause(), false);
media.hide();
}
media.destroy();
media.hide();
await Promise.resolve();
});
test('ordinary browsers retain real preloads on the same launcher URL', () => {
for (const userAgent of [
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
'Mozilla/5.0 Electron/38.0.0',
'',
]) {
const { media, root } = preloadFixture({ globalRef: {
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
} });
assert.equal(media.warm({ observation: { media: {
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
} } }), true);
assert.equal(root.children.length, 1);
media.pause();
assert.equal(root.children.length, 0);
media.destroy();
}
});
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
const timers = new Map();
const globalRef = {
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
};
const advance = (ms) => {
const until = now + ms;
while (true) {
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
}
now = until;
};
let events;
const calls = [];
let time = 0;
let state = -1;
const player = {
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
getCurrentTime: () => time,
getPlayerState: () => state,
};
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
const statuses = [];
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
}
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
for (const alreadyPlaying of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
f.events.onReady({ target: f.player });
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
f.setTime(6); f.advance(5000);
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
for (const endedCallback of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.setState(0); f.setTime(7);
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
f.events.onReady({ target: f.player });
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.calls, ['pause']);
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.advance(3000);
assert.equal(f.session.getState().phase, 'starting');
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.setTime(4); f.advance(4000);
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
assert.equal(f.calls.at(-1), 'pause');
f.session.destroy();
});
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (outcome === 'blocked') f.events.onAutoplayBlocked();
else if (outcome === 'unavailable') f.events.onError();
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
else f.advance(5000);
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
assert.equal(f.timers.size, 0);
const calls = [...f.calls];
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
f.session.destroy();
}
});
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.session.destroy();
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.events.onError(); f.advance(20000);
assert.deepEqual(f.calls, ['destroy']);
assert.deepEqual(f.statuses, []);
assert.equal(f.timers.size, 0);
let constructCount = 0;
let resolveApi;
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
await Promise.resolve();
session.destroy();
resolveApi({ Player: class { constructor() { constructCount++; } } });
await Promise.resolve(); await Promise.resolve();
assert.equal(constructCount, 0);
});
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
const nodes = [];
class Element extends EventTarget {
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
setAttribute() {}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; }
}
const documentRef = {
body: new Element('body'),
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} });
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
assert.equal(media.show(options), true);
const frame = nodes.find((node) => node.tag === 'iframe');
const card = nodes.find((node) => node.tag === 'section');
assert.ok(card.parent);
assert.equal(media.pause(), true);
assert.equal(card.parent, null);
frame.dispatchEvent(new Event('load'));
assert.equal(card.parent, null);
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
media.destroy();
assert.equal(documentRef.body.children.length, 0);
});
test('resolves supported embedded callout providers from canonical public URLs', () => {
assert.deepEqual(
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
{
provider: 'youtube',
id: 'DbqRexFxv3k',
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
'facebook',
);
assert.deepEqual(
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
{
provider: 'x',
id: '2092640554568007958',
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
'x',
);
assert.equal(
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
'DbqRexFxv3k',
);
});
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
for (const value of [
'https://x.com.evil.example/user/status/2092640554568007958',
'https://x.com/Indowatchosint',
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
'http://www.youtube.com/watch?v=DbqRexFxv3k',
'https://localhost/video.mp4',
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
]) {
assert.equal(resolveEmbeddedMediaSource(value), null, value);
}
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
});
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
autoplay: true,
startAtSec: 26,
}));
assert.equal(timestampedFrame.searchParams.get('start'), '26');
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
assert.equal(facebookFrame.hostname, 'www.facebook.com');
assert.equal(facebookFrame.pathname, '/plugins/video.php');
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
assert.equal(embeddedMediaFrameUrl(x), null);
});
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
const media = bounded[0].media;
assert.equal(media.embedEndAtSec, 7);
const source = resolveEmbeddedMediaSource(media.sourceUrl);
for (const autoplay of [false, true]) {
const url = new URL(embeddedMediaFrameUrl(source, {
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
}));
assert.equal(url.searchParams.get('end'), '7');
}
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
assert.equal(url.searchParams.has('end'), false);
}
});
function preloadFixture(options = {}) {
class Element extends EventTarget {
constructor(tag) {
super(); this.tag = tag; this.children = []; this.dataset = {};
this.classList = { add() {}, remove() {}, contains() { return false; } };
}
setAttribute() {}
removeAttribute() {}
append(...children) {
for (const child of children) { child.remove(); child.parent = this; this.children.push(child); }
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this);
this.parent = null;
}
}
const documentRef = {
body: new Element('body'),
createElement: tag => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {}, ...options });
return { media, root: documentRef.body.children[0] };
}
for (const operation of ['pause', 'hide', 'destroy']) {
test(`${operation} removes a hidden YouTube preload`, () => {
const { media, root } = preloadFixture();
media.warm({ sourceUrl: 'https://youtu.be/DbqRexFxv3k' });
assert.equal(root.children.length, 1);
media[operation]();
assert.equal(root.children.length, 0);
media.destroy();
});
}
test('camera updates retain a preload until the visible player loads', () => {
const { media, root } = preloadFixture();
const observation = { media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } };
media.warm({ observation });
const frame = root.children[0];
media.hide({ preserveWarm: true });
media.warm({ observation });
assert.equal(root.children[0], frame);
assert.equal(media.show({ observation, anchor: {} }), true);
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
assert.equal(root.children.length, 0);
});
test('hiding revokes a pending Facebook SDK before it can mount or subscribe', async () => {
let resolveApi;
const calls = [];
const { media, root } = preloadFixture({
facebookLoader: () => new Promise(resolve => { resolveApi = resolve; }),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
assert.equal(root.children.length, 1);
media.hide();
assert.equal(root.children.length, 0);
resolveApi({ Event: { subscribe() { calls.push('subscribe'); } }, XFBML: { parse() { calls.push('parse'); } } });
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(calls, []);
media.destroy();
});
test('pausing cancels a mounted Facebook preload and rejects its late ready callback', async () => {
let ready;
let unsubscribed = 0;
const timers = new Set();
const playerCalls = [];
const { media, root } = preloadFixture({
globalRef: { setTimeout(fn) { timers.add(fn); return fn; }, clearTimeout(fn) { timers.delete(fn); } },
facebookLoader: async () => ({
Event: { subscribe(_name, callback) { ready = callback; }, unsubscribe() { unsubscribed++; } },
XFBML: { parse() {} },
}),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
await Promise.resolve(); await Promise.resolve();
assert.equal(timers.size, 1);
media.pause();
assert.equal(root.children.length, 0);
assert.equal(timers.size, 0);
assert.equal(unsubscribed, 1);
ready({ type: 'video', instance: { play() { playerCalls.push('play'); }, pause() { playerCalls.push('pause'); } } });
assert.deepEqual(playerCalls, []);
media.destroy();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+499
View File
@@ -0,0 +1,499 @@
/**
* Simplified GeoPera river centerline from the point nearest the owner-provided
* Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published
* vertex is retained, plus the exact endpoint nearest the verified bazaar pin.
*
* Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson
* License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data)
*/
export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([
Object.freeze([85.48476, 28.33255]),
Object.freeze([85.48353, 28.33308]),
Object.freeze([85.48231, 28.33279]),
Object.freeze([85.48108, 28.33278]),
Object.freeze([85.47985, 28.33304]),
Object.freeze([85.47863, 28.33275]),
Object.freeze([85.47741, 28.33301]),
Object.freeze([85.47618, 28.33299]),
Object.freeze([85.47496, 28.33325]),
Object.freeze([85.47373, 28.33351]),
Object.freeze([85.4725, 28.33377]),
Object.freeze([85.47128, 28.33375]),
Object.freeze([85.47005, 28.33401]),
Object.freeze([85.46881, 28.33481]),
Object.freeze([85.46759, 28.33506]),
Object.freeze([85.46636, 28.33532]),
Object.freeze([85.46513, 28.33585]),
Object.freeze([85.4639, 28.33584]),
Object.freeze([85.46267, 28.33609]),
Object.freeze([85.46145, 28.33635]),
Object.freeze([85.46022, 28.33661]),
Object.freeze([85.45899, 28.33659]),
Object.freeze([85.45777, 28.33685]),
Object.freeze([85.45655, 28.33629]),
Object.freeze([85.45533, 28.33628]),
Object.freeze([85.45411, 28.336]),
Object.freeze([85.45289, 28.33544]),
Object.freeze([85.45167, 28.33543]),
Object.freeze([85.45045, 28.33514]),
Object.freeze([85.44922, 28.33513]),
Object.freeze([85.448, 28.33511]),
Object.freeze([85.44677, 28.33537]),
Object.freeze([85.44554, 28.33563]),
Object.freeze([85.44432, 28.33534]),
Object.freeze([85.4431, 28.33533]),
Object.freeze([85.44188, 28.33504]),
Object.freeze([85.44065, 28.3353]),
Object.freeze([85.43943, 28.33529]),
Object.freeze([85.43821, 28.335]),
Object.freeze([85.43699, 28.33472]),
Object.freeze([85.43577, 28.33416]),
Object.freeze([85.43456, 28.33334]),
Object.freeze([85.43335, 28.33251]),
Object.freeze([85.43214, 28.33141]),
Object.freeze([85.43093, 28.33059]),
Object.freeze([85.42971, 28.33057]),
Object.freeze([85.42848, 28.33056]),
Object.freeze([85.42726, 28.33027]),
Object.freeze([85.42604, 28.33026]),
Object.freeze([85.42482, 28.33025]),
Object.freeze([85.4236, 28.32996]),
Object.freeze([85.42238, 28.32968]),
Object.freeze([85.42116, 28.32939]),
Object.freeze([85.41995, 28.32829]),
Object.freeze([85.41873, 28.32774]),
Object.freeze([85.41752, 28.32718]),
Object.freeze([85.41629, 28.32744]),
Object.freeze([85.41508, 28.32634]),
Object.freeze([85.41387, 28.32524]),
Object.freeze([85.41297, 28.32415]),
Object.freeze([85.41268, 28.32306]),
Object.freeze([85.41239, 28.32198]),
Object.freeze([85.41241, 28.3209]),
Object.freeze([85.41273, 28.31982]),
Object.freeze([85.41275, 28.31873]),
Object.freeze([85.41276, 28.31765]),
Object.freeze([85.41308, 28.31657]),
Object.freeze([85.41249, 28.31548]),
Object.freeze([85.4122, 28.3144]),
Object.freeze([85.41252, 28.31332]),
Object.freeze([85.41254, 28.31223]),
Object.freeze([85.41194, 28.31114]),
Object.freeze([85.41196, 28.31006]),
Object.freeze([85.41167, 28.30897]),
Object.freeze([85.41077, 28.30788]),
Object.freeze([85.40955, 28.30705]),
Object.freeze([85.40865, 28.30596]),
Object.freeze([85.40744, 28.30513]),
Object.freeze([85.40623, 28.30404]),
Object.freeze([85.40503, 28.30294]),
Object.freeze([85.40381, 28.30238]),
Object.freeze([85.4026, 28.30156]),
Object.freeze([85.40139, 28.30046]),
Object.freeze([85.40019, 28.29936]),
Object.freeze([85.39898, 28.29827]),
Object.freeze([85.39777, 28.29717]),
Object.freeze([85.39748, 28.29608]),
Object.freeze([85.39658, 28.29499]),
Object.freeze([85.39629, 28.2939]),
Object.freeze([85.3957, 28.29281]),
Object.freeze([85.39449, 28.29172]),
Object.freeze([85.39328, 28.29062]),
Object.freeze([85.39238, 28.28953]),
Object.freeze([85.39117, 28.28897]),
Object.freeze([85.38996, 28.28787]),
Object.freeze([85.38875, 28.28678]),
Object.freeze([85.38754, 28.28622]),
Object.freeze([85.38632, 28.28593]),
Object.freeze([85.38511, 28.28511]),
Object.freeze([85.3839, 28.28401]),
Object.freeze([85.383, 28.28292]),
Object.freeze([85.38179, 28.28182]),
Object.freeze([85.38089, 28.28073]),
Object.freeze([85.37968, 28.28017]),
Object.freeze([85.37846, 28.27934]),
Object.freeze([85.37725, 28.27852]),
Object.freeze([85.37666, 28.27743]),
Object.freeze([85.37668, 28.27634]),
Object.freeze([85.37669, 28.27526]),
Object.freeze([85.37701, 28.27418]),
Object.freeze([85.37642, 28.27309]),
Object.freeze([85.37644, 28.27201]),
Object.freeze([85.37615, 28.27092]),
Object.freeze([85.37616, 28.26984]),
Object.freeze([85.37648, 28.26876]),
Object.freeze([85.37558, 28.26767]),
Object.freeze([85.37499, 28.26658]),
Object.freeze([85.37378, 28.26548]),
Object.freeze([85.37288, 28.26439]),
Object.freeze([85.37198, 28.26329]),
Object.freeze([85.37169, 28.26221]),
Object.freeze([85.37048, 28.26111]),
Object.freeze([85.36928, 28.26001]),
Object.freeze([85.36806, 28.25973]),
Object.freeze([85.36685, 28.25863]),
Object.freeze([85.36656, 28.25754]),
Object.freeze([85.36566, 28.25645]),
Object.freeze([85.36507, 28.25536]),
Object.freeze([85.36478, 28.25427]),
Object.freeze([85.36449, 28.25318]),
Object.freeze([85.36512, 28.25211]),
Object.freeze([85.36452, 28.25102]),
Object.freeze([85.36423, 28.24993]),
Object.freeze([85.36425, 28.24885]),
Object.freeze([85.36335, 28.24776]),
Object.freeze([85.36245, 28.24666]),
Object.freeze([85.36124, 28.24556]),
Object.freeze([85.36004, 28.24447]),
Object.freeze([85.35882, 28.24391]),
Object.freeze([85.35823, 28.24282]),
Object.freeze([85.35733, 28.24173]),
Object.freeze([85.35734, 28.24064]),
Object.freeze([85.35797, 28.23957]),
Object.freeze([85.35799, 28.23849]),
Object.freeze([85.3577, 28.2374]),
Object.freeze([85.35802, 28.23632]),
Object.freeze([85.35834, 28.23524]),
Object.freeze([85.35928, 28.23417]),
Object.freeze([85.3596, 28.23309]),
Object.freeze([85.36023, 28.23202]),
Object.freeze([85.36024, 28.23093]),
Object.freeze([85.36057, 28.22985]),
Object.freeze([85.36058, 28.22877]),
Object.freeze([85.3609, 28.22769]),
Object.freeze([85.36092, 28.22661]),
Object.freeze([85.36094, 28.22553]),
Object.freeze([85.36126, 28.22445]),
Object.freeze([85.36067, 28.22336]),
Object.freeze([85.36007, 28.22227]),
Object.freeze([85.35886, 28.22117]),
Object.freeze([85.35796, 28.22008]),
Object.freeze([85.35706, 28.21898]),
Object.freeze([85.35616, 28.21789]),
Object.freeze([85.35526, 28.21679]),
Object.freeze([85.35467, 28.2157]),
Object.freeze([85.35469, 28.21462]),
Object.freeze([85.3547, 28.21354]),
Object.freeze([85.35472, 28.21246]),
Object.freeze([85.35443, 28.21137]),
Object.freeze([85.35414, 28.21028]),
Object.freeze([85.35416, 28.2092]),
Object.freeze([85.35448, 28.20812]),
Object.freeze([85.3548, 28.20704]),
Object.freeze([85.35482, 28.20596]),
Object.freeze([85.35392, 28.20486]),
Object.freeze([85.35332, 28.20377]),
Object.freeze([85.35303, 28.20269]),
Object.freeze([85.35275, 28.2016]),
Object.freeze([85.35246, 28.20052]),
Object.freeze([85.35247, 28.19943]),
Object.freeze([85.35188, 28.19834]),
Object.freeze([85.35128, 28.19725]),
Object.freeze([85.35038, 28.19616]),
Object.freeze([85.3501, 28.19507]),
Object.freeze([85.3495, 28.19398]),
Object.freeze([85.34891, 28.19289]),
Object.freeze([85.34862, 28.1918]),
Object.freeze([85.34802, 28.19071]),
Object.freeze([85.34743, 28.18962]),
Object.freeze([85.34622, 28.18853]),
Object.freeze([85.34593, 28.18744]),
Object.freeze([85.34565, 28.18635]),
Object.freeze([85.34475, 28.18526]),
Object.freeze([85.34385, 28.18417]),
Object.freeze([85.34325, 28.18308]),
Object.freeze([85.34266, 28.18199]),
Object.freeze([85.34237, 28.1809]),
Object.freeze([85.34239, 28.17982]),
Object.freeze([85.34271, 28.17874]),
Object.freeze([85.34272, 28.17765]),
Object.freeze([85.34274, 28.17657]),
Object.freeze([85.34215, 28.17548]),
Object.freeze([85.34216, 28.1744]),
Object.freeze([85.34249, 28.17332]),
Object.freeze([85.34189, 28.17223]),
Object.freeze([85.34191, 28.17115]),
Object.freeze([85.34192, 28.17006]),
Object.freeze([85.34255, 28.16899]),
Object.freeze([85.34226, 28.1679]),
Object.freeze([85.34289, 28.16683]),
Object.freeze([85.3423, 28.16574]),
Object.freeze([85.3417, 28.16465]),
Object.freeze([85.34048, 28.16436]),
Object.freeze([85.33928, 28.16353]),
Object.freeze([85.33807, 28.16271]),
Object.freeze([85.33747, 28.16162]),
Object.freeze([85.33718, 28.16053]),
Object.freeze([85.33628, 28.15971]),
Object.freeze([85.33507, 28.15888]),
Object.freeze([85.33385, 28.15859]),
Object.freeze([85.33295, 28.15777]),
Object.freeze([85.33174, 28.15694]),
Object.freeze([85.33053, 28.15639]),
Object.freeze([85.32932, 28.15529]),
Object.freeze([85.32842, 28.1542]),
Object.freeze([85.32721, 28.15337]),
Object.freeze([85.32631, 28.15228]),
Object.freeze([85.3251, 28.15145]),
Object.freeze([85.32389, 28.15089]),
Object.freeze([85.32267, 28.15061]),
Object.freeze([85.32147, 28.14978]),
Object.freeze([85.32026, 28.14895]),
Object.freeze([85.31904, 28.1484]),
Object.freeze([85.31783, 28.14784]),
Object.freeze([85.31663, 28.14674]),
Object.freeze([85.31541, 28.14619]),
Object.freeze([85.3142, 28.14536]),
Object.freeze([85.313, 28.14426]),
Object.freeze([85.3124, 28.14317]),
Object.freeze([85.31181, 28.14208]),
Object.freeze([85.31058, 28.14261]),
Object.freeze([85.30936, 28.14232]),
Object.freeze([85.30816, 28.14122]),
Object.freeze([85.30695, 28.14012]),
Object.freeze([85.30575, 28.13903]),
Object.freeze([85.30485, 28.13793]),
Object.freeze([85.30456, 28.13685]),
Object.freeze([85.30366, 28.13575]),
Object.freeze([85.30307, 28.13466]),
Object.freeze([85.30248, 28.13357]),
Object.freeze([85.30127, 28.13274]),
Object.freeze([85.30067, 28.13165]),
Object.freeze([85.30039, 28.13057]),
Object.freeze([85.3001, 28.12948]),
Object.freeze([85.29889, 28.12838]),
Object.freeze([85.29799, 28.12729]),
Object.freeze([85.29709, 28.12647]),
Object.freeze([85.29588, 28.12591]),
Object.freeze([85.29467, 28.12508]),
Object.freeze([85.29347, 28.12398]),
Object.freeze([85.29226, 28.12316]),
Object.freeze([85.29105, 28.12206]),
Object.freeze([85.28985, 28.12096]),
Object.freeze([85.28864, 28.11986]),
Object.freeze([85.28775, 28.11877]),
Object.freeze([85.28653, 28.11821]),
Object.freeze([85.28532, 28.11765]),
Object.freeze([85.28411, 28.11683]),
Object.freeze([85.2829, 28.116]),
Object.freeze([85.28231, 28.11491]),
Object.freeze([85.28172, 28.11382]),
Object.freeze([85.28082, 28.11272]),
Object.freeze([85.27961, 28.11217]),
Object.freeze([85.2787, 28.11134]),
Object.freeze([85.27749, 28.11079]),
Object.freeze([85.27629, 28.10969]),
Object.freeze([85.27508, 28.10859]),
Object.freeze([85.27388, 28.10749]),
Object.freeze([85.27267, 28.10667]),
Object.freeze([85.27177, 28.10584]),
Object.freeze([85.27056, 28.10474]),
Object.freeze([85.26936, 28.10392]),
Object.freeze([85.26815, 28.10309]),
Object.freeze([85.26695, 28.10199]),
Object.freeze([85.26574, 28.10089]),
Object.freeze([85.26454, 28.09979]),
Object.freeze([85.26364, 28.0987]),
Object.freeze([85.26274, 28.09761]),
Object.freeze([85.26153, 28.09678]),
Object.freeze([85.26033, 28.09568]),
Object.freeze([85.25911, 28.09539]),
Object.freeze([85.2579, 28.09511]),
Object.freeze([85.25668, 28.09509]),
Object.freeze([85.25546, 28.09481]),
Object.freeze([85.25425, 28.09398]),
Object.freeze([85.25304, 28.09342]),
Object.freeze([85.25183, 28.09286]),
Object.freeze([85.25061, 28.09285]),
Object.freeze([85.24939, 28.09256]),
Object.freeze([85.24818, 28.09201]),
Object.freeze([85.24696, 28.09199]),
Object.freeze([85.24573, 28.09252]),
Object.freeze([85.24451, 28.0925]),
Object.freeze([85.24328, 28.09248]),
Object.freeze([85.24207, 28.0922]),
Object.freeze([85.24085, 28.09191]),
Object.freeze([85.23963, 28.0919]),
Object.freeze([85.23841, 28.09161]),
Object.freeze([85.2372, 28.09132]),
Object.freeze([85.23598, 28.09104]),
Object.freeze([85.23476, 28.09102]),
Object.freeze([85.23355, 28.09019]),
Object.freeze([85.23234, 28.08964]),
Object.freeze([85.23113, 28.08881]),
Object.freeze([85.22993, 28.08771]),
Object.freeze([85.22872, 28.08688]),
Object.freeze([85.22782, 28.08579]),
Object.freeze([85.22692, 28.08496]),
Object.freeze([85.22571, 28.08414]),
Object.freeze([85.2245, 28.08358]),
Object.freeze([85.22329, 28.08302]),
Object.freeze([85.22208, 28.08219]),
Object.freeze([85.22087, 28.08164]),
Object.freeze([85.21966, 28.08108]),
Object.freeze([85.21876, 28.08026]),
Object.freeze([85.21755, 28.07943]),
Object.freeze([85.21635, 28.07833]),
Object.freeze([85.21514, 28.07723]),
Object.freeze([85.21394, 28.0764]),
Object.freeze([85.21272, 28.07585]),
Object.freeze([85.21151, 28.07529]),
Object.freeze([85.2103, 28.075]),
Object.freeze([85.2094, 28.07391]),
Object.freeze([85.20881, 28.07282]),
Object.freeze([85.20791, 28.07172]),
Object.freeze([85.20762, 28.07063]),
Object.freeze([85.20672, 28.06954]),
Object.freeze([85.20552, 28.06844]),
Object.freeze([85.20523, 28.06735]),
Object.freeze([85.20495, 28.06627]),
Object.freeze([85.20588, 28.0652]),
Object.freeze([85.2062, 28.06412]),
Object.freeze([85.20683, 28.06304]),
Object.freeze([85.20716, 28.06196]),
Object.freeze([85.20717, 28.06088]),
Object.freeze([85.2078, 28.05981]),
Object.freeze([85.20751, 28.05872]),
Object.freeze([85.20814, 28.05765]),
Object.freeze([85.20847, 28.05657]),
Object.freeze([85.20787, 28.05548]),
Object.freeze([85.20759, 28.05439]),
Object.freeze([85.20699, 28.0533]),
Object.freeze([85.2061, 28.0522]),
Object.freeze([85.20489, 28.05138]),
Object.freeze([85.20369, 28.05028]),
Object.freeze([85.2031, 28.04919]),
Object.freeze([85.2022, 28.04809]),
Object.freeze([85.20191, 28.04701]),
Object.freeze([85.20101, 28.04591]),
Object.freeze([85.19981, 28.04508]),
Object.freeze([85.1986, 28.04398]),
Object.freeze([85.19739, 28.04343]),
Object.freeze([85.1965, 28.04233]),
Object.freeze([85.19529, 28.0415]),
Object.freeze([85.19439, 28.04041]),
Object.freeze([85.1938, 28.03932]),
Object.freeze([85.19382, 28.03824]),
Object.freeze([85.19384, 28.03715]),
Object.freeze([85.19355, 28.03607]),
Object.freeze([85.19357, 28.03498]),
Object.freeze([85.19328, 28.0339]),
Object.freeze([85.1936, 28.03282]),
Object.freeze([85.19362, 28.03174]),
Object.freeze([85.19364, 28.03065]),
Object.freeze([85.19305, 28.02956]),
Object.freeze([85.19215, 28.02847]),
Object.freeze([85.19156, 28.02738]),
Object.freeze([85.19097, 28.02629]),
Object.freeze([85.18976, 28.02573]),
Object.freeze([85.18854, 28.02571]),
Object.freeze([85.18732, 28.02515]),
Object.freeze([85.18611, 28.0246]),
Object.freeze([85.18522, 28.0235]),
Object.freeze([85.18493, 28.02242]),
Object.freeze([85.18556, 28.02134]),
Object.freeze([85.1868, 28.02027]),
Object.freeze([85.18742, 28.0192]),
Object.freeze([85.18714, 28.01811]),
Object.freeze([85.18746, 28.01703]),
Object.freeze([85.18656, 28.01594]),
Object.freeze([85.18536, 28.01484]),
Object.freeze([85.18446, 28.01375]),
Object.freeze([85.18418, 28.01266]),
Object.freeze([85.18389, 28.01157]),
Object.freeze([85.1836, 28.01049]),
Object.freeze([85.18362, 28.0094]),
Object.freeze([85.18364, 28.00832]),
Object.freeze([85.18366, 28.00724]),
Object.freeze([85.18368, 28.00615]),
Object.freeze([85.1843, 28.00508]),
Object.freeze([85.18432, 28.004]),
Object.freeze([85.18404, 28.00291]),
Object.freeze([85.18375, 28.00182]),
Object.freeze([85.18438, 28.00075]),
Object.freeze([85.18501, 27.99967]),
Object.freeze([85.18533, 27.9986]),
Object.freeze([85.18596, 27.99752]),
Object.freeze([85.18567, 27.99643]),
Object.freeze([85.18477, 27.99534]),
Object.freeze([85.18357, 27.99424]),
Object.freeze([85.18237, 27.99314]),
Object.freeze([85.18239, 27.99206]),
Object.freeze([85.18302, 27.99098]),
Object.freeze([85.18273, 27.9899]),
Object.freeze([85.18152, 27.98907]),
Object.freeze([85.18062, 27.98824]),
Object.freeze([85.18034, 27.98716]),
Object.freeze([85.18066, 27.98608]),
Object.freeze([85.18037, 27.98499]),
Object.freeze([85.18039, 27.98391]),
Object.freeze([85.18041, 27.98283]),
Object.freeze([85.18043, 27.98174]),
Object.freeze([85.18075, 27.98067]),
Object.freeze([85.18138, 27.97959]),
Object.freeze([85.1814, 27.97851]),
Object.freeze([85.18172, 27.97743]),
Object.freeze([85.18204, 27.97635]),
Object.freeze([85.18236, 27.97527]),
Object.freeze([85.18299, 27.9742]),
Object.freeze([85.18362, 27.97312]),
Object.freeze([85.18425, 27.97205]),
Object.freeze([85.18396, 27.97096]),
Object.freeze([85.18275, 27.9704]),
Object.freeze([85.18154, 27.97012]),
Object.freeze([85.18033, 27.96956]),
Object.freeze([85.17911, 27.96927]),
Object.freeze([85.17821, 27.96818]),
Object.freeze([85.17762, 27.96709]),
Object.freeze([85.17703, 27.966]),
Object.freeze([85.17613, 27.9649]),
Object.freeze([85.17492, 27.96434]),
Object.freeze([85.17371, 27.96406]),
Object.freeze([85.1725, 27.9635]),
Object.freeze([85.17128, 27.96321]),
Object.freeze([85.17008, 27.96238]),
Object.freeze([85.16888, 27.96129]),
Object.freeze([85.16859, 27.9602]),
Object.freeze([85.168, 27.95911]),
Object.freeze([85.16771, 27.95802]),
Object.freeze([85.16682, 27.95693]),
Object.freeze([85.16561, 27.95637]),
Object.freeze([85.1644, 27.95581]),
Object.freeze([85.16318, 27.95552]),
Object.freeze([85.16198, 27.95442]),
Object.freeze([85.1623, 27.95335]),
Object.freeze([85.16232, 27.95226]),
Object.freeze([85.16234, 27.95118]),
Object.freeze([85.16236, 27.9501]),
Object.freeze([85.16238, 27.94902]),
Object.freeze([85.1627, 27.94794]),
Object.freeze([85.16241, 27.94685]),
Object.freeze([85.16152, 27.94576]),
Object.freeze([85.16093, 27.94466]),
Object.freeze([85.16034, 27.94357]),
Object.freeze([85.15974, 27.94248]),
Object.freeze([85.15915, 27.94139]),
Object.freeze([85.15887, 27.9403]),
Object.freeze([85.15797, 27.93921]),
Object.freeze([85.15738, 27.93812]),
Object.freeze([85.15679, 27.93703]),
Object.freeze([85.1562, 27.93594]),
Object.freeze([85.15561, 27.93485]),
Object.freeze([85.15532, 27.93376]),
Object.freeze([85.15473, 27.93267]),
Object.freeze([85.15352, 27.93184]),
Object.freeze([85.15229, 27.93291]),
Object.freeze([85.15105, 27.9337]),
Object.freeze([85.15015, 27.93288]),
Object.freeze([85.14987, 27.93179]),
Object.freeze([85.14988, 27.93071]),
Object.freeze([85.14929, 27.92962]),
Object.freeze([85.14901, 27.92853]),
Object.freeze([85.14994, 27.92746]),
Object.freeze([85.15057, 27.92639]),
Object.freeze([85.15089, 27.92531]),
Object.freeze([85.15121, 27.92423]),
Object.freeze([85.15123, 27.92315]),
Object.freeze([85.15034, 27.92205]),
Object.freeze([85.14943, 27.92177]),
]);
+117
View File
@@ -0,0 +1,117 @@
// Overview pins follow the event pack through Devighat.
// Charaudi remains a separate evidence shot, and none of these observation
// locations extends the mapped flood path.
export const INCIDENT_OVERVIEW_PLACES = Object.freeze(
[
[
'immediate-collapse-viewpoint',
'COLLAPSE VIEWPOINT',
28.3632333,
85.4423639,
2850,
'left',
],
[
'debris-dammed-lake',
'DAMMED LAKE',
28.3346111,
85.4861389,
2750,
'right',
],
[
'second-landslide',
'SECOND LANDSLIDE',
28.3379706,
85.4688823,
2750,
'left',
],
[
'gyirong-border-gate',
'BORDER GATE',
28.2808338,
85.3780248,
1818,
'right',
],
['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'],
[
'syabru-besi',
'SYABRU BESI',
28.1602583333333,
85.3325666666667,
1417,
'right',
],
['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'],
[
'mailung-upper-trishuli',
'UPPER TRISHULI',
28.090925,
85.2322916666667,
950,
'right',
],
[
'mailung-bazzar',
'MAILUNG',
28.0761666666667,
85.2125277777778,
800,
'left',
],
[
'dandagaun',
'DANDAGAUN',
28.0751944444444,
85.2076944444444,
780,
'right-lower',
],
[
'dandagaun-viewpoint',
'DANDAGAUN VIEWPOINT',
28.0354,
85.1894361111111,
700,
'right',
],
[
'betrawati-bazaar',
'BETRAWATI',
27.9758992572246,
85.1835331689033,
600,
'left',
],
[
'bhainse',
'BHAINSE',
27.9623111111111,
85.1739777777778,
500,
'right-lower',
],
[
'bidur-trishuli-bridge',
'BIDUR BRIDGE',
27.920858,
85.148029,
510,
'left',
],
[
'devighat-taadi-khola-bridge',
'DEVIGHAT BRIDGE',
27.8585278,
85.1090318,
350,
'right',
],
].map(([id, title, lat, lon, heightM, placement]) =>
Object.freeze({ id, title, lat, lon, heightM, placement }),
),
);
export const INCIDENT_OVERVIEW_HOLD_SEC = 11;
File diff suppressed because it is too large. Load diff
+676
View File
@@ -0,0 +1,676 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
BHOTE_KOSHI_REGIONAL_PRESENTATION,
BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
closeRegionRing,
createBhoteKoshiLocatorLayer,
largestGeoJsonRing,
NEPAL_CONTEXT_PRESENTATION,
revealPolylinePositions,
} from './bhoteKoshiLocator.js';
function viewerFixture() {
const added = [];
const removed = [];
return {
creditDisplay: { addStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
},
_test: { added, removed },
};
}
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
cancelFrame() {},
};
}
function overlayHostFixture() {
const publications = [];
const clears = [];
return {
setEntries(sourceId, entries, options) {
publications.push({ sourceId, entries, options });
},
clearSource(sourceId) {
clears.push(sourceId);
},
_test: { publications, clears },
};
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
});
test('Bhote Koshi locator reveals a border progressively', () => {
const positions = [
new Cesium.Cartesian3(0, 0, 0),
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
]);
assert.equal(revealPolylinePositions(positions, 1), positions);
});
test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => {
assert.deepEqual(anchoredCalloutPresentation(0), {
alpha: 0,
leaderProgress: 0,
contentAlpha: 0,
scale: 0.96,
});
assert.deepEqual(anchoredCalloutPresentation(1200), {
alpha: 1,
leaderProgress: 1,
contentAlpha: 1,
scale: 1,
});
});
test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const renders = [];
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
assert.equal(await layer.init(viewer), true);
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.added.length, 1);
const source = viewer._test.added[0];
const ids = source.entities.values.map((entity) => entity.id);
for (const id of [
'nepal-context-halo',
'nepal-border-fill',
'nepal-border-highlight',
'bhote-koshi-incident-halo',
]) {
assert.ok(ids.includes(id), `${id} belongs to the regional sequence`);
}
const incidentHalo = source.entities.getById('bhote-koshi-incident-halo');
const border = source.entities.getById('nepal-border-highlight');
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
assert.equal(incident.variant, 'card');
assert.equal(incident.paintLane, 'thumbnail');
assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT');
assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']);
assert.equal(incident.anchorDot, true);
assert.equal(incident.leaderStyle, 'elbow');
assert.equal('image' in incident, false);
assert.equal('requireImage' in incident, false);
assert.equal(incident.sourceAlpha(), 0);
assert.equal(incident.leaderProgress(), 0);
assert.equal(incident.contentAlpha(), 0);
assert.equal(incident.presentationScale(), 0.96);
assert.equal(nepal.sourceAlpha(), 0);
assert.ok(border.polyline.positions.getValue().length < 5);
assert.equal(incidentHalo.show.getValue(), false);
assert.equal(incidentHalo.point.pixelSize.getValue(), 16);
nowMs = 3100;
assert.equal(nepal.sourceAlpha(), 1);
assert.equal(border.polyline.positions.getValue().length, 5);
assert.equal(incidentHalo.show.getValue(), false);
nowMs = 3300;
assert.equal(incidentHalo.show.getValue(), true);
assert.equal(incident.sourceAlpha(), 0);
nowMs = 4500;
assert.equal(incident.sourceAlpha(), 1);
assert.equal(incident.leaderProgress(), 1);
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(layer.getStats().status, 'idle');
});
test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
await layer.destroy();
});
test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(labels.length, 15);
assert.equal(overlayHost._test.publications.at(-1).options.moving, true);
for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) {
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
await layer.destroy();
});
test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
assert.equal(bidur.title, '14 · BIDUR BRIDGE');
for (const place of INCIDENT_OVERVIEW_PLACES) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.sourceAlpha(), 1, place.id);
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
nowMs = 250;
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, 2);
nowMs = 2250;
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
});
await layer.enable(viewer);
const source = viewer._test.added[0];
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
]);
assert.equal(record.interactive, true);
assert.equal(typeof record.activate, 'function');
assert.equal(record.sourceAlpha(), 0);
nowMs = 1200;
assert.equal(record.sourceAlpha(), 1);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record');
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.destroy();
});
test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
const flights = [];
let armedCallback = null;
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
scheduleDelay(callback) {
armedCallback = callback;
return 1;
},
cancelDelay() {},
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
assert.equal(flights.length, 0);
assert.equal(typeof armedCallback, 'function');
moveStart.raiseEvent();
assert.equal(flights.length, 0);
moveEnd.raiseEvent();
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
assert.equal(flights[1].duration, 2.8);
const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6);
assert.ok(Math.abs(orbit.height - 10526) < 1e-4);
await layer.destroy();
});
for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => {
const viewer = viewerFixture();
const timers = new Map();
const callbacks = [];
const flights = [];
let nextTimer = 0;
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
overlayHost: overlayHostFixture(),
requestRender() {},
scheduleDelay(callback) {
callbacks.push(callback);
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
if (phase === 'orbit') flights[0].complete();
const oldFlights = [...flights];
SceneDirector.prototype.stopScene.call(director);
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
assert.equal(moveStart.numberOfListeners, 0);
assert.equal(moveEnd.numberOfListeners, 0);
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
});
}
test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
nowMs = 350;
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
nowMs = 850;
assert.equal(kathmandu.sourceAlpha(), 1);
assert.ok(bhaktapur.sourceAlpha() > 0);
assert.ok(dhulikhel.sourceAlpha() > 0);
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const originalWarn = console.warn;
console.warn = () => {};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
await layer.destroy();
}
});
test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
await layer.destroy();
});
test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => {
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+97
View File
@@ -0,0 +1,97 @@
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
export const SOURCE_PATH_BEAT_IDS = Object.freeze([
'debris-dammed-lake',
'second-landslide',
'dhunche',
'mailung-upper-trishuli',
'mailung-bazzar',
'dandagaun',
'dandagaun-viewpoint',
'betrawati-bazaar',
'bhainse',
'bidur-trishuli-bridge',
]);
function nearestVertex(point, route) {
if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat))
return -1;
const longitudeScale = Math.cos((point.lat * Math.PI) / 180);
let nearest = -1;
let distance = 1000; // Witness viewpoints can sit beside the river, not on it.
route.forEach(([lon, lat], index) => {
const metres =
Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195;
if (metres < distance) {
distance = metres;
nearest = index;
}
});
return nearest;
}
/** Split the already-bundled GeoPera centreline; never connect witness pins. */
export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) {
const observations = new Map(
(event.evidenceSpine || []).map((point) => [point.id, point]),
);
const result = new Map();
const add = (id, from, to, { joinComparison = false } = {}) => {
let start = nearestVertex(from, route);
const end = nearestVertex(to, route);
if (start < 0 || end <= start) return;
const startHeight = from.fallbackElevationM ?? from.elevationM;
const endHeight = to.fallbackElevationM ?? to.elevationM;
if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return;
if (joinComparison) {
// The raster corridor ends between simplified centreline vertices.
// Snapping to its nearest vertex can move the head upstream on entry.
// Keep that already-sourced endpoint and continue to the next vertex
// downstream of it, rather than replaying the upstream half-segment.
const [lon, lat] = route[start];
const [nextLon, nextLat] = route[start + 1];
const scale = Math.cos((from.lat * Math.PI) / 180);
const along =
(from.lon - lon) * (nextLon - lon) * scale * scale +
(from.lat - lat) * (nextLat - lat);
if (along > 0) start++;
}
const coordinates = route.slice(start, end + 1);
if (
joinComparison &&
(coordinates[0][0] !== from.lon || coordinates[0][1] !== from.lat)
) {
coordinates.unshift([from.lon, from.lat]);
}
result.set(
id,
coordinates.map(([lon, lat], index) => ({
lon,
lat,
// Stable source profile for explicit source-elevation shots; otherwise a
// fallback when neither rendered mesh nor elevation terrain can be sampled.
fallbackElevationM:
startHeight +
((endHeight - startHeight) * index) / (coordinates.length - 1),
})),
);
};
add(
'debris-dammed-lake',
observations.get('debris-dammed-lake'),
observations.get('second-landslide'),
);
add(
'second-landslide',
observations.get('second-landslide'),
observations.get('gyirong-border-gate'),
);
let previous = event.reconstruction?.corridor?.at(-1);
for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) {
const point = observations.get(id);
add(id, previous, point, { joinComparison: id === 'dhunche' });
// Missing coverage breaks continuity rather than inventing a long connector.
previous = point;
}
return result;
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url)));
test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => {
const lakeStart = [85.48476, 28.33255];
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart);
assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false);
const paths = buildSourceShotPaths(event);
const first = paths.get('debris-dammed-lake')[0];
assert.deepEqual([first.lon, first.lat], lakeStart);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length),
paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat]));
});
test('ten shot paths use only the existing centreline and join at shared endpoints', () => {
const paths = buildSourceShotPaths(event);
assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS);
const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`));
const boundary = event.reconstruction.corridor.at(-1);
vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff.
for (const points of paths.values()) {
assert.ok(points.length >= 2);
assert.ok(points.every(({ lon, lat, fallbackElevationM }) => (
vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM)
)));
}
const coordinate = (point) => [point.lon, point.lat];
for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) {
for (let index = 1; index < ids.length; index++) {
assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0]));
}
}
assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1));
for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) {
assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route');
}
});
test('missing and distant observations fail closed without connecting witness coordinates', () => {
const broken = structuredClone(event);
broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0;
const paths = buildSourceShotPaths(broken);
assert.equal(paths.has('dhunche'), false);
assert.equal(paths.has('mailung-upper-trishuli'), false);
assert.equal(buildSourceShotPaths({}).size, 0);
});
test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => {
const boundary = event.reconstruction.corridor.at(-1);
const path = buildSourceShotPaths(event).get('dhunche');
assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]);
assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint');
});
+10 -8
View File
@@ -1,13 +1,15 @@
import { createApplicationBikeshare } from '../app/layers/bikeshare.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js';
import { createBikeshareLayer } from '../layers/bikeshare/index.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as overlays from '../overlays/worldOverlay.js';
const layer = createBikeshareLayer({
source: createBikeshareSource(),
services: { render, sprites, picking, overlays },
const sourceSlot = createSourceSlot(
createBikeshareSource(),
['getStations'],
'Bikeshare source',
);
export const configureBikeshareSource = sourceSlot.configure;
const layer = createApplicationBikeshare({
source: sourceSlot.source,
});
export const createBikeshareSelectedOverlayEntry =
layer.createBikeshareSelectedOverlayEntry;
+93
View File
@@ -0,0 +1,93 @@
// src/data/cameraSensitivity.js
/**
* Shared claims on `camera.percentageChanged`.
*
* `camera.changed` fires once the view has moved by this fraction, and it is a
* single number on the camera that every listener in the app shares. Several
* layers want it finer than the default — traffic, bikeshare and transit all
* ask for 0.05 so they notice they have entered a coverage area — and each of
* them used to write the number directly and hand back whatever it remembered.
*
* That cannot work, because a number is not an owner. Two layers asking for the
* same 0.05 write the same value, so when the first one leaves it sees its own
* number still in place, concludes nothing else has claimed it, and restores
* the coarse default out from under a layer that is still running. The symptom
* is a camera-driven layer that quietly stops noticing the camera: Transit is
* turned off and Bikeshare stops loading new cities until something else nudges
* it.
*
* So ownership is tracked explicitly. Claims are named, the camera gets the
* finest value anyone has asked for, and the original value returns only when
* the last claim is gone. Claiming twice under one name replaces that claim.
*
* This is bookkeeping, not policy: it never decides what a layer should ask
* for, only that nobody's answer is taken away while they still need it.
*/
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
const _cameras = new WeakMap();
/** The value Cesium starts a camera at, used when the camera reports nothing. */
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
function ledgerFor(camera) {
let ledger = _cameras.get(camera);
if (!ledger) {
const base = Number.isFinite(camera.percentageChanged)
? camera.percentageChanged
: DEFAULT_PERCENTAGE_CHANGED;
ledger = { base, claims: new Map() };
_cameras.set(camera, ledger);
}
return ledger;
}
function applyLedger(camera, ledger) {
let value = ledger.base;
for (const claimed of ledger.claims.values()) {
if (claimed < value) value = claimed;
}
camera.percentageChanged = value;
return value;
}
/**
* Ask for a camera-change sensitivity at least as fine as `value`.
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`).
* @param {string} owner Stable claim name, usually the layer id.
* @param {number} value Requested `percentageChanged`.
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function claimCameraSensitivity(camera, owner, value) {
if (!camera || !owner || !Number.isFinite(value)) return null;
const ledger = ledgerFor(camera);
ledger.claims.set(owner, value);
return applyLedger(camera, ledger);
}
/**
* Give up one claim. The camera keeps the finest value still claimed by
* somebody else, and returns to its original only when none are left.
* @param {object|null|undefined} camera
* @param {string} owner
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function releaseCameraSensitivity(camera, owner) {
if (!camera || !owner) return null;
const ledger = _cameras.get(camera);
if (!ledger) return null;
if (!ledger.claims.delete(owner)) return camera.percentageChanged;
const applied = applyLedger(camera, ledger);
if (ledger.claims.size === 0) _cameras.delete(camera);
return applied;
}
/**
* Who currently holds a claim. For tests and diagnostics only.
* @param {object|null|undefined} camera
* @returns {Array<string>}
*/
export function cameraSensitivityClaims(camera) {
const ledger = camera ? _cameras.get(camera) : null;
return ledger ? [...ledger.claims.keys()] : [];
}
+13 -25
View File
@@ -1,32 +1,20 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationCctv } from '../app/layers/cctv.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js';
import { createCctvLayer } from '../layers/cctv/index.js';
import * as sprites from './spriteOrder.js';
import * as activation from '../cctvFocusRequest.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as locations from '../locations.js';
import * as picking from './pickRegistry.js';
import * as terrain from './terrainHeights.js';
import * as ground from './groundFloor.js';
import * as mesh from './meshFloorSampler.js';
import * as focus from './focusDeemphasis.js';
import * as render from '../renderGovernor.js';
const layer = createCctvLayer({
source: createCctvSource(),
services: {
sprites,
activation,
overlays,
locations,
picking,
terrain,
ground,
mesh,
focus,
render,
},
const sourceSlot = createSourceSlot(
createCctvSource(),
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
'Cctv source',
);
export const configureCctvSource = sourceSlot.configure;
const layer = createApplicationCctv({
surface: defaultSurface,
source: sourceSlot.source,
});
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
export const migrateRangeScaleForFloor = layer.migrateRangeScaleForFloor;
export const _pushAmbientCardEntriesForTest =
layer._pushAmbientCardEntriesForTest;
export const _setCctvOverlayHostForTest = layer._setCctvOverlayHostForTest;
+697 -185
View File
File diff suppressed because it is too large. Load diff
+389
View File
@@ -0,0 +1,389 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
calgaryCameraId,
calgaryCameraName,
calgaryCameraToSource,
loadCalgarySourcesFromOpenData,
normalizeCalgaryImageUrl,
} from '../../server/providers/cctv/sources.js';
import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { directionToHeading } from './directionText.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Open Calgary row, shaped like the live `k7p9-kppz` payload. */
const row = (overrides = {}) => ({
camera_url: {
url: 'http://trafficcam.calgary.ca/loc142.jpg',
description: 'Camera 143',
},
quadrant: 'SW',
camera_location: 'Bow Trail / 37 Street SW',
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
...overrides,
});
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
const source = calgaryCameraToSource(row());
assert.equal(source.id, 'calgary-142');
assert.equal(source.name, 'Bow Trail / 37 Street SW');
assert.equal(source.cityId, 'calgary');
assert.equal(source.provider, 'The City of Calgary');
assert.equal(source.lat, 51.0453097);
assert.equal(source.lon, -114.1413616);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'calgary-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – City of Calgary',
);
// The catalog publishes http://; the registered URL is the upgraded one.
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
});
test('no heading is derived from the quadrant or the address suffix', () => {
// Both fields parse as a confident compass bearing, and both would be wrong
// for every camera in the city: they are Calgary's address grid, not a
// camera facing. Nobody may "fix" this by wiring either one up.
assert.equal(directionToHeading('SW', true), 225);
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
const source = calgaryCameraToSource(row({ quadrant }));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the quadrant says.
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
}
});
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
// Toronto: a plausible lat/lon, but not a Calgary camera.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
),
null,
);
// Null island, and the string coordinates that would become it.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [0, 0] } }),
),
null,
);
assert.equal(calgaryCameraToSource(row({ point: null })), null);
assert.equal(
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
null,
);
// A catalog edit cannot steer the frame proxy off the city host.
assert.equal(
calgaryCameraToSource(
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
),
null,
);
assert.equal(
calgaryCameraToSource(
row({
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
}),
),
null,
);
assert.equal(
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
null,
);
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
assert.equal(calgaryCameraToSource(null), null);
});
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
assert.equal(
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(normalizeCalgaryImageUrl(''), null);
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
'calgary-86',
);
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
'calgary-cams-deerfoot-16av',
);
assert.equal(calgaryCameraId(''), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
calgaryCameraName(
{ camera_location: ' 9 Avenue / 3 Street SE ' },
'calgary-1',
),
'9 Avenue / 3 Street SE',
);
assert.equal(
calgaryCameraName(
{ camera_url: { description: 'Camera 12' } },
'calgary-11',
),
'Camera 12',
);
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
row(),
// Same frame file twice: one camera, not two.
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
]);
});
const cameras = await loadCalgarySourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
['calgary-142', 'calgary-86'],
);
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
const long = calgaryCameraToSource(
row({
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/rows.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['nsw', 10],
['calgary', 10],
],
);
// Calgary contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
);
});
/**
* Serve the Calgary catalog to the Calgary endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith('https://data.calgary.ca/')) {
return Response.json([
row(),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
]);
}
return Response.json([]);
});
// A source root with no curated catalogs or ground-height sidecar, so the
// file-based packs contribute nothing and only the live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Calgary lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_CALGARY_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
'the catalog refresh invokes the Calgary loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
['calgary-142', 'calgary-86'],
'Calgary cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_CALGARY_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop;
}
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
.filter(
(c) =>
c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx;
const accepted = [];
const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) { clear = false; break; }
if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) {
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
}
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
const t = Math.min(
1,
(h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}}
*/
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) {
return {
mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
launch:
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
};
}
return {
mode: 'steady',
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
@@ -230,7 +267,12 @@ export function createFrameSlot() {
export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot();
if (result?.ok && result.frame) {
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
.sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id);
}
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
allocateSourceCap,
resolveCatalogCap,
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({
id: `${prefix}-${i}`,
rank: i,
})),
});
test('under the cap every pack is served whole, in merge order', () => {
const { sources, packs } = allocateSourceCap(
[pack('a', 3), pack('b', 2)],
10,
);
assert.deepEqual(
sources.map((s) => s.id),
['a-0', 'a-1', 'a-2', 'b-0', 'b-1'],
);
assert.deepEqual(packs, [
{ name: 'a', offered: 3, kept: 3 },
{ name: 'b', offered: 2, kept: 2 },
]);
});
test('over the cap the packs share it round-robin, and the last pack is never starved', () => {
// The old positional slice kept the first N of [a..., b..., c...], which
// deleted pack c entirely; the fair split thins every pack instead.
const { sources, packs } = allocateSourceCap(
[pack('a', 100), pack('b', 100), pack('c', 100)],
9,
);
assert.equal(sources.length, 9);
assert.deepEqual(
packs.map((p) => p.kept),
[3, 3, 3],
);
// Each pack contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('c-')).map((s) => s.rank),
[0, 1, 2],
);
});
test('a duplicate inside one pack takes one slot, not two', () => {
const dup = {
name: 'file',
sources: [{ id: 'x', v: 'old' }, { id: 'x', v: 'new' }, { id: 'y' }],
};
const { sources } = allocateSourceCap([dup], 2);
assert.deepEqual(
sources.map((s) => [s.id, s.v]),
[
['x', 'new'],
['y', undefined],
],
);
});
test('a short pack releases its turns to the others', () => {
const { packs } = allocateSourceCap([pack('a', 2), pack('b', 50)], 10);
assert.deepEqual(
packs.map((p) => p.kept),
[2, 8],
);
});
test('duplicate ids resolve last-pack-wins and count against the winning pack', () => {
const live = {
name: 'live',
sources: [{ id: 'cam-1', label: 'live' }, { id: 'cam-2' }],
};
const override = {
name: 'env',
sources: [{ id: 'cam-1', label: 'override' }],
};
const { sources, packs } = allocateSourceCap([live, override], 10);
assert.deepEqual(
sources.map((s) => [s.id, s.label]),
[
['cam-2', undefined],
['cam-1', 'override'],
],
);
assert.deepEqual(packs, [
{ name: 'live', offered: 1, kept: 1 },
{ name: 'env', offered: 1, kept: 1 },
]);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('1'), 8);
assert.equal(resolveCatalogCap('999999'), CCTV_MAX_SOURCES_CEILING);
assert.equal(resolveCatalogCap('120.9'), 120);
});
test('the catalog applies the fair cap to configured packs end to end', async () => {
const saved = { ...process.env };
try {
process.env.CCTV_PREFER_AUSTIN = '0';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = JSON.stringify(
Array.from({ length: 12 }, (_, i) => ({
id: `env-${i}`,
name: `Env ${i}`,
lat: 30 + i * 0.01,
lon: -97,
url: `https://example.test/${i}.jpg`,
})),
);
process.env.CCTV_MAX_SOURCES = '9';
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const sources = await getSources();
assert.equal(sources.length, 9);
assert.deepEqual(
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CAMERA_CODE_MAX_CHARS,
cameraDisplayCode,
normalizeSourceItem,
} from '../../server/providers/cctv/normalize.js';
test('cameraDisplayCode trims long names with an ellipsis and collapses whitespace', () => {
assert.equal(
cameraDisplayCode(' IH 35 at Cesar Chavez '),
'IH 35 at Cesar Chavez',
);
const long = 'City of Windsor – Goyeau Street and Park Street';
const code = cameraDisplayCode(long);
assert.equal(code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(code.endsWith('…'));
assert.equal(cameraDisplayCode(''), '');
});
test('every pack labels by its feed name; explicit codes win; the id is the last resort', () => {
const base = { lat: 1, lon: 1, url: 'https://x.test/a.jpg' };
assert.equal(
normalizeSourceItem({
...base,
id: '354',
name: '5TH ST / CONGRESS AVE',
sourceKind: 'austin-open-data',
}).code,
'5TH ST / CONGRESS AVE',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'on-860',
name: 'Goyeau Street and Park Street',
sourceKind: 'ontario-511-open-data',
}).code,
'GOYEAU STREET AND PARK STRE…',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'nsw-46fa631a',
name: 'Erskine Street looking west',
sourceKind: 'nsw-livetraffic',
code: 'ERSKINE ST (SYDNEY)',
}).code,
'ERSKINE ST (SYDNEY)',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'fi-c1455101',
name: '',
sourceKind: 'fintraffic-open-data',
}).code,
'fi-c1455101',
'a missing name falls back to the id',
);
});
+272
View File
@@ -0,0 +1,272 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_AUSTIN_MAX_SOURCES,
DEFAULT_CALTRANS_MAX_SOURCES,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_MAX_SOURCES_CEILING,
DEFAULT_DRIVEBC_MAX_SOURCES,
DEFAULT_TFL_MAX_SOURCES,
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
DRIVEBC_WEBCAMS_URL,
} from '../../server/providers/cctv/constants.js';
import {
driveBcImageCredit,
loadDriveBcSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** A DriveBC `/api/webcams/` row carrying the fields the loader reads. */
function driveBcRow(id, lat, lon, overrides = {}) {
return {
id,
name: `Camera ${id}`,
is_on: true,
should_appear: true,
region_name: 'Lower Mainland',
orientation: 'N',
elevation: 12,
location: { type: 'Point', coordinates: [lon, lat] },
links: { imageDisplay: `/images/${id}.jpg?t=1` },
...overrides,
};
}
test('DriveBC loader keeps published cameras and builds frame URLs from the camera id', async (t) => {
quiet(t);
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
driveBcRow(13, 49.001268, -122.756658, {
name: 'Douglas (Peace Arch) Border Crossing',
region_name: 'Border Cams',
elevation: 10,
// The payload's own link is ignored; frames come from the pinned host.
links: { imageDisplay: 'https://example.com/13.jpg' },
}),
driveBcRow(682, 49.0579, -123.0569, {
orientation: 'se',
elevation: null,
credit:
'Images courtesy of <a href="https://www.translink.ca/" target="_blank">TransLink</a>',
}),
driveBcRow(1, 49.2, -123.1, { is_on: false }),
driveBcRow(2, 49.2, -123.1, { should_appear: false }),
driveBcRow('3', 49.2, -123.1),
driveBcRow(4, null, -123.1),
driveBcRow(5, 49.2, -123.1, { location: null }),
driveBcRow(6, 1000, -123.1),
driveBcRow(7, 34.05, -118.24),
]);
});
const cameras = await loadDriveBcSourcesFromOpenData();
assert.deepEqual(requested, [DRIVEBC_WEBCAMS_URL]);
// Nearest to the Vancouver anchor first: the pack is always distance-sorted
// so the catalog-wide cap thins it from the far end.
assert.deepEqual(
cameras.map((camera) => camera.id),
['drivebc-682', 'drivebc-13'],
);
const border = cameras.find((camera) => camera.id === 'drivebc-13');
const deltaport = cameras.find((camera) => camera.id === 'drivebc-682');
assert.equal(border.url, 'https://www.drivebc.ca/images/13.jpg');
assert.equal(border.snapshotUrl, border.url);
assert.equal(border.name, 'Douglas (Peace Arch) Border Crossing');
assert.equal(border.city, 'BC Border');
assert.equal(border.provider, 'DriveBC');
assert.equal(border.feedType, 'image');
assert.equal(border.headingDeg, 0);
assert.equal(border.headingConfidence, 'high');
assert.equal(border.groundElevationM, 10);
assert.match(border.license, /Open Government Licence – British Columbia/);
assert.equal(
border.credit,
'',
'a provincial camera carries no partner credit',
);
assert.equal(
deltaport.credit,
'Images courtesy of TransLink',
'a partner-supplied camera names its owner, HTML stripped',
);
assert.equal(deltaport.city, 'Lower Mainland');
assert.equal(
deltaport.headingDeg,
135,
'orientation codes are case-insensitive',
);
assert.equal(
deltaport.groundElevationM,
0,
'a missing elevation falls back to sea level',
);
});
test('DriveBC loader keeps the cameras nearest Vancouver and Victoria under its cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: '8' });
const rows = [
// Fort Nelson rows come first in payload order, so only distance can drop them.
...[58.8, 58.81, 58.82, 58.83].map((lat, i) =>
driveBcRow(100 + i, lat, -122.7),
),
...[49.28, 49.27, 49.26, 49.25].map((lat, i) =>
driveBcRow(200 + i, lat, -123.12),
),
...[48.43, 48.44, 48.45, 48.46].map((lat, i) =>
driveBcRow(300 + i, lat, -123.37),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadDriveBcSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras
.map((camera) => camera.id)
.filter((id) => /^drivebc-10\d$/.test(id)),
[],
'the northern cameras are the ones dropped',
);
});
test('DriveBC loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ webcams: [] }),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadDriveBcSourcesFromOpenData(), []);
}
});
test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_DRIVEBC_ENABLED: undefined,
CCTV_DRIVEBC_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only DriveBC answers.
return String(url) === DRIVEBC_WEBCAMS_URL
? Response.json([driveBcRow(13, 49.001268, -122.756658)])
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['drivebc-13'],
);
assert.equal(sources[0].url, 'https://www.drivebc.ca/images/13.jpg');
process.env.CCTV_DRIVEBC_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(DRIVEBC_WEBCAMS_URL),
false,
'a disabled pack makes no request',
);
});
test('default per-pack camera caps fit inside the default catalog cap', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
DEFAULT_CALTRANS_MAX_SOURCES +
DEFAULT_TFL_MAX_SOURCES +
DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES +
1;
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
);
assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
'the default cap must sit inside the CCTV_MAX_SOURCES ceiling',
);
});
test('DriveBC credit keeps partner attribution and drops operational notes', () => {
assert.equal(
driveBcImageCredit(
'Images courtesy of <a href="https://x.test/">TransLink</a>',
),
'Images courtesy of TransLink',
);
assert.equal(
driveBcImageCredit('Camera image provided by City of Vancouver'),
'Camera image provided by City of Vancouver',
);
assert.equal(
driveBcImageCredit(
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
),
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
);
assert.equal(
driveBcImageCredit(
'This camera is located in a remote area and relies on solar power. As a result, transmission delays may occur.',
),
'',
);
assert.equal(driveBcImageCredit(null), '');
});
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTallinnSourcesFromCatalog,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0">
<predefinedLocationName>
<values><value lang="et">Test Site</value></values>
</predefinedLocationName>
<location>
<pointByCoordinates>
<pointCoordinates>
<latitude>59.4370</latitude>
<longitude>24.7530</longitude>
</pointCoordinates>
</pointByCoordinates>
</location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const SAMPLE_IMAGES_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1">
<linearTrafficView id="1">
<linearPredefinedLocationReference targetClass="PredefinedLocation" id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0"/>
<trafficViewRecord id="1-1">
<urlLink>
<urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg</urlLinkAddress>
<urlLinkType>image</urlLinkType>
</urlLink>
</trafficViewRecord>
</linearTrafficView>
</trafficView>
</payloadPublication>
</d2LogicalModel>`;
test('Tallinn catalog loads curated ristmikud stills only', () => {
const cameras = loadTallinnSourcesFromCatalog();
assert.ok(
cameras.length >= 200,
`expected a full Tallinn pack, got ${cameras.length}`,
);
for (const camera of cameras.slice(0, 25)) {
assert.match(camera.id, /^tln-/);
assert.equal(camera.cityId, 'tallinn');
assert.equal(camera.feedType, 'image');
assert.equal(camera.sourceKind, 'tallinn-ristmikud');
assert.ok(camera.url.startsWith('https://ristmikud.tallinn.ee/'));
assert.equal(camera.url, camera.snapshotUrl);
assert.ok(Number.isFinite(camera.lat) && Number.isFinite(camera.lon));
}
});
test('Tarktee DATEX parsers join location UUIDs to image URLs', () => {
const locations = parseTarkteeDatexLocations(SAMPLE_LOCATIONS_XML);
const images = parseTarkteeDatexImages(SAMPLE_IMAGES_XML);
assert.equal(locations.size, 1);
assert.equal(images.size, 1);
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
assert.equal(locations.get(id)?.name, 'Test Site');
assert.equal(locations.get(id)?.lat, 59.437);
assert.equal(locations.get(id)?.lon, 24.753);
assert.equal(
images.get(id),
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
);
});
test('Tarktee DATEX image parser rejects non-official hosts', () => {
const xml = SAMPLE_IMAGES_XML.replace(
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
'https://evil.example/images/42/42.jpg',
);
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
+256
View File
@@ -0,0 +1,256 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadFintrafficSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
import {
fintrafficCameraName,
isLikelyFinlandCoordinate,
} from '../../server/providers/cctv/normalize.js';
import {
FINTRAFFIC_GROUND_ELEVATION_M,
FINTRAFFIC_IMAGE_ORIGIN,
FINTRAFFIC_STATIONS_URL,
} from '../../server/providers/cctv/constants.js';
/** One station feature in the shape tie.digitraffic.fi actually returns. */
function station(
id,
{
name = `vt4_${id}`,
status = 'GATHERING',
lon = 24.9384,
lat = 60.1699,
altitude = 0,
presets = [],
} = {},
) {
return {
type: 'Feature',
id,
geometry: { type: 'Point', coordinates: [lon, lat, altitude] },
properties: {
id,
name,
collectionStatus: status,
dataUpdatedTime: '2026-09-13T03:25:36Z',
presets,
state: null,
},
};
}
/** Run the loader against a canned station list, restoring global fetch after. */
async function loadWith(features, { env = {}, capture = {} } = {}) {
const originalFetch = globalThis.fetch;
const originalEnv = {};
for (const [key, value] of Object.entries(env)) {
originalEnv[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
globalThis.fetch = async (url, options) => {
capture.url = String(url);
capture.options = options;
return new Response(
JSON.stringify({
type: 'FeatureCollection',
dataUpdatedTime: '2026-09-13T05:31:58Z',
features,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
};
try {
return await loadFintrafficSourcesFromOpenData();
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
test('Fintraffic loader turns every in-collection preset into one camera', async () => {
const cameras = await loadWith([
station('C01503', {
name: 'kt51_Inkoo',
lon: 23.99616,
lat: 60.05374,
presets: [
{ id: 'C0150301', inCollection: true },
{ id: 'C0150302', inCollection: true },
],
}),
]);
assert.equal(cameras.length, 2);
const [first, second] = cameras;
assert.equal(first.id, 'fi-c0150301');
assert.equal(first.name, 'kt51 Inkoo (view 01)');
assert.equal(second.name, 'kt51 Inkoo (view 02)');
assert.equal(first.provider, 'Fintraffic');
assert.equal(first.city, 'Finland');
assert.equal(first.cityId, 'finland');
assert.equal(first.sourceKind, 'fintraffic-open-data');
assert.equal(first.license, 'Fintraffic / digitraffic.fi (CC BY 4.0)');
assert.equal(first.feedType, 'image');
// Presets of one station share the station position...
assert.equal(first.lat, 60.05374);
assert.equal(second.lat, first.lat);
assert.equal(second.lon, first.lon);
// ...so the id-hash fallback heading is what keeps their gizmos apart.
assert.notEqual(first.headingDeg, second.headingDeg);
assert.equal(first.headingConfidence, 'low');
});
test('Fintraffic frame URLs are built on the official image origin', async () => {
const cameras = await loadWith([
station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] }),
]);
assert.equal(cameras[0].url, `${FINTRAFFIC_IMAGE_ORIGIN}C0150301.jpg`);
assert.equal(cameras[0].snapshotUrl, cameras[0].url);
assert.ok(cameras[0].url.startsWith('https://weathercam.digitraffic.fi/'));
});
test('Fintraffic loader drops dead stations, uncollected presets and bad ids', async () => {
const cameras = await loadWith([
// Station off collection: every preset goes, live or not.
station('C01601', {
status: 'REMOVED_TEMPORARILY',
presets: [{ id: 'C0160101', inCollection: true }],
}),
station('C01503', {
presets: [
{ id: 'C0150301', inCollection: true },
{ id: 'C0150302', inCollection: false },
{ id: 'C0150303' },
// Ids that would escape the synthesized frame path, or belong to
// another station, never become a camera.
{ id: '../../etc/passwd', inCollection: true },
{ id: 'C9999901', inCollection: true },
],
}),
// Coordinates nowhere near Finland (swapped lat/lon).
station('C01504', {
lon: 60.1699,
lat: 24.9384,
presets: [{ id: 'C0150401', inCollection: true }],
}),
]);
assert.deepEqual(
cameras.map((camera) => camera.id),
['fi-c0150301'],
);
});
test('Fintraffic ground elevation uses a reported altitude and falls back otherwise', async () => {
const cameras = await loadWith([
station('C08508', {
altitude: 136,
presets: [{ id: 'C0850801', inCollection: true }],
}),
station('C01503', {
altitude: 0,
presets: [{ id: 'C0150301', inCollection: true }],
}),
station('C03506', {
altitude: 99_000,
presets: [{ id: 'C0350601', inCollection: true }],
}),
]);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
assert.equal(byId.get('fi-c0850801').groundElevationM, 136);
// 0 is "not reported", not sea level.
assert.equal(
byId.get('fi-c0150301').groundElevationM,
FINTRAFFIC_GROUND_ELEVATION_M,
);
// A garbage upstream value cannot fling a camera kilometres up.
assert.equal(byId.get('fi-c0350601').groundElevationM, 1400);
});
test('Fintraffic loader identifies itself to Digitraffic and refuses redirects', async () => {
const capture = {};
await loadWith(
[station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] })],
{ capture },
);
assert.equal(capture.url, FINTRAFFIC_STATIONS_URL);
assert.equal(capture.options.headers['Digitraffic-User'], 'gods-eye-view');
assert.equal(capture.options.headers['Accept-Encoding'], 'gzip');
assert.equal(capture.options.redirect, 'manual');
assert.ok(capture.options.signal instanceof AbortSignal);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://example.com/stations' },
});
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('Fintraffic pack caps itself and fails to an empty list, never a throw', async () => {
const presets = Array.from({ length: 40 }, (_, index) => ({
id: `C01503${String(index).padStart(2, '0')}`,
inCollection: true,
}));
const capped = await loadWith([station('C01503', { presets })], {
env: { CCTV_FINTRAFFIC_MAX_SOURCES: '8' },
});
assert.equal(capped.length, 8);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error('network down');
};
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = async () => new Response('nope', { status: 503 });
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fintrafficCameraName unpacks the machine station name and the view number', () => {
assert.equal(
fintrafficCameraName('vt3_Hyvinkää_Noppo', 'C01234', 'C0123402'),
'vt3 Hyvinkää Noppo (view 02)',
);
// Nameless station still gets a usable label.
assert.equal(
fintrafficCameraName('', 'C01234', 'C0123409'),
'Fintraffic C01234 (view 09)',
);
// A preset id that is just the station id has no view suffix to add.
assert.equal(
fintrafficCameraName('vt4_Mäntsälä', 'C01234', 'C01234'),
'vt4 Mäntsälä',
);
});
test('isLikelyFinlandCoordinate spans the catalog extent and rejects the rest', () => {
assert.equal(isLikelyFinlandCoordinate(60.1699, 24.9384), true); // Helsinki
assert.equal(isLikelyFinlandCoordinate(70.088512, 27.0), true); // Utsjoki, the northern extreme
assert.equal(isLikelyFinlandCoordinate(59.856454, 19.618722), true); // the southwestern extreme
assert.equal(isLikelyFinlandCoordinate(51.5074, -0.1278), false); // London
assert.equal(isLikelyFinlandCoordinate(0, 0), false); // null island
assert.equal(isLikelyFinlandCoordinate(NaN, 24.9384), false);
});
+198
View File
@@ -0,0 +1,198 @@
/**
* Pure monitor-plane footprint math shared by the client geometry
* (src/layers/cctv/geometry.js), the server sidecar join
* (server/providers/cctv/groundHeights.js) and the offline precompute
* (scripts/precompute-cctv-heights.mjs). Lives outside every ownership
* package on purpose. No Cesium, no DOM: the same function
* decides where a plane's support points are on the ground for both.
*
* The plane is the pitched far cap of the camera frustum: center at range R
* along the heading (horizontal R·cos(pitch)), half-width R·tan(hFov/2),
* half-height R·tan(vFov/2) with vFov from a 16:9 aspect, tilted by the
* pitch. Support points are a 3×3 grid over that rectangle: rows bottom /
* middle / top, columns left / center / right, named `bl bm br ml mc mr tl tm
* tr`. Their horizontal positions depend on the pose only, never on ground
* height, so an offline job can sample the ground under them for the nominal
* pose and the client can reuse those samples as long as the pose is the same.
*/
const EARTH_RADIUS_M = 6371000;
export const PLANE_VERT_ASPECT = 16 / 9;
export const SUPPORT_KEYS = Object.freeze([
'bl',
'bm',
'br',
'ml',
'mc',
'mr',
'tl',
'tm',
'tr',
]);
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Offset a lat/lon by a distance along a compass heading (spherical earth;
* sub-centimetre at the ≤ 5 km ranges cameras use).
* @param {number} lat @param {number} lon @param {number} headingDeg @param {number} distM
* @returns {{lat:number, lon:number}}
*/
export function projectPoint(lat, lon, headingDeg, distM) {
const b = toRad(headingDeg);
const la = toRad(lat);
const lo = toRad(lon);
const ad = distM / EARTH_RADIUS_M;
const la2 = Math.asin(
Math.sin(la) * Math.cos(ad) + Math.cos(la) * Math.sin(ad) * Math.cos(b),
);
const lo2 =
lo +
Math.atan2(
Math.sin(b) * Math.sin(ad) * Math.cos(la),
Math.cos(ad) - Math.sin(la) * Math.sin(la2),
);
return { lat: toDeg(la2), lon: toDeg(lo2) };
}
/**
* Plane dimensions and offsets for a pose, before any ground is known.
* @param {{pitchDeg:number, fovDeg:number, rangeM:number}} pose
* @returns {{R:number, pitch:number, halfW:number, halfH:number, horiz:number,
* vert:number, upVert:number, upHoriz:number, vFovDeg:number}}
*/
export function planeDimensions(pose) {
const R = Math.max(1, Number(pose.rangeM) || 1);
const pitch = toRad(Math.max(-89, Math.min(89, Number(pose.pitchDeg) || 0)));
const hFov = toRad(Math.max(8, Math.min(160, Number(pose.fovDeg) || 74)));
const halfW = R * Math.tan(hFov / 2);
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PLANE_VERT_ASPECT);
const halfH = R * Math.tan(vFovRad / 2);
return {
R,
pitch,
halfW,
halfH,
horiz: R * Math.cos(pitch),
vert: R * Math.sin(pitch),
// In-plane "up", decomposed into a vertical part and a horizontal part
// along the heading (a downward pitch tilts the plane's top forward).
upVert: Math.cos(pitch) * halfH,
upHoriz: -Math.sin(pitch) * halfH,
vFovDeg: toDeg(vFovRad),
};
}
/**
* Horizontal (lat/lon) positions of the mount and the nine plane support
* points for a pose. Altitudes are not part of this: the caller adds the
* ground it measured under each point.
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
* fovDeg:number, rangeM:number}} pose
* @returns {{mount:{lat:number,lon:number}, capCenter:{lat:number,lon:number},
* supports: Record<string,{lat:number,lon:number, row:-1|0|1, col:-1|0|1}>}}
*/
export function planeSupportPoints(pose) {
const dims = planeDimensions(pose);
const heading = Number(pose.headingDeg) || 0;
const cap = projectPoint(pose.lat, pose.lon, heading, dims.horiz);
const supports = {};
const rows = { b: -1, m: 0, t: 1 };
// Column letter: l / r, with the centre spelled `m` in the bottom and top
// rows (bm, tm) and `c` in the middle row (mc).
const cols = { l: -1, m: 0, c: 0, r: 1 };
for (const key of SUPPORT_KEYS) {
const row = rows[key[0]];
const col = cols[key[1]];
const across = projectPoint(
cap.lat,
cap.lon,
heading + 90,
col * dims.halfW,
);
const along = projectPoint(
across.lat,
across.lon,
heading,
row * dims.upHoriz,
);
supports[key] = { lat: along.lat, lon: along.lon, row, col };
}
return { mount: { lat: pose.lat, lon: pose.lon }, capCenter: cap, supports };
}
/**
* Altitude of the plane at a support point, given the plane center altitude:
* the rigid rectangle's rows sit ± upVert around the center.
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
* @returns {number}
*/
export function supportAltitude(capAltM, row, dims) {
return capAltM + row * dims.upVert;
}
/**
* The single rigid lift (metres, ≥ 0) that puts every support point at least
* `clearanceM` above the ground measured under it. Support points without a
* measured ground fall back to `fallbackGroundM` (normally the ground at the
* mount), which is what makes the pre-precompute path deterministic instead
* of buried.
* @param {number} capAltM - Unlifted plane center altitude.
* @param {{upVert:number}} dims
* @param {Record<string, number>|null|undefined} groundUnder - support key → ground altitude.
* @param {number} fallbackGroundM
* @param {number} clearanceM
* @returns {{liftM:number, limitingKey:string|null}}
*/
export function requiredPlaneLift(
capAltM,
dims,
groundUnder,
fallbackGroundM,
clearanceM,
) {
let liftM = 0;
let limitingKey = null;
const rows = { b: -1, m: 0, t: 1 };
for (const key of SUPPORT_KEYS) {
const raw = groundUnder ? groundUnder[key] : undefined;
// Strict: a null/absent support must fall back, not read as 0 m.
const ground =
typeof raw === 'number' && Number.isFinite(raw) ? raw : fallbackGroundM;
if (!Number.isFinite(ground)) continue;
const alt = supportAltitude(capAltM, rows[key[0]], dims);
const deficit = ground + clearanceM - alt;
if (deficit > liftM) {
liftM = deficit;
limitingKey = key;
}
}
return { liftM, limitingKey };
}
/**
* Stable hash of the nominal pose a footprint was sampled for, so shipped
* samples are only reused while the pose they describe is unchanged.
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
* fovDeg:number, rangeM:number, mountHeightM:number}} pose
* @returns {string}
*/
export function poseHash(pose) {
const text = [
Number(pose.lat).toFixed(6),
Number(pose.lon).toFixed(6),
Number(pose.headingDeg).toFixed(1),
Number(pose.pitchDeg).toFixed(1),
Number(pose.fovDeg).toFixed(1),
Number(pose.rangeM).toFixed(1),
Number(pose.mountHeightM).toFixed(1),
].join('|');
// FNV-1a 32-bit, base36: short, stable, dependency-free.
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return `p1-${h.toString(36)}`;
}
Loaded 100 of 724 files, more files were not shown because too many files have changed in this diff. Show more