mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge main into prereq-10-osm-overlays and port Open Map Overlays
Main moved on since this PR opened; the overlay layer is ported to the current contracts: - Layer-state token 6 (j belongs to transit; new single-character tokens are digits) with a reservation-ledger row, and the option owner renamed to the layer id so the compact share codec can encode it. - Constructed per catalog by createOsmOverlaysLayer() in constructCatalog.js; src/standalone/data.js no longer registers layers. - Drapes through maps/imageryHost.js, so the overlays show on Google 3D (tileset imagery) as well as the globe stacks, and follow map-stack switches. Imagery layers are created on enable and destroyed on disable/destroy. - Translated chip labels and layer name in every locale, LAYER_NAME_KEYS, Infrastructure panel group, voice manifest entry. - Attribution: OpenSeaMap tiles CC BY-SA 2.0, OpenSnowMap credit as required, OSM ODbL credit for both; bulk-download note in DATA_SOURCES. - CURRENT-STATE row, changelog fragment, share-codec absent defaults, updated token-ledger fixtures and voice schema pins.
This commit is contained in:
commit
ce1ad3b95a
2330 files changed
+632956
-84310
No files matched your search
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* Bundled administrative boundaries — countries and states/provinces (Natural
|
||||
* Earth admin-0/admin-1) and US counties (US Census Bureau cartographic boundaries),
|
||||
* so "outline Texas", "outline Bavaria" or "outline Travis County" draws the
|
||||
* real boundary without a network lookup.
|
||||
*
|
||||
* Data: `local_data/natural_earth/{countries,states_provinces}.json` and
|
||||
* `local_data/us_census_counties/counties.json`, public domain and built
|
||||
* by `scripts/build-admin-packs.mjs` (provenance in each folder's README).
|
||||
* Rings are delta-encoded integers; see `decodeRing`.
|
||||
*
|
||||
* PURE data module — no Cesium, node-testable. Each pack is fetched through
|
||||
* `loadBundledJson` on the first lookup that needs it (never at app start),
|
||||
* indexed once, and kept; a failed load is retried later rather than cached
|
||||
* (`createRetryableLoader`). The county pack is only loaded for asks that
|
||||
* name a county (or a county-equivalent city) or that a geocoder typed as one.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `findAdminArea(query, { near })` — name-only. Resolves asks that are
|
||||
* unambiguous from the words alone ("Texas", "TX", "State of Texas",
|
||||
* "Bavaria", "Travis County, Texas", "Orleans Parish"); duplicates are
|
||||
* settled by a state/country qualifier, then by the camera (`near`).
|
||||
* Country/state homonyms such as Georgia use the qualifier or camera;
|
||||
* city/state homonyms such as New York still defer to the geocoder.
|
||||
* - `findAdminAreaAt(names, lat, lon, scope)` — geocoder-confirmed. The
|
||||
* geocoder already typed the place as a country, state or county; the unit must
|
||||
* carry one of the names AND contain the geocoded point.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const FOLD = {
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
æ: 'ae',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
};
|
||||
|
||||
/** Fold a name for matching: case, accents, punctuation, "the", "saint". */
|
||||
export function normalizeAdminName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '')
|
||||
.replace(/\bsaint\b/g, 'st')
|
||||
.replace(/\bsainte\b/g, 'ste');
|
||||
}
|
||||
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const packLoads = { admin1: 0, counties: 0, countries: 0 };
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²) — same formula as naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one packed ring: integers in units of 10^-decimals degrees, the
|
||||
* first vertex absolute and each later one a delta from the previous.
|
||||
* @param {number[]} encoded
|
||||
* @param {number} decimals
|
||||
* @returns {Array<[number, number]>} open ring of [lon, lat]
|
||||
*/
|
||||
export function decodeRing(encoded, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const ring = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i + 1 < encoded.length; i += 2) {
|
||||
x += encoded[i];
|
||||
y += encoded[i + 1];
|
||||
ring.push([x / factor, y / factor]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons ([outer, ...holes][]) contain the point: inside an outer
|
||||
* ring and not inside any of that part's holes.
|
||||
*/
|
||||
export function polygonsContain(polygons, lat, lon) {
|
||||
for (const [outer, ...holes] of polygons || []) {
|
||||
if (!pointInRing(outer, lat, lon)) continue;
|
||||
if (!holes.some((hole) => pointInRing(hole, lat, lon))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
let areaKm2 = 0;
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
areaKm2 += ringAreaKm2(outer);
|
||||
for (const hole of holes) areaKm2 -= ringAreaKm2(hole);
|
||||
for (const [lon, lat] of outer) {
|
||||
const x = lon - 360 * Math.round((lon - ref) / 360);
|
||||
if (x < west) west = x;
|
||||
if (x > east) east = x;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
polygons,
|
||||
bbox: [west, south, east, north],
|
||||
areaKm2: Math.max(0, areaKm2),
|
||||
label,
|
||||
};
|
||||
return entry.geometry;
|
||||
}
|
||||
|
||||
/** Area-weighted centroid of an open ring (planar; fine at unit scale). */
|
||||
function ringCentroid(ring) {
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [x0, y0] = ring[j];
|
||||
const [x1, y1] = ring[i];
|
||||
const f = x0 * y1 - x1 * y0;
|
||||
a += f;
|
||||
cx += (x0 + x1) * f;
|
||||
cy += (y0 + y1) * f;
|
||||
}
|
||||
if (!a) return { lon: ring[0][0], lat: ring[0][1] };
|
||||
return { lon: cx / (3 * a), lat: cy / (3 * a) };
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
const clampedLon = Math.min(east, Math.max(west, x));
|
||||
const clampedLat = Math.min(north, Math.max(south, lat));
|
||||
return haversineKm(lat, x, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth `min_label` (the zoom at which a unit is labelled) up to which
|
||||
* a unit may be named alone: every US state, Canadian province, Mexican and
|
||||
* German state; not English counties, French départements or small districts.
|
||||
*/
|
||||
const BARE_NAME_MAX_RANK = 7;
|
||||
|
||||
/**
|
||||
* Words that name a unit somewhere (Cameroon's "North", Paraguay's
|
||||
* "Central") but, said alone, name a direction or a feature.
|
||||
*/
|
||||
const GENERIC_NAMES = new Set([
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'northern',
|
||||
'southern',
|
||||
'eastern',
|
||||
'western',
|
||||
'north east',
|
||||
'north west',
|
||||
'south east',
|
||||
'south west',
|
||||
'northeast',
|
||||
'northwest',
|
||||
'southeast',
|
||||
'southwest',
|
||||
'far north',
|
||||
'far west',
|
||||
'central',
|
||||
'centre',
|
||||
'center',
|
||||
'capital',
|
||||
'coast',
|
||||
'island',
|
||||
'islands',
|
||||
'lakes',
|
||||
'highland',
|
||||
'highlands',
|
||||
'interior',
|
||||
'upper',
|
||||
'lower',
|
||||
'valley',
|
||||
'plateau',
|
||||
'plateaux',
|
||||
'littoral',
|
||||
'oriental',
|
||||
'occidental',
|
||||
]);
|
||||
|
||||
/** State codes that, alone, more often mean a city ("LA", "NY"). */
|
||||
const CITY_ABBREVIATIONS = new Set(['la', 'ny']);
|
||||
|
||||
/** Postal abbreviations accepted alone ("TX", "ON"): US states and Canada. */
|
||||
const POSTAL_COUNTRIES = new Set(['US', 'CA']);
|
||||
|
||||
function addKey(index, key, entry, tier) {
|
||||
if (!key) return;
|
||||
entry.keys?.add(key);
|
||||
const list = index.get(key);
|
||||
const item = { entry, tier };
|
||||
if (!list) index.set(key, [item]);
|
||||
else if (!list.some((i) => i.entry === entry)) list.push(item);
|
||||
}
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'state',
|
||||
feature,
|
||||
decimals,
|
||||
amb: new Set(feature.amb || []),
|
||||
ambAbroad: new Set(feature.ambAbroad || []),
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: String(feature.iso2 || '').toLowerCase(),
|
||||
};
|
||||
addKey(index, normalizeAdminName(feature.name), entry, 0);
|
||||
addKey(index, normalizeAdminName(feature.nameEn), entry, 1);
|
||||
for (const alt of feature.alt || [])
|
||||
addKey(index, normalizeAdminName(alt), entry, 2);
|
||||
if (feature.postal && POSTAL_COUNTRIES.has(feature.iso2))
|
||||
addKey(index, `postal:${feature.postal.toLowerCase()}`, entry, 2);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(PACKS.counties.url);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
const states = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'county',
|
||||
feature,
|
||||
decimals,
|
||||
state: normalizeAdminName(feature.state),
|
||||
st: String(feature.st || '').toLowerCase(),
|
||||
};
|
||||
addKey(byFull, normalizeAdminName(feature.full), entry, 0);
|
||||
addKey(byName, normalizeAdminName(feature.name), entry, 1);
|
||||
states.set(entry.state, entry.st);
|
||||
}
|
||||
return { byFull, byName, states };
|
||||
});
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(PACKS.countries.url);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'country',
|
||||
feature,
|
||||
decimals: pack.meta.decimals,
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: feature.iso2?.toLowerCase(),
|
||||
};
|
||||
for (const name of [feature.name, ...(feature.alt || [])])
|
||||
addKey(index, normalizeAdminName(name), entry, 0);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
async function countryCandidates(parsed) {
|
||||
if (!['any', 'country'].includes(parsed.kind)) return [];
|
||||
const { index } = await loadCountries();
|
||||
const key = COUNTRY_TARGET_ALIASES.get(parsed.name) || parsed.name;
|
||||
return (index.get(key) || []).filter(({ entry }) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(entry, q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Country target aliases never broaden constituent-country requests. */
|
||||
const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['usa', 'united states of america'],
|
||||
['us', 'united states of america'],
|
||||
['u s', 'united states of america'],
|
||||
['u s a', 'united states of america'],
|
||||
['united states', 'united states of america'],
|
||||
['america', 'united states of america'],
|
||||
['uk', 'united kingdom'],
|
||||
['u k', 'united kingdom'],
|
||||
['great britain', 'united kingdom'],
|
||||
['britain', 'united kingdom'],
|
||||
['russian federation', 'russia'],
|
||||
['korea', 'south korea'],
|
||||
['czechia', 'czech republic'],
|
||||
['tanzania', 'united republic of tanzania'],
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
['scotland', 'united kingdom'],
|
||||
['wales', 'united kingdom'],
|
||||
['northern ireland', 'united kingdom'],
|
||||
]);
|
||||
|
||||
/** County-equivalent words, longest first so "city and borough" wins. */
|
||||
const COUNTY_WORDS = [
|
||||
'city and borough',
|
||||
'planning region',
|
||||
'census area',
|
||||
'municipality',
|
||||
'municipio',
|
||||
'borough',
|
||||
'parish',
|
||||
'county',
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
* @param {string} text
|
||||
* @returns {{kind:'country'|'county'|'state'|'any', name:string, qualifiers:string[],
|
||||
* postal:string|null, countyWord:string|null}|null}
|
||||
*/
|
||||
export function parseAdminQuery(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return null;
|
||||
const segments = raw.split(',').map(normalizeAdminName).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
const head = segments[0];
|
||||
const country =
|
||||
/^(?:country|nation) of (.+)$/.exec(head) ||
|
||||
/^(.+) (?:country|nation)$/.exec(head);
|
||||
if (country)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: country[1],
|
||||
qualifiers: segments.slice(1),
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
};
|
||||
const qualifiers = segments.slice(1);
|
||||
const postal = /^[A-Za-z]{2}$/.test(raw) ? raw.toLowerCase() : null;
|
||||
// "TX", "DC", "D.C.": written as an abbreviation, not a short word.
|
||||
const abbreviation = /^[A-Z]{2,3}$/.test(raw.replace(/[.\s]/g, ''));
|
||||
|
||||
// "County Cork", "County of Los Angeles"
|
||||
const countyPrefix = /^county (?:of )?(.+)$/.exec(head);
|
||||
if (countyPrefix)
|
||||
return {
|
||||
kind: 'county',
|
||||
name: countyPrefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: 'county',
|
||||
full: head,
|
||||
};
|
||||
// "Travis County", "Travis County Texas", "Orleans Parish, LA"
|
||||
const county = COUNTY_WORD_RE.exec(head);
|
||||
if (county && county.index > 0) {
|
||||
const name = head.slice(0, county.index).trim();
|
||||
const rest = head.slice(county.index + county[1].length).trim();
|
||||
if (rest) qualifiers.unshift(rest);
|
||||
return {
|
||||
kind: 'county',
|
||||
name,
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: county[1],
|
||||
full: head.slice(0, county.index + county[1].length),
|
||||
};
|
||||
}
|
||||
const prefix = STATE_PREFIX_RE.exec(head);
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: suffix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
name: head,
|
||||
qualifiers,
|
||||
postal,
|
||||
abbreviation,
|
||||
countyWord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
const q = COUNTRY_ALIASES.get(qualifier) || qualifier;
|
||||
return (
|
||||
entry.country === q ||
|
||||
entry.iso2 === q ||
|
||||
entry.keys.has(qualifier) ||
|
||||
entry.keys.has(`postal:${qualifier}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
const contains = hasNear
|
||||
? polygonsContain(geometry.polygons, near.lat, near.lon)
|
||||
: false;
|
||||
const distanceKm = hasNear
|
||||
? contains
|
||||
? 0
|
||||
: boxDistanceKm(geometry.bbox, near.lat, near.lon)
|
||||
: 0;
|
||||
return { entry, tier, contains, distanceKm, areaKm2: geometry.areaKm2 };
|
||||
});
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
Number(b.contains) - Number(a.contains) ||
|
||||
a.tier - b.tier ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
b.areaKm2 - a.areaKm2,
|
||||
);
|
||||
return scored;
|
||||
}
|
||||
|
||||
function toResult(scored, candidateCount) {
|
||||
const { entry } = scored;
|
||||
const { feature } = entry;
|
||||
const geometry = geometryOf(entry);
|
||||
// Parts are stored largest first, so polygons[0][0] is the main outline.
|
||||
const polygons = geometry.polygons;
|
||||
if (entry.kind === 'county') {
|
||||
return {
|
||||
kind: 'county',
|
||||
name: feature.full,
|
||||
region: feature.state,
|
||||
regionCode: feature.st,
|
||||
country: 'United States of America',
|
||||
id: feature.geoid,
|
||||
source: 'us-census',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: entry.kind,
|
||||
name: feature.nameEn || feature.name,
|
||||
localName: feature.name,
|
||||
type: feature.type,
|
||||
region: null,
|
||||
regionCode: feature.postal || null,
|
||||
country: feature.country,
|
||||
id: feature.iso || null,
|
||||
source: 'natural-earth',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function countyCandidates(parsed) {
|
||||
const { byFull, byName, states } = await loadCounties();
|
||||
// An exact full name ("Orleans Parish") wins outright; without one, the
|
||||
// name with ANY county-equivalent word ("Anchorage Borough" finds
|
||||
// Anchorage Municipality). An independent city is only ever its full name
|
||||
// ("Fairfax city", "Carson City").
|
||||
let list = parsed.countyWord
|
||||
? [
|
||||
...(byFull.get(parsed.full || `${parsed.name} ${parsed.countyWord}`) ||
|
||||
byName.get(parsed.name) ||
|
||||
[]),
|
||||
]
|
||||
: [...(byFull.get(parsed.name) || [])];
|
||||
if (!list.length) return [];
|
||||
// Every qualifier must be a state or the US: "Travis County, TX, USA"
|
||||
// qualifies, "Travis County Courthouse, Texas" names something else.
|
||||
const stateByCode = new Map([...states].map(([name, st]) => [st, name]));
|
||||
const stateQualifiers = [];
|
||||
for (const q of parsed.qualifiers) {
|
||||
const state = states.has(q) ? q : stateByCode.get(q);
|
||||
if (state) stateQualifiers.push(state);
|
||||
else if (!isUnitedStates(q)) return [];
|
||||
}
|
||||
if (stateQualifiers.length)
|
||||
list = list.filter((i) => stateQualifiers.includes(i.entry.state));
|
||||
return list;
|
||||
}
|
||||
|
||||
function isUnitedStates(qualifier) {
|
||||
return (
|
||||
(COUNTRY_ALIASES.get(qualifier) || qualifier) === 'united states of america'
|
||||
);
|
||||
}
|
||||
|
||||
async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
const { index } = await loadAdmin1();
|
||||
const keys = [];
|
||||
if (parsed.full) keys.push(parsed.full);
|
||||
keys.push(parsed.name);
|
||||
if (parsed.postal) keys.push(`postal:${parsed.postal}`);
|
||||
const seen = new Set();
|
||||
let list = [];
|
||||
for (const key of keys) {
|
||||
for (const item of index.get(key) || []) {
|
||||
if (seen.has(item.entry)) continue;
|
||||
seen.add(item.entry);
|
||||
list.push({ ...item, key, tier: key === parsed.full ? 0 : item.tier });
|
||||
}
|
||||
}
|
||||
// Every qualifier must place the unit: "Bavaria, Germany", "Washington,
|
||||
// DC" — not "Santa Barbara, California" (a city the pack does not hold).
|
||||
if (parsed.qualifiers.length)
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
// county or a Bahamas district ("Long Island", "Santa Barbara", "Kent" are
|
||||
// likelier the places a geocoder knows). It must not also be a country or
|
||||
// a large city ("Georgia", "New York"), and a short code must be written
|
||||
// as one ("TX", not "tx"). Otherwise a qualifier, admin wording or the
|
||||
// geocoder decides.
|
||||
// A country qualifier places a name without making it a state: "New York,
|
||||
// USA" and "Madrid, Spain" stay the geocoder's; "Washington, DC" names the
|
||||
// unit itself.
|
||||
if (parsed.kind === 'any') {
|
||||
const short = parsed.name.replace(/ /g, '').length <= 3;
|
||||
if (short && (!parsed.abbreviation || CITY_ABBREVIATIONS.has(parsed.name)))
|
||||
return [];
|
||||
const namesUnit = (entry) =>
|
||||
parsed.qualifiers.some(
|
||||
(q) => entry.keys.has(q) || entry.keys.has(`postal:${q}`),
|
||||
);
|
||||
if (!parsed.qualifiers.length) {
|
||||
if (GENERIC_NAMES.has(parsed.name)) return [];
|
||||
return list.filter(
|
||||
(i) =>
|
||||
!i.entry.amb.has(i.key) &&
|
||||
!i.entry.ambAbroad.has(i.key) &&
|
||||
((i.entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
i.entry.feature.cityState),
|
||||
);
|
||||
}
|
||||
// Qualified: a shared name abroad is settled by the country ("Victoria,
|
||||
// Australia"); one shared at home only by the unit's own name.
|
||||
return list.filter((i) => !i.entry.amb.has(i.key) || namesUnit(i.entry));
|
||||
}
|
||||
// "X State" only settles a name that is ambiguous on its own ("New York
|
||||
// State"); otherwise it is as likely a university ("Ohio State") and a
|
||||
// geocoder decides. A unit literally named so ("Free State") matches.
|
||||
if (parsed.form === 'suffix')
|
||||
return list.filter((i) => i.key === parsed.full || i.entry.amb.has(i.key));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an administrative unit from the ask's words alone.
|
||||
*
|
||||
* @param {string} query e.g. "Texas", "TX", "State of Texas", "Bavaria",
|
||||
* "Bavaria, Germany", "Travis County", "Travis County, TX", "Orleans Parish"
|
||||
* @param {{near?: {lat:number, lon:number}|null}} [options] the camera's
|
||||
* view centre — the tie-break between same-named units
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*
|
||||
* @typedef {{kind:'country'|'state'|'county', name:string, region:string|null,
|
||||
* regionCode:string|null, country:string, id:string|null,
|
||||
* source:'natural-earth'|'us-census',
|
||||
* polygons:Array<Array<Array<[number,number]>>>, ring:Array<[number,number]>,
|
||||
* bbox:number[], areaKm2:number, label:{lat:number, lon:number},
|
||||
* candidates:number}} AdminArea
|
||||
*/
|
||||
export async function findAdminArea(query, { near = null } = {}) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed || !parsed.name) return null;
|
||||
let candidates = [];
|
||||
if (parsed.kind === 'country') {
|
||||
candidates = await countryCandidates(parsed);
|
||||
} else if (parsed.kind === 'county') {
|
||||
// County wording also names admin-1 units abroad. Neither pack gets
|
||||
// first refusal: qualifiers exclude ineligible countries before ranking.
|
||||
const [counties, admin1] = await Promise.all([
|
||||
countyCandidates(parsed),
|
||||
stateCandidates({ ...parsed, kind: 'state' }),
|
||||
]);
|
||||
candidates = [...counties, ...admin1];
|
||||
} else {
|
||||
candidates = await stateCandidates(parsed);
|
||||
if (!candidates.length || COUNTRY_TARGET_ALIASES.has(parsed.name)) {
|
||||
const countries = await countryCandidates(parsed);
|
||||
if (countries.length) {
|
||||
const states = await stateCandidates(parsed, { allowAmbiguous: true });
|
||||
if (
|
||||
states.length &&
|
||||
!Number.isFinite(near?.lat) &&
|
||||
!parsed.qualifiers.length
|
||||
)
|
||||
return null;
|
||||
candidates = [...countries, ...states];
|
||||
}
|
||||
}
|
||||
// A county-equivalent city: "Baltimore city", "Fairfax city, Virginia".
|
||||
if (!candidates.length && / city$/.test(parsed.name))
|
||||
candidates = await countyCandidates(parsed);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const ranked = rank(candidates, near);
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
* point. Ambiguity marks do not apply — the geocoder already chose.
|
||||
*
|
||||
* @param {string[]} names the ask and the geocoder's own name for it
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {'country'|'state'|'county'} scope
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*/
|
||||
export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const parsedList = [...new Set((names || []).filter(Boolean))]
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
if (
|
||||
!out.some((c) => c.entry === item.entry) &&
|
||||
polygonsContain(geometryOf(item.entry).polygons, lat, lon)
|
||||
)
|
||||
out.push(item);
|
||||
return out;
|
||||
};
|
||||
if (scope === 'country') {
|
||||
const countries = [];
|
||||
for (const parsed of parsedList)
|
||||
countries.push(
|
||||
...(await countryCandidates({
|
||||
...parsed,
|
||||
kind: 'country',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(countries);
|
||||
return found.length
|
||||
? toResult(rank(found, { lat, lon })[0], found.length)
|
||||
: null;
|
||||
}
|
||||
// The geocoder's scope says what kind of unit this is; the ask's wording
|
||||
// may omit it ("Travis" typed as a county). A US county that matches wins
|
||||
// outright — "New York County" is Manhattan, not the state.
|
||||
if (scope === 'county' && inUnitedStatesBox(lat, lon)) {
|
||||
const counties = [];
|
||||
for (const parsed of parsedList)
|
||||
counties.push(
|
||||
...(await countyCandidates({
|
||||
...parsed,
|
||||
kind: 'county',
|
||||
countyWord: parsed.countyWord || 'county',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(counties);
|
||||
if (found.length)
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
{ allowAmbiguous: true },
|
||||
)),
|
||||
);
|
||||
const found = containing(states);
|
||||
if (!found.length) return null;
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
|
||||
/** Rough box around the US and its territories (skips the county pack elsewhere). */
|
||||
function inUnitedStatesBox(lat, lon) {
|
||||
return (
|
||||
(lat >= 13 && lat <= 72 && (lon <= -64 || lon >= 144)) ||
|
||||
(lat >= -15 && lat <= -10 && lon <= -168)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Bundled state/province and US county boundaries: name matching,
|
||||
// disambiguation, multipolygons and holes, and lazy loading.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
polygonsContain,
|
||||
} from './adminBoundaries.js';
|
||||
|
||||
const ADMIN1 = new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const COUNTIES = new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const PORTLAND = { lat: 45.5152, lon: -122.6784 };
|
||||
|
||||
// Runs first: nothing loads until a lookup needs it, and a state ask never
|
||||
// pulls the county pack.
|
||||
test('packs load lazily, once, and only the pack an ask needs', async () => {
|
||||
assert.deepEqual(packLoads, { admin1: 0, counties: 0, countries: 0 });
|
||||
assert.equal(parseAdminQuery('Texas').kind, 'any');
|
||||
assert.deepEqual(
|
||||
packLoads,
|
||||
{ admin1: 0, counties: 0, countries: 0 },
|
||||
'parsing is free',
|
||||
);
|
||||
await findAdminArea('Texas');
|
||||
await findAdminArea('Bavaria');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 0, countries: 0 });
|
||||
await findAdminArea('Travis County');
|
||||
await findAdminArea('Orleans Parish');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 1, countries: 0 });
|
||||
});
|
||||
|
||||
test('Texas, TX and "State of Texas" name the same unit', async () => {
|
||||
for (const query of [
|
||||
'Texas',
|
||||
'texas',
|
||||
'TX',
|
||||
'State of Texas',
|
||||
'the State of Texas',
|
||||
'Texas, USA',
|
||||
'Texas, United States',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: AUSTIN });
|
||||
assert.equal(unit?.id, 'US-TX', query);
|
||||
assert.equal(unit.name, 'Texas');
|
||||
assert.equal(unit.kind, 'state');
|
||||
assert.equal(unit.source, 'natural-earth');
|
||||
}
|
||||
const texas = await findAdminArea('Texas');
|
||||
assert.ok(
|
||||
texas.areaKm2 > 600_000 && texas.areaKm2 < 750_000,
|
||||
`Texas area ${texas.areaKm2}`,
|
||||
);
|
||||
assert.ok(polygonsContain(texas.polygons, AUSTIN.lat, AUSTIN.lon));
|
||||
});
|
||||
|
||||
test('provinces and states abroad resolve by English and local names', async () => {
|
||||
for (const query of [
|
||||
'Bavaria',
|
||||
'Bayern',
|
||||
'Bavaria, Germany',
|
||||
'Free State of Bavaria',
|
||||
]) {
|
||||
const unit = await findAdminArea(query);
|
||||
assert.equal(unit?.id, 'DE-BY', query);
|
||||
assert.equal(unit.name, 'Bavaria');
|
||||
assert.equal(unit.country, 'Germany');
|
||||
}
|
||||
assert.equal((await findAdminArea('Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('ON'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Province of Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Free State'))?.country, 'South Africa');
|
||||
// Same name in two countries: the qualifier decides.
|
||||
assert.equal((await findAdminArea('Punjab, India'))?.id, 'IN-PB');
|
||||
assert.equal((await findAdminArea('Punjab, Pakistan'))?.id, 'PK-PB');
|
||||
// A qualifier may name the unit itself.
|
||||
assert.equal((await findAdminArea('Washington, DC'))?.id, 'US-DC');
|
||||
});
|
||||
|
||||
test('"Washington" is the state; "Washington County" is a county near the camera', async () => {
|
||||
const state = await findAdminArea('Washington', { near: AUSTIN });
|
||||
assert.equal(state?.id, 'US-WA');
|
||||
const fromAustin = await findAdminArea('Washington County', { near: AUSTIN });
|
||||
assert.equal(fromAustin.kind, 'county');
|
||||
assert.equal(fromAustin.name, 'Washington County');
|
||||
assert.equal(fromAustin.region, 'Texas');
|
||||
assert.ok(fromAustin.candidates > 20, 'dozens of Washington Counties');
|
||||
const fromPortland = await findAdminArea('Washington County', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(fromPortland.region, 'Oregon');
|
||||
const arkansas = await findAdminArea('Washington County, Arkansas', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(arkansas.region, 'Arkansas');
|
||||
assert.equal(arkansas.candidates, 1);
|
||||
assert.equal(
|
||||
(await findAdminArea('Washington County, AR'))?.region,
|
||||
'Arkansas',
|
||||
);
|
||||
});
|
||||
|
||||
test('Travis County with and without its state', async () => {
|
||||
for (const query of [
|
||||
'Travis County',
|
||||
'Travis County, Texas',
|
||||
'Travis County, TX',
|
||||
'Travis County Texas',
|
||||
'travis county, texas, usa',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: PORTLAND });
|
||||
assert.equal(unit?.id, '48453', query);
|
||||
assert.equal(unit.name, 'Travis County');
|
||||
assert.equal(unit.region, 'Texas');
|
||||
assert.equal(unit.source, 'us-census');
|
||||
}
|
||||
assert.equal(await findAdminArea('Travis County, Ohio'), null);
|
||||
assert.equal(await findAdminArea('Travis County Courthouse'), null);
|
||||
// Every qualifier must place the county, not name something in it.
|
||||
assert.equal(await findAdminArea('Travis County Courthouse, Texas'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('Orange County Great Park, California'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('county equivalents: parishes, boroughs, independent cities, and non-US counties', async () => {
|
||||
const orleans = await findAdminArea('Orleans Parish', { near: AUSTIN });
|
||||
assert.equal(orleans?.id, '22071');
|
||||
assert.equal(orleans.region, 'Louisiana');
|
||||
assert.equal((await findAdminArea('Orleans Parish, LA'))?.id, '22071');
|
||||
// The wrong county word still finds the only unit with that name.
|
||||
assert.equal(
|
||||
(await findAdminArea('Anchorage Borough'))?.name,
|
||||
'Anchorage Municipality',
|
||||
);
|
||||
// Outside the US a county is an admin-1 unit.
|
||||
assert.equal((await findAdminArea('County Cork'))?.country, 'Ireland');
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County, England'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County', { near: AUSTIN }))?.region,
|
||||
'Texas',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bare name that is also a country or a large city is left to a geocoder', async () => {
|
||||
for (const query of ['New York', 'Georgia', 'Madrid']) {
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
}
|
||||
// Admin wording settles it.
|
||||
assert.equal((await findAdminArea('New York State'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of New York'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of Mexico'))?.country, 'Mexico');
|
||||
// City-states are the city itself.
|
||||
assert.equal((await findAdminArea('Berlin'))?.id, 'DE-BE');
|
||||
assert.equal((await findAdminArea('Vienna'))?.country, 'Austria');
|
||||
// A country qualifier places a name; it does not make a city a state.
|
||||
assert.equal(await findAdminArea('New York, USA'), null);
|
||||
assert.equal(await findAdminArea('Madrid, Spain'), null);
|
||||
// A namesake city abroad: ambiguous alone, settled by naming the country.
|
||||
const santaFe = { lat: 35.687, lon: -105.938 };
|
||||
assert.equal(await findAdminArea('Santa Fe', { near: santaFe }), null);
|
||||
assert.equal(await findAdminArea('Victoria'), null);
|
||||
assert.equal((await findAdminArea('Victoria, Australia'))?.id, 'AU-VIC');
|
||||
});
|
||||
|
||||
test('bare names: only prominent units, no generic words, codes as codes', async () => {
|
||||
// Places a geocoder knows better than a small admin unit of that name.
|
||||
for (const query of [
|
||||
'Long Island',
|
||||
'Santa Barbara',
|
||||
'Kent',
|
||||
'Hamilton',
|
||||
'Milan',
|
||||
])
|
||||
assert.equal(await findAdminArea(query, { near: AUSTIN }), null, query);
|
||||
for (const query of ['North', 'Central', 'Capital', 'Lakes'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
// Two-letter codes must be written as codes, and not the city ones.
|
||||
assert.equal(await findAdminArea('tx'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('LA', { near: { lat: 34.05, lon: -118.24 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await findAdminArea('NY'), null);
|
||||
assert.equal((await findAdminArea('OK'))?.id, 'US-OK');
|
||||
});
|
||||
|
||||
test('non-admin asks never match', async () => {
|
||||
for (const query of [
|
||||
'Texas Capitol',
|
||||
'the Texas State Capitol',
|
||||
'Zilker Park',
|
||||
'Ohio State',
|
||||
'Texas State',
|
||||
'Alps',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await findAdminArea(query), null, String(query));
|
||||
}
|
||||
});
|
||||
|
||||
test('geocoder-confirmed lookup: the named unit that contains the point', async () => {
|
||||
// Atlanta — the geocoder said "state", so the US state wins over the country.
|
||||
const georgia = await findAdminAreaAt(['Georgia'], 33.749, -84.388, 'state');
|
||||
assert.equal(georgia?.id, 'US-GA');
|
||||
// Tbilisi — Georgia the country has no admin-1 unit of that name.
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
30.33,
|
||||
-97.78,
|
||||
'county',
|
||||
);
|
||||
assert.equal(travis?.id, '48453');
|
||||
// A county the geocoder typed stays a county even where a state shares
|
||||
// its name: Manhattan, the Big Island, Arkansas County.
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['New York County'], 40.783, -73.966, 'county'))?.id,
|
||||
'36061',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Hawaii County'], 19.6, -155.5, 'county'))?.name,
|
||||
'Hawaii County',
|
||||
);
|
||||
// A geocoded point outside the named unit is not that unit.
|
||||
assert.equal(await findAdminAreaAt(['Texas'], 35.0, -106.6, 'state'), null);
|
||||
// Outside the US a geocoder "county" is an admin-1 unit (Kent).
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Kent'], 51.2, 0.7, 'county'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
|
||||
test('multipolygons: every island is a part, and containment follows the parts', async () => {
|
||||
const hawaii = await findAdminArea('Hawaii');
|
||||
assert.ok(
|
||||
hawaii.polygons.length >= 7,
|
||||
`Hawaii parts ${hawaii.polygons.length}`,
|
||||
);
|
||||
assert.ok(polygonsContain(hawaii.polygons, 19.6, -155.5), 'Big Island');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 20.8, -156.3), 'Maui');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 21.45, -158.0), 'Oahu');
|
||||
assert.ok(
|
||||
!polygonsContain(hawaii.polygons, 21.0, -157.3),
|
||||
'the channel between',
|
||||
);
|
||||
// The main ring is the largest part (the Big Island).
|
||||
assert.equal(hawaii.ring, hawaii.polygons[0][0]);
|
||||
|
||||
// Alaska is cut at the antimeridian: its box stays on one side of the globe.
|
||||
const alaska = await findAdminArea('Alaska');
|
||||
const [west, , east] = alaska.bbox;
|
||||
assert.ok(east - west < 60, `Alaska box width ${east - west}°`);
|
||||
assert.ok(alaska.polygons.length > 10, 'Aleutians and the southeast islands');
|
||||
});
|
||||
|
||||
test('holes: Berlin is cut out of Brandenburg', async () => {
|
||||
const brandenburg = await findAdminArea('Brandenburg');
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length > 1),
|
||||
'Brandenburg has a hole',
|
||||
);
|
||||
assert.ok(!polygonsContain(brandenburg.polygons, 52.52, 13.405), 'Berlin');
|
||||
assert.ok(polygonsContain(brandenburg.polygons, 52.39, 13.06), 'Potsdam');
|
||||
const berlin = await findAdminArea('Berlin');
|
||||
assert.ok(polygonsContain(berlin.polygons, 52.52, 13.405));
|
||||
});
|
||||
|
||||
test('holes: an independent city is cut out of its county', async () => {
|
||||
const fairfax = await findAdminArea('Fairfax County, Virginia');
|
||||
assert.ok(fairfax.polygons[0].length > 1, 'Fairfax County has holes');
|
||||
assert.ok(
|
||||
!polygonsContain(fairfax.polygons, 38.846, -77.306),
|
||||
'Fairfax city',
|
||||
);
|
||||
assert.ok(polygonsContain(fairfax.polygons, 38.9, -77.2), 'Fairfax County');
|
||||
const city = await findAdminArea('Fairfax city, Virginia');
|
||||
assert.ok(polygonsContain(city.polygons, 38.846, -77.306));
|
||||
assert.equal((await findAdminArea('Carson City'))?.region, 'Nevada');
|
||||
for (const query of ['New York City', 'Mexico City', 'Quebec City, Canada'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
});
|
||||
|
||||
test('rings decode from deltas and are open', () => {
|
||||
assert.deepEqual(decodeRing([-97743, 30267, 10, -5, -3, 8], 3), [
|
||||
[-97.743, 30.267],
|
||||
[-97.733, 30.262],
|
||||
[-97.736, 30.27],
|
||||
]);
|
||||
});
|
||||
|
||||
test('names normalize across accents, punctuation and "Saint"', () => {
|
||||
assert.equal(normalizeAdminName('Québec'), 'quebec');
|
||||
assert.equal(normalizeAdminName(' The State of TEXAS '), 'state of texas');
|
||||
assert.equal(normalizeAdminName('Saint Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('St. Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('Schleswig-Holstein'), 'schleswig holstein');
|
||||
});
|
||||
|
||||
test('packs: public-domain provenance, schema, and size budgets', () => {
|
||||
const admin1 = JSON.parse(readFileSync(ADMIN1, 'utf8'));
|
||||
const counties = JSON.parse(readFileSync(COUNTIES, 'utf8'));
|
||||
for (const pack of [admin1, counties]) {
|
||||
assert.match(pack.meta.license, /public domain/i);
|
||||
assert.match(pack.meta.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pack.meta.script, 'scripts/build-admin-packs.mjs');
|
||||
for (const feature of pack.features) {
|
||||
assert.ok(feature.name, 'named');
|
||||
assert.ok(feature.polygons.length >= 1, `${feature.name} has a part`);
|
||||
for (const poly of feature.polygons)
|
||||
for (const ring of poly) {
|
||||
assert.equal(ring.length % 2, 0);
|
||||
assert.ok(ring.length >= 8, `${feature.name}: ring has 4+ vertices`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
admin1.features.length > 4500,
|
||||
`${admin1.features.length} states/provinces`,
|
||||
);
|
||||
assert.ok(
|
||||
counties.features.length > 3200,
|
||||
`${counties.features.length} counties`,
|
||||
);
|
||||
assert.ok(statSync(ADMIN1).size <= 4 * 1024 * 1024, 'admin-1 pack ≤ 4 MB');
|
||||
assert.ok(statSync(COUNTIES).size <= 2 * 1024 * 1024, 'county pack ≤ 2 MB');
|
||||
});
|
||||
|
||||
test('county queries compare US and international names before choosing a geography', async () => {
|
||||
const england = { lat: 54.78, lon: -1.58 };
|
||||
const nc = { lat: 36.0, lon: -78.9 };
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham', { near: england }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham'))?.country,
|
||||
'United Kingdom',
|
||||
'exact alias survives parsing',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County', { near: nc }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: nc }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, USA', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County, NC', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
// Durham is also a city: without county wording leave city geocoding intact.
|
||||
assert.equal(await findAdminArea('Durham, NC', { near: england }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Durham, NC'], nc.lat, nc.lon, 'county'))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
});
|
||||
|
||||
test('countries resolve aliases, keep substantial islands and disambiguate Georgia by scope and geography', async () => {
|
||||
for (const names of [
|
||||
['USA', 'United States', 'America'],
|
||||
['UK', 'United Kingdom', 'Britain'],
|
||||
['Iran', 'Islamic Republic of Iran'],
|
||||
['Czechia', 'Czech Republic'],
|
||||
['Ivory Coast', "Côte d'Ivoire"],
|
||||
['South Korea', 'Korea'],
|
||||
['Russia', 'Russian Federation'],
|
||||
['Switzerland'],
|
||||
['France'],
|
||||
]) {
|
||||
const resolved = await Promise.all(
|
||||
names.map((name) => findAdminArea(name, { near: AUSTIN })),
|
||||
);
|
||||
assert.ok(
|
||||
resolved.every((r) => r?.kind === 'country'),
|
||||
names.join('/'),
|
||||
);
|
||||
assert.equal(new Set(resolved.map((r) => r.id)).size, 1);
|
||||
}
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: AUSTIN })).kind,
|
||||
'state',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: { lat: 41.7, lon: 44.8 } })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('the country of Georgia', { near: AUSTIN })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(await findAdminArea('New York', { near: AUSTIN }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Iran'], 35.69, 51.39, 'country')).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
await findAdminAreaAt(['Iran'], 46.9, 7.4, 'country'),
|
||||
null,
|
||||
'name alone cannot override a mismatched geocoded point',
|
||||
);
|
||||
const japan = await findAdminArea('Japan');
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.69],
|
||||
[43.06, 141.35],
|
||||
[33.59, 130.4],
|
||||
[33.56, 133.53],
|
||||
])
|
||||
assert.ok(polygonsContain(japan.polygons, lat, lon));
|
||||
const usa = await findAdminArea('USA');
|
||||
for (const [lat, lon] of [
|
||||
[30.27, -97.74],
|
||||
[61.22, -149.9],
|
||||
[19.7, -155.1],
|
||||
])
|
||||
assert.ok(
|
||||
polygonsContain(usa.polygons, lat, lon),
|
||||
'USA includes mainland, Alaska and Hawaii',
|
||||
);
|
||||
const bytes = statSync(
|
||||
new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
).size;
|
||||
assert.ok(bytes < 1_000_000);
|
||||
});
|
||||
|
||||
test('UK constituent countries retain their own identity and extent from any camera', async () => {
|
||||
const targets = [
|
||||
['Scotland', 'SCT', [55.95, -3.19], [54.6, 61]],
|
||||
['England', 'ENG', [51.5, -0.12], [49.8, 55.9]],
|
||||
['Wales', 'WLS', [51.48, -3.18], [51.3, 53.6]],
|
||||
['Northern Ireland', 'NIR', [54.6, -5.93], [53.9, 55.4]],
|
||||
];
|
||||
for (const [name, id, point, extent] of targets) {
|
||||
const unit = await findAdminArea(name, { near: AUSTIN });
|
||||
assert.equal(unit.id, id);
|
||||
assert.equal(unit.name, name);
|
||||
assert.equal(unit.kind, 'country');
|
||||
assert.ok(
|
||||
unit.bbox[1] >= extent[0] && unit.bbox[3] <= extent[1],
|
||||
`${name}: ${unit.bbox}`,
|
||||
);
|
||||
assert.ok(polygonsContain(unit.polygons, ...point));
|
||||
for (const [other, , otherPoint] of targets)
|
||||
if (other !== name)
|
||||
assert.equal(polygonsContain(unit.polygons, ...otherPoint), false);
|
||||
assert.equal((await findAdminAreaAt([name], ...point, 'country')).id, id);
|
||||
assert.equal(await findAdminAreaAt([name], 30.27, -97.74, 'country'), null);
|
||||
}
|
||||
assert.equal((await findAdminArea('UK', { near: AUSTIN })).id, 'GBR');
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: AUSTIN })).country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
+40
-10
@@ -1,3 +1,5 @@
|
||||
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
|
||||
|
||||
const KNOT_TO_MPS = 0.514444;
|
||||
const FOOT_TO_M = 0.3048;
|
||||
const FPM_TO_MPS = 0.00508;
|
||||
@@ -43,27 +45,35 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
.toLowerCase();
|
||||
const latitude = finiteNumber(aircraft?.lat);
|
||||
const longitude = finiteNumber(aircraft?.lon);
|
||||
if (!hex || latitude === null || longitude === null) return null;
|
||||
if (
|
||||
!hex ||
|
||||
latitude === null ||
|
||||
longitude === null ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
|
||||
const seenPosition = Math.max(
|
||||
0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
|
||||
);
|
||||
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
|
||||
const onGround = aircraft?.alt_baro === 'ground';
|
||||
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm =
|
||||
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
hex,
|
||||
String(aircraft?.flight || aircraft?.r || '').trim() || null,
|
||||
null,
|
||||
Math.max(0, nowSeconds - seenPosition),
|
||||
Math.max(0, nowSeconds - seen),
|
||||
Number.isFinite(seenPosition)
|
||||
? Math.max(0, nowSeconds - seenPosition)
|
||||
: null,
|
||||
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
|
||||
longitude,
|
||||
latitude,
|
||||
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
|
||||
@@ -94,8 +104,28 @@ export function normalizeAdsbLolPointResponse(payload) {
|
||||
: Math.floor(
|
||||
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
|
||||
);
|
||||
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
|
||||
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
|
||||
.filter(Boolean);
|
||||
return { time: nowSeconds, states };
|
||||
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
|
||||
const states = [];
|
||||
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
|
||||
// status travels alongside them, keyed only to admitted aircraft.
|
||||
const admitted = new Set();
|
||||
const statuses = [];
|
||||
for (const row of aircraft) {
|
||||
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
|
||||
if (!state) continue;
|
||||
states.push(state);
|
||||
// The live contract admits the first valid vector for each ICAO. Its
|
||||
// status must come from that same row, never an invalid or later duplicate.
|
||||
const id = state[0];
|
||||
if (admitted.has(id)) continue;
|
||||
admitted.add(id);
|
||||
if (typeof row.emergency === 'string')
|
||||
statuses.push([id, row.emergency.trim().toLowerCase()]);
|
||||
}
|
||||
const emergencyByIcao = Object.fromEntries(statuses);
|
||||
return {
|
||||
time: nowSeconds,
|
||||
states,
|
||||
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
|
||||
};
|
||||
}
|
||||
+430
-67
@@ -7,91 +7,418 @@
|
||||
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
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 +431,73 @@ 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';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts (English data). */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: msg('Light aircraft'),
|
||||
glider: msg('Glider'),
|
||||
turboprop: msg('Turboprop'),
|
||||
airliner: msg('Airliner'),
|
||||
widebody: msg('Widebody'),
|
||||
quadjet: msg('Four-engine jet'),
|
||||
helicopter: msg('Helicopter'),
|
||||
fastjet: msg('Fast jet'),
|
||||
bizjet: msg('Business jet'),
|
||||
uav: msg('Drone'),
|
||||
});
|
||||
|
||||
/** 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 +511,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 },
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Transponder emergency status for one aircraft, from the Mode A squawk and
|
||||
* the ADS-B emergency/priority field.
|
||||
*
|
||||
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
|
||||
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
|
||||
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
|
||||
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
|
||||
* squawk only.
|
||||
*
|
||||
* The status is what the transponder broadcast in THIS poll, never a finding:
|
||||
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
|
||||
* broadcast, which does not confirm an incident or its resolution.
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||
|
||||
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||
|
||||
const KINDS = {
|
||||
general: { label: msg('General emergency'), severity: 'emergency' },
|
||||
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
|
||||
nordo: { label: msg('Radio failure'), severity: 'emergency' },
|
||||
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
|
||||
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
|
||||
lifeguard: {
|
||||
label: msg('Lifeguard (medical priority)'),
|
||||
severity: 'priority',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Mode A code as its four octal digits, else null.
|
||||
* @param {unknown} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeSquawk(value) {
|
||||
const code = String(value ?? '').trim();
|
||||
return /^[0-7]{4}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency status from one observation. Higher severity wins; an ADS-B
|
||||
* emergency wins a tie. A
|
||||
* 7x00 squawk counts even when the ADS-B field says `none`.
|
||||
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
|
||||
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
|
||||
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
|
||||
* 'squawk' when the Mode A code itself names the status (so it can be
|
||||
* quoted), else 'ads-b'.
|
||||
*/
|
||||
export function aircraftEmergency({ squawk, emergency } = {}) {
|
||||
const code = normalizeSquawk(squawk);
|
||||
const reported = String(emergency ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const fromAdsb = Object.hasOwn(KINDS, reported);
|
||||
const squawkKind = SQUAWK_KINDS[code] ?? null;
|
||||
const kind =
|
||||
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
|
||||
? reported
|
||||
: squawkKind;
|
||||
if (!kind) return null;
|
||||
return {
|
||||
kind,
|
||||
...KINDS[kind],
|
||||
squawk: code,
|
||||
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a summary may phrase the status without flattening it into an incident:
|
||||
* quote the code when the Mode A squawk itself names the status, otherwise
|
||||
* attribute it to the ADS-B emergency field.
|
||||
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
|
||||
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
|
||||
* or 'ADS-B reports general emergency'; null when there is no status.
|
||||
*/
|
||||
export function emergencyBroadcastPhrase(status) {
|
||||
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
|
||||
const label = KINDS[status.kind].label.toLowerCase();
|
||||
return status.source === 'squawk' && status.squawk
|
||||
? `broadcasting squawk ${status.squawk} (${label})`
|
||||
: `ADS-B reports ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft is broadcasting an emergency (not a priority status).
|
||||
* @param {{severity?: string}|null|undefined} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmergency(status) {
|
||||
return status?.severity === 'emergency';
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/data/aircraftEmergency.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aircraftEmergency,
|
||||
emergencyBroadcastPhrase,
|
||||
isEmergency,
|
||||
normalizeSquawk,
|
||||
} from './aircraftEmergency.js';
|
||||
|
||||
test('the three ICAO emergency squawks map to their kinds', () => {
|
||||
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
|
||||
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
|
||||
const general = aircraftEmergency({ squawk: ' 7700 ' });
|
||||
assert.deepEqual(general, {
|
||||
kind: 'general',
|
||||
label: 'General emergency',
|
||||
severity: 'emergency',
|
||||
squawk: '7700',
|
||||
source: 'squawk',
|
||||
});
|
||||
});
|
||||
|
||||
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
|
||||
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
|
||||
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
|
||||
assert.equal(aircraftEmergency(), null);
|
||||
assert.equal(normalizeSquawk('7800'), null);
|
||||
assert.equal(normalizeSquawk(7700), '7700');
|
||||
assert.equal(normalizeSquawk('770'), null);
|
||||
});
|
||||
|
||||
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
|
||||
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
|
||||
assert.equal(minfuel.kind, 'minfuel');
|
||||
assert.equal(minfuel.severity, 'priority');
|
||||
assert.equal(minfuel.source, 'ads-b');
|
||||
assert.equal(minfuel.squawk, '4521');
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
|
||||
'squawk',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
|
||||
'squawk',
|
||||
'a code that names the status stays quotable',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
|
||||
'ads-b',
|
||||
);
|
||||
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
|
||||
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
|
||||
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('priority statuses are reported but are not emergencies', () => {
|
||||
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
|
||||
assert.equal(
|
||||
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
false,
|
||||
);
|
||||
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
|
||||
assert.equal(isEmergency(null), false);
|
||||
});
|
||||
|
||||
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
|
||||
),
|
||||
'ADS-B reports general emergency',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
'ADS-B reports lifeguard (medical priority)',
|
||||
);
|
||||
assert.equal(emergencyBroadcastPhrase(null), null);
|
||||
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('an emergency squawk outranks an ADS-B priority', () => {
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
|
||||
'unlawful',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
|
||||
import { FlightRecords } from '../layers/flights/records.js';
|
||||
import { MilitaryFlightRecords } from '../layers/military/records.js';
|
||||
import { mapAnalystRecord as mapFlight } from './flights.js';
|
||||
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
|
||||
import { createAnalystEngine } from './analystEngine.js';
|
||||
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
const NOW_MS = 1800000000000;
|
||||
|
||||
test('fallback status belongs to the first valid admitted aircraft row', () => {
|
||||
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
|
||||
for (const status of [undefined, 'general', 'none']) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [
|
||||
{ ...valid, lat: 100, emergency: 'general' },
|
||||
{ ...valid, emergency: status },
|
||||
{ ...valid, lat: null, emergency: 'general' },
|
||||
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
|
||||
],
|
||||
});
|
||||
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
|
||||
assert.equal(snapshot.records.length, 1);
|
||||
assert.equal(snapshot.records[0].emergency, status);
|
||||
assert.equal(snapshot.records[0].latitude, 30);
|
||||
}
|
||||
});
|
||||
|
||||
function civilStore() {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
}
|
||||
const receiveContext = {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
};
|
||||
|
||||
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
|
||||
const store = civilStore();
|
||||
const aircraft = {
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
for (const [status, expectedKind, flagged] of [
|
||||
['general', 'general', true],
|
||||
['minfuel', 'minfuel', false],
|
||||
['none', null, false],
|
||||
]) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [{ ...aircraft, emergency: status }],
|
||||
});
|
||||
assert.equal(
|
||||
payload.states[0].length,
|
||||
18,
|
||||
'fallback vectors retain the OpenSky shape',
|
||||
);
|
||||
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
|
||||
const result = mapFlight(
|
||||
aircraft.hex,
|
||||
store.receive(observation, receiveContext).meta,
|
||||
);
|
||||
assert.equal(result.emergency, flagged);
|
||||
assert.equal(result.emergencyKind, expectedKind);
|
||||
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
|
||||
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
|
||||
}
|
||||
});
|
||||
|
||||
test('changed status without contact time retains general contact, including voice projection', async () => {
|
||||
const store = civilStore();
|
||||
const [observation] = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS, now: NOW_MS },
|
||||
).records;
|
||||
store.receive(observation, receiveContext);
|
||||
const meta = store.receive(
|
||||
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
|
||||
receiveContext,
|
||||
).meta;
|
||||
const record = mapFlight(observation.id, meta);
|
||||
assert.equal(record.emergencyKind, 'nordo');
|
||||
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const flights = {
|
||||
id: 'flights',
|
||||
source: 'fixture',
|
||||
getStats: () => ({ count: 1 }),
|
||||
getAnalystRecords: () => [record],
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionCartographic: {
|
||||
height: 300000,
|
||||
latitude: 0.52,
|
||||
longitude: -1.71,
|
||||
},
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
getContextModeState: () => ({ mode: null, active: false }),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
},
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
isEnabled: (id) => id === 'flights',
|
||||
getAll: () => [
|
||||
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'emergency', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items[0].emergency, true);
|
||||
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(result.items[0].emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
result.items[0].emergencyBroadcast,
|
||||
'broadcasting squawk 7600 (radio failure)',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result.items[0], 'time'),
|
||||
false,
|
||||
'general contact is not a generic incident timestamp',
|
||||
);
|
||||
} finally {
|
||||
runner.dispose?.();
|
||||
}
|
||||
});
|
||||
|
||||
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
|
||||
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
|
||||
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
|
||||
const OPENSKY = {
|
||||
time: NOW_MS / 1000,
|
||||
states: [
|
||||
[
|
||||
'a1b2c3',
|
||||
'SWA696 ',
|
||||
'United States',
|
||||
NOW_MS / 1000 - 6,
|
||||
NOW_MS / 1000 - 3,
|
||||
-97.67,
|
||||
30.19,
|
||||
3000,
|
||||
false,
|
||||
150,
|
||||
180,
|
||||
-5,
|
||||
null,
|
||||
3040,
|
||||
'7700',
|
||||
false,
|
||||
0,
|
||||
4,
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
|
||||
const ADSB_LOL = {
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
flight: 'RCH123 ',
|
||||
t: 'C17',
|
||||
lat: 31.1,
|
||||
lon: -97.4,
|
||||
alt_baro: 18000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 1.5,
|
||||
seen_pos: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function civilAnalystRecord() {
|
||||
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
|
||||
const store = new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
return mapFlight(row.id, meta);
|
||||
}
|
||||
|
||||
function militaryAnalystRecord() {
|
||||
const [row] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
});
|
||||
return mapMilitary(row.id, meta);
|
||||
}
|
||||
|
||||
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
|
||||
const civil = civilAnalystRecord();
|
||||
const military = militaryAnalystRecord();
|
||||
|
||||
assert.equal(civil.emergency, true);
|
||||
assert.equal(civil.emergencyKind, 'general');
|
||||
assert.equal(civil.squawk, '7700');
|
||||
assert.equal(civil.emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
civil.emergencyBroadcast,
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
|
||||
|
||||
assert.equal(military.emergency, true);
|
||||
assert.equal(military.emergencyKind, 'general');
|
||||
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
|
||||
assert.equal(military.emergencySource, 'ads-b');
|
||||
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
|
||||
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
|
||||
});
|
||||
|
||||
test('the analyst can filter emergencies by source', async () => {
|
||||
const records = {
|
||||
flights: [civilAnalystRecord()],
|
||||
military: [militaryAnalystRecord()],
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => records[key] || [],
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
|
||||
});
|
||||
const query = (value) =>
|
||||
engine.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [
|
||||
{ field: 'emergency', op: 'eq', value: true },
|
||||
{ field: 'emergencySource', op: 'eq', value },
|
||||
],
|
||||
});
|
||||
const bySquawk = await query('squawk');
|
||||
assert.deepEqual(
|
||||
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
|
||||
);
|
||||
const byAdsb = await query('ads-b');
|
||||
assert.deepEqual(
|
||||
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['ae01ce', 'ADS-B reports general emergency']],
|
||||
);
|
||||
});
|
||||
|
||||
test('military status changes retain only the latest known general contact', () => {
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (v) => v,
|
||||
});
|
||||
const [observation] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const context = {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
const unknown = mapMilitary(
|
||||
observation.id,
|
||||
store.receive({ ...observation, contactTimeMs: null }, context).meta,
|
||||
);
|
||||
assert.equal(unknown.lastContactAtMs, null);
|
||||
store.receive(observation, context);
|
||||
const changed = mapMilitary(
|
||||
observation.id,
|
||||
store.receive(
|
||||
{
|
||||
...observation,
|
||||
contactTimeMs: null,
|
||||
squawk: '7600',
|
||||
emergency: 'none',
|
||||
},
|
||||
context,
|
||||
).meta,
|
||||
);
|
||||
assert.equal(changed.emergencyKind, 'nordo');
|
||||
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
|
||||
});
|
||||
|
||||
test('fallback missing contact age retains known contact through the real converter', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
const first = receive(row);
|
||||
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
|
||||
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
|
||||
});
|
||||
|
||||
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
assert.equal(receive(row).emergency.kind, 'general');
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined }).emergency?.kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
function renderHarness(factory) {
|
||||
const flightState = { records: { data: new Map() }, _trackedIcao: null };
|
||||
const rendering = factory({
|
||||
flightState,
|
||||
services: {
|
||||
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
|
||||
aircraftPresentation: {},
|
||||
camera: {},
|
||||
focus: {},
|
||||
recession: {},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return { flightState, rendering };
|
||||
}
|
||||
|
||||
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
|
||||
for (const factory of [civilRendering, militaryRendering]) {
|
||||
const { flightState, rendering } = renderHarness(factory);
|
||||
const normal = rendering._fleetBillboardColor('civil', {});
|
||||
assert.equal(
|
||||
rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ emergency: 'minfuel' }),
|
||||
}),
|
||||
normal,
|
||||
);
|
||||
const red = rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.ok(red.red > 0.9 && red.green < 0.5);
|
||||
flightState.records.data.set('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.equal(rendering._modelColor('civil'), red);
|
||||
if (rendering._cockpitContactColor)
|
||||
assert.equal(rendering._cockpitContactColor('civil'), red);
|
||||
flightState._trackedIcao = 'civil';
|
||||
assert.equal(
|
||||
rendering._modelColor('civil'),
|
||||
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
|
||||
);
|
||||
flightState._trackedIcao = null;
|
||||
flightState.records.data.set('civil', { emergency: null });
|
||||
assert.equal(rendering._modelColor('civil'), normal);
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
flightState.records = records;
|
||||
const apply = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
for (const [emergency, red] of [
|
||||
['general', true],
|
||||
['none', false],
|
||||
['general', true],
|
||||
]) {
|
||||
const snapshot = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'abc123',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
squawk: '1200',
|
||||
emergency,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
*
|
||||
* Cesium gives each Model a dynamic environment map: it renders the sky around
|
||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||
* when the model is first placed and again when it moves more than 1 km, when
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||
*/
|
||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||
enabled: false,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
|
||||
/**
|
||||
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||
* the glTF cache and are never added to the scene, so they are left out:
|
||||
* drawn models are the calls that set a minimum pixel size.
|
||||
*/
|
||||
const SOURCES = [
|
||||
{
|
||||
name: 'flights',
|
||||
source: readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'militaryFlights',
|
||||
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'localAdsb',
|
||||
source: readFileSync(
|
||||
new URL('../layers/localAdsb/models.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
test('the shared option turns the dynamic environment map off', () => {
|
||||
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
|
||||
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
|
||||
});
|
||||
|
||||
for (const { name, source } of SOURCES) {
|
||||
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||
const calls = [
|
||||
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
|
||||
for (const [index, options] of calls.entries())
|
||||
assert.match(
|
||||
options,
|
||||
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
|
||||
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
return Boolean(
|
||||
includeHidden || isTracked || billboardShown || modelRendering,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -219,7 +242,7 @@ export function applyAircraftBillboardTreatment({
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the already-composed aircraft alpha to an ambient glTF model without
|
||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
@@ -228,11 +251,22 @@ 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 &&
|
||||
model.color.red === baseColor.red &&
|
||||
model.color.green === baseColor.green &&
|
||||
model.color.blue === baseColor.blue
|
||||
)
|
||||
return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
return 1;
|
||||
}
|
||||
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
|
||||
assert.equal(model.colorBlendAmount, 0.9);
|
||||
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
|
||||
});
|
||||
|
||||
|
||||
test('model hue changes at constant alpha, then clears without redundant writes', () => {
|
||||
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
|
||||
const model = {color:color(1,1,1).withAlpha(0.65)};
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,0.2);
|
||||
assert.equal(model.color.alpha,0.65);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,1);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
navStatus: null, // /api/vessels does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+48
-2105
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -856,15 +860,6 @@ function makeTrailSpy() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassList(...initial) {
|
||||
const classes = new Set(initial);
|
||||
return {
|
||||
add(value) { classes.add(value); },
|
||||
remove(value) { classes.delete(value); },
|
||||
contains(value) { return classes.has(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteractionHandler() {
|
||||
return {
|
||||
click: null,
|
||||
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
};
|
||||
const record = makeRecord();
|
||||
const trail = makeTrailSpy();
|
||||
presentSelectedVessel({ text: `AIS: ${record.name}` });
|
||||
let vesselReadout;
|
||||
const stopReadout = subscribeSelectedVessel(
|
||||
(readout) => (vesselReadout = readout),
|
||||
);
|
||||
const hud = {
|
||||
textContent: `AIS: ${record.name}`,
|
||||
classList: makeClassList('active'),
|
||||
get readout() {
|
||||
return vesselReadout;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.window = windowTarget;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||||
getElementById: () => null,
|
||||
};
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
@@ -1011,6 +1013,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
@@ -1063,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||||
@@ -1091,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Empty pick is a deliberate deselect — see the note above.
|
||||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||||
} finally {
|
||||
@@ -1105,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||||
try {
|
||||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1294,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
@@ -1600,6 +1648,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
|
||||
}
|
||||
});
|
||||
|
||||
for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||||
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
|
||||
const id = 'cyclone:fixture:cone:0';
|
||||
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
|
||||
const harness = installWireHarness({ id });
|
||||
const requests = [];
|
||||
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
|
||||
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
|
||||
if (card === 'covered') host.hitTest = (_x, _y, options) => {
|
||||
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
|
||||
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
|
||||
};
|
||||
_setVesselOverlayHostForTest(host);
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(requests.length, card === 'live' ? 1 : 0);
|
||||
} finally {
|
||||
_setVesselOverlayHostForTest(null);
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('weather-cyclones');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||
assert.equal(keyed.interactive, true);
|
||||
@@ -1641,3 +1714,75 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
assert.equal(nameless.id, '366999124');
|
||||
assert.equal(nameless.mmsi, '366999124');
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
label: 'Test vessel source',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].reference, 'opaque:test-reference');
|
||||
assert.ok(requests[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(aisLiveVesselsLayer.source, 'Test vessel source');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AISSTREAM_STALE_MS,
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
readAisTrack,
|
||||
} 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);
|
||||
});
|
||||
|
||||
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||
);
|
||||
// AISStream's MetaData carries the last known position on every message,
|
||||
// static data included.
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: {
|
||||
ShipStaticData: {
|
||||
UserID: 244690000,
|
||||
Name: 'EXAMPLE VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
},
|
||||
MetaData: {
|
||||
MMSI: '244690000',
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const row = rowFor('244690000');
|
||||
assert.equal(row.speed, 12.4);
|
||||
assert.equal(row.course, 86.7);
|
||||
assert.equal(row.heading, 87);
|
||||
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||
assert.equal(row.destination, 'ROTTERDAM');
|
||||
assert.equal(row.imo, '9876543');
|
||||
});
|
||||
|
||||
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||
const classBStatic = (part, value) => ({
|
||||
MessageID: 24,
|
||||
RepeatIndicator: 0,
|
||||
Valid: true,
|
||||
Reserved: 0,
|
||||
PartNumber: part === 'B',
|
||||
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||
ReportB: {
|
||||
Valid: part === 'B',
|
||||
ShipType: part === 'B' ? value : 0,
|
||||
VendorIDName: '',
|
||||
VenderIDModel: 0,
|
||||
VenderIDSerial: 0,
|
||||
CallSign: '',
|
||||
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||
FixType: 0,
|
||||
Spare: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, scenario] of [
|
||||
{
|
||||
type: 'ShipStaticData',
|
||||
initial: [
|
||||
{
|
||||
Name: 'OLD VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
Name: 'NEW VESSEL',
|
||||
Type: 80,
|
||||
Destination: 'HAMBURG',
|
||||
ImoNumber: 9876544,
|
||||
},
|
||||
],
|
||||
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||
expected: {
|
||||
name: 'NEW VESSEL',
|
||||
type: '80',
|
||||
destination: 'HAMBURG',
|
||||
imo: '9876544',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'StaticDataReport',
|
||||
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||
empty: classBStatic('A', ''),
|
||||
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||
},
|
||||
].entries()) {
|
||||
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||
const mmsi = String(244690100 + index);
|
||||
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||
let now = start;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const position = (
|
||||
lat,
|
||||
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||
) => {
|
||||
const envelope = positionReport(mmsi, body);
|
||||
Object.assign(envelope.MetaData, {
|
||||
latitude: lat,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
});
|
||||
return ingestAisStreamEnvelope(envelope);
|
||||
};
|
||||
const sendStatic = (body) =>
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: scenario.type,
|
||||
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
ShipName: 'STALE HEADER',
|
||||
latitude: 54,
|
||||
longitude: 7,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
},
|
||||
});
|
||||
position(53.40879);
|
||||
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||
now += 60_000;
|
||||
position(53.41879);
|
||||
const before = rowFor(mmsi);
|
||||
const track = readAisTrack(mmsi);
|
||||
assert.equal(track.length, 2);
|
||||
|
||||
now += 5 * 60_000;
|
||||
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
|
||||
// Empty or omitted static fields must not erase known metadata, including
|
||||
// the cached values used when the next actual position arrives.
|
||||
assert.equal(sendStatic(scenario.empty), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
if (scenario.type === 'StaticDataReport') {
|
||||
sendStatic({
|
||||
...classBStatic('A', ''),
|
||||
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||
});
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
}
|
||||
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(
|
||||
rowFor(mmsi),
|
||||
undefined,
|
||||
'static traffic must not renew position freshness',
|
||||
);
|
||||
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||
const next = rowFor(mmsi);
|
||||
for (const [key, value] of Object.entries(scenario.expected))
|
||||
assert.equal(next[key], value, key);
|
||||
assert.equal(next.speed, 0);
|
||||
assert.equal(next.course, 0);
|
||||
assert.equal(next.heading, 0);
|
||||
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||
assert.equal(readAisTrack(mmsi).length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||
const mmsi = '244690200';
|
||||
const staticReport = (body) => ({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||
const extended = positionReport(mmsi, {});
|
||||
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||
extended.Message = {
|
||||
ExtendedClassBPositionReport: {
|
||||
UserID: Number(mmsi),
|
||||
Type: 80,
|
||||
Sog: 12,
|
||||
Cog: 86,
|
||||
},
|
||||
};
|
||||
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||
ingestAisStreamEnvelope(extended);
|
||||
const before = rowFor(mmsi);
|
||||
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||
assert.equal(rowFor(mmsi).type, '80');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
});
|
||||
+6
-22
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze([
|
||||
'transport',
|
||||
'auth',
|
||||
'rate-limit',
|
||||
]);
|
||||
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
@@ -249,6 +229,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!env.hasKey) {
|
||||
const actions = terminateOwned('key-absent');
|
||||
status = 'missing-key';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'AISSTREAM_API_KEY is not set';
|
||||
reconnectAttempt = 0;
|
||||
keyFingerprint = null;
|
||||
@@ -257,6 +238,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (env.hasTransport === false) {
|
||||
const actions = terminateOwned('no-transport');
|
||||
status = 'unsupported';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'Node WebSocket transport is unavailable';
|
||||
return actions;
|
||||
}
|
||||
@@ -329,6 +311,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!silenceWatchArmed) return [];
|
||||
const silentFor = monoNow - silenceSinceMono;
|
||||
if (silentFor >= recycleAfterMs) {
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||
const actions = terminateOwned('silent');
|
||||
scheduleRetry('transport');
|
||||
@@ -401,6 +384,7 @@ export function createAisWatchdog(options = {}) {
|
||||
function onClose(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
release();
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
if (!error) error = 'AISStream websocket closed';
|
||||
scheduleRetry('transport');
|
||||
return [];
|
||||
@@ -450,7 +434,7 @@ export function createAisWatchdog(options = {}) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
/** Status metadata for /api/vessels. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
createAlprTileSource(),
|
||||
['fetch'],
|
||||
'ALPR source',
|
||||
{ destroy: () => {}, detailZoom: () => null },
|
||||
);
|
||||
export const configureAlprSource = slot.configure;
|
||||
export default createApplicationAlpr({
|
||||
surface: defaultSurface,
|
||||
source: slot.source,
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { directionToHeading } from './directionText.js';
|
||||
test('conflicting facing directions cannot manufacture one heading', () => {
|
||||
for (const bare of [false, true]) {
|
||||
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
|
||||
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
|
||||
assert.equal(directionToHeading('NB northbound', bare), 0);
|
||||
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
|
||||
}
|
||||
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
|
||||
assert.equal(directionToHeading('West / SR-24', true), 270);
|
||||
});
|
||||
|
||||
test('travel tokens retain precedence over quadrant streets and bare words', () => {
|
||||
for (const [text, heading] of [
|
||||
['I-5 NB at NE 45th St', 0],
|
||||
['SR 520 EB at NE 148th', 90],
|
||||
['US 13 SB @ SW Front St', 180],
|
||||
]) {
|
||||
for (const bare of [false, true])
|
||||
assert.equal(directionToHeading(text, bare), heading);
|
||||
}
|
||||
assert.equal(
|
||||
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
|
||||
270,
|
||||
);
|
||||
assert.equal(directionToHeading('NE near West St', true), 45);
|
||||
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
|
||||
});
|
||||
+922
-118
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||
});
|
||||
const result = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.coverage.scopeSource, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||
const lookups = [];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
lookups.push(name);
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const airborne = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||
});
|
||||
const military = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(airborne.count, 2);
|
||||
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||
for (const result of [first, airborne, military]) {
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.scopeLabel, 'over Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
}
|
||||
assert.deepEqual(lookups, ['Texland']);
|
||||
});
|
||||
|
||||
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: {
|
||||
name,
|
||||
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||
source: 'remote-boundary',
|
||||
},
|
||||
});
|
||||
await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({
|
||||
followUp: true,
|
||||
scope: { kind: 'region', name: 'Northern Texland' },
|
||||
});
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||
for (const scope of [
|
||||
{ kind: 'anywhere' },
|
||||
{ kind: 'view' },
|
||||
{ kind: 'radius', km: 150 },
|
||||
{ kind: 'region', name: 'Uncredited Texland' },
|
||||
]) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({
|
||||
...TEXLAND,
|
||||
name,
|
||||
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({ followUp: true, scope });
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.equal(result.ok, true, scope.kind);
|
||||
assert.equal(result.count, 3, scope.kind);
|
||||
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, committedScope, scopeSource] of [
|
||||
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||
['window without a boundary source', 'window:presented', null],
|
||||
]) {
|
||||
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||
const committedSnapshot = {
|
||||
id: 'flights', enabled: true, feedState: 'stale',
|
||||
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||
};
|
||||
let reads = 0;
|
||||
let lookups = 0;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => { reads++; return FLIGHTS; },
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||
}),
|
||||
resolveRegionRing: async () => {
|
||||
lookups++;
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{
|
||||
beforeCommit: async (result) => {
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
scopeLabel: 'the presented cohort',
|
||||
scopeDetail: 'the presented cohort window',
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||
assert.equal(followUp.coverage.scope, committedScope);
|
||||
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -174,11 +345,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
|
||||
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||
const first = await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const r = await eng.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'GND1');
|
||||
assert.equal(r.coverage.followUp, true);
|
||||
assert.equal(r.scopeLabel, first.scopeLabel);
|
||||
assert.equal(r.display.scope, first.display.scope);
|
||||
assert.equal(r.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
@@ -187,6 +442,184 @@ test('analyst: unresolved region is an honest failure, not empty success', async
|
||||
assert.match(r.error, /Atlantis/);
|
||||
});
|
||||
|
||||
test('analyst: a region lookup timeout is reported as region-timeout', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'region-timeout');
|
||||
assert.match(r.error, /Texas/);
|
||||
assert.equal(r.coverage.scope, 'region:Texas:timeout');
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives and obeys the owning action signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
getViewContext: () => ({
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
viewRadiusKm: 150,
|
||||
}),
|
||||
});
|
||||
const pending = eng.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
for (const [label, reason, trackAuthority] of [
|
||||
['default abort reason', undefined, false],
|
||||
['custom error reason', new Error('operator stopped'), false],
|
||||
['primitive reason and revoked authority', 'operator stopped', true],
|
||||
]) {
|
||||
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||
const started = Promise.withResolvers();
|
||||
const geocode = Promise.withResolvers();
|
||||
const controller = new AbortController();
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||
method,
|
||||
async () => { throw new Error('offline region query reached feature source'); },
|
||||
])),
|
||||
});
|
||||
const placeSearch = {
|
||||
geocode(name, { signal }) {
|
||||
lookups.push(name);
|
||||
assert.equal(signal, controller.signal);
|
||||
started.resolve();
|
||||
return geocode.promise;
|
||||
},
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||
let commits = 0;
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||
beforeCommit: async () => { commits++; },
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
// Let an actual late geocode result cross the resolver's post-await check.
|
||||
geocode.resolve({ place: {
|
||||
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||
} });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(commits, 0);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||
const lookup = Promise.withResolvers();
|
||||
let current = true;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: lookup.promise,
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
lookup.reject(new Error('late lookup failed'));
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||
const controller = new AbortController();
|
||||
const lookup = Promise.withResolvers();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: () => lookup.promise,
|
||||
});
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||
const controller = new AbortController();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
await assert.rejects(
|
||||
engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -195,9 +628,338 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
|
||||
});
|
||||
|
||||
test('analyst: satellites and local infrastructure are queryable layers', async () => {
|
||||
const SATS = [
|
||||
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
|
||||
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
|
||||
];
|
||||
const DAMS = [
|
||||
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
|
||||
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
|
||||
];
|
||||
const DCS = [
|
||||
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
|
||||
];
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
|
||||
const sats = await eng.query({
|
||||
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
|
||||
});
|
||||
assert.equal(sats.ok, true);
|
||||
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
|
||||
assert.equal(sats.items[0].noradId, '25544');
|
||||
assert.ok(Number.isFinite(sats.items[0].distanceKm));
|
||||
|
||||
const nav = await eng.query({
|
||||
layers: ['satellites'], scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
|
||||
});
|
||||
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
|
||||
|
||||
const dams = await eng.query({
|
||||
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
|
||||
});
|
||||
assert.equal(dams.count, 1);
|
||||
assert.equal(dams.items[0].id, 'Austin Dam');
|
||||
assert.equal(dams.items[0].river, 'Colorado');
|
||||
|
||||
const dcs = await eng.query({
|
||||
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
|
||||
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
|
||||
});
|
||||
assert.equal(dcs.count, 1);
|
||||
assert.equal(dcs.items[0].id, 'AUS-1');
|
||||
});
|
||||
|
||||
test('helpers: haversine sanity + scope radius', () => {
|
||||
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
|
||||
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
|
||||
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
|
||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||
});
|
||||
|
||||
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
|
||||
getRecordCoverage: () => ({ total: 3000, truncated: true }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
|
||||
assert.equal(result.count, 1);
|
||||
const layer = result.coverage.layersQueried[0];
|
||||
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
|
||||
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
|
||||
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
|
||||
});
|
||||
|
||||
test('an uncapped cohort is complete and carries no caveat', async () => {
|
||||
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
|
||||
assert.equal(result.display.caveat, undefined);
|
||||
assert.equal(result.display.scope, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
|
||||
const engine = makeEngine();
|
||||
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
|
||||
assert.equal(field.ok, false);
|
||||
assert.equal(field.code, 'UNKNOWN_FIELD');
|
||||
assert.ok(field.allowed.includes('altitudeM'));
|
||||
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
|
||||
assert.equal(op.code, 'BAD_OPERATOR');
|
||||
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
|
||||
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
|
||||
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
|
||||
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
});
|
||||
|
||||
test('a typed boolean filter still matches exactly', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
|
||||
});
|
||||
|
||||
test('distance honours sortDir: desc is farthest first', async () => {
|
||||
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
|
||||
assert.equal(far.items[0].id, 'N123');
|
||||
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
|
||||
assert.equal(near.items[0].id, 'SWA1');
|
||||
});
|
||||
|
||||
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
|
||||
const r = await makeEngine().query({ followUp: true, filters: [] });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NO_RESULT_CONTEXT');
|
||||
});
|
||||
|
||||
test('follow-ups never rewrite the remembered rows', async () => {
|
||||
const engine = makeEngine();
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
await engine.query({ followUp: true, sortBy: 'distance' });
|
||||
const again = await engine.query({ followUp: true });
|
||||
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
|
||||
assert.ok(again.items.every((item) => item.distanceKm === undefined));
|
||||
});
|
||||
|
||||
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
|
||||
let releaseSlow;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
|
||||
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
let generation = 0;
|
||||
const slowGeneration = ++generation;
|
||||
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
|
||||
const newGeneration = ++generation;
|
||||
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
|
||||
assert.equal(fresh.ok, true);
|
||||
releaseSlow();
|
||||
const stale = await slow;
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
|
||||
});
|
||||
|
||||
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
|
||||
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getLayerSnapshot: () => snapshot,
|
||||
isWarming: () => warming,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
|
||||
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
|
||||
assert.equal(zero.ok, true);
|
||||
assert.equal(zero.count, 0);
|
||||
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
|
||||
});
|
||||
|
||||
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
|
||||
const now = Date.parse('2026-09-23T12:00:00Z');
|
||||
const engine = createAnalystEngine({
|
||||
now: () => now,
|
||||
getRecords: () => [
|
||||
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
|
||||
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
|
||||
],
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
|
||||
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
|
||||
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
|
||||
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
|
||||
assert.equal(age.items[0].ageHours, 2);
|
||||
assert.equal(age.display.window, 'last 24 h');
|
||||
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
||||
});
|
||||
|
||||
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
||||
let state = 'stale';
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
|
||||
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const first = await engine.query();
|
||||
state = 'nominal';
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(first.coverage.feedProvenance.overall, 'stale');
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
engine.reset();
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
|
||||
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
assert.equal(result.count, 200_000);
|
||||
assert.equal(result.items[0].frp, 199_999);
|
||||
assert.equal(result.summary.frpMin, 0);
|
||||
});
|
||||
|
||||
test('an enabled but empty layer says why on screen', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [],
|
||||
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
|
||||
getEmptyNote: () => 'Fly below 400 km to a covered region',
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['transit'] });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 0);
|
||||
assert.equal(result.coverage.layersQueried[0].status, 'empty');
|
||||
assert.match(result.display.caveat, /transit: Fly below 400 km/);
|
||||
});
|
||||
|
||||
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
|
||||
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'BAD_SCOPE');
|
||||
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
|
||||
assert.equal(ok.ok, true);
|
||||
});
|
||||
|
||||
test('a field copied with its hinted unit still resolves', async () => {
|
||||
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
|
||||
});
|
||||
|
||||
test('a written unit converts to the field unit or is refused', async () => {
|
||||
const engine = makeEngine();
|
||||
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
|
||||
assert.equal(feet.ok, true);
|
||||
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
|
||||
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
|
||||
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
|
||||
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
|
||||
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
|
||||
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
|
||||
assert.equal(wrong.code, 'BAD_UNIT');
|
||||
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
|
||||
assert.equal(unitless.code, 'BAD_UNIT');
|
||||
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
|
||||
assert.equal(hinted.ok, true);
|
||||
});
|
||||
|
||||
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
|
||||
const engine = makeEngine();
|
||||
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
|
||||
assert.equal(text.ok, true);
|
||||
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
|
||||
for (const value of ['', ' ', true, 'high', '12k'])
|
||||
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
|
||||
});
|
||||
|
||||
test('any supplied centre must be a real coordinate', async () => {
|
||||
const engine = makeEngine();
|
||||
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
|
||||
});
|
||||
|
||||
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
|
||||
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
|
||||
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
|
||||
assert.equal(none.ok, false);
|
||||
assert.equal(none.code, 'NOT_READY');
|
||||
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
|
||||
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
|
||||
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
|
||||
assert.equal(down.code, 'FEED_UNAVAILABLE');
|
||||
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
|
||||
assert.equal(partial.ok, true);
|
||||
assert.equal(partial.partial, true);
|
||||
assert.deepEqual(partial.unanswered, ['earthquakes']);
|
||||
assert.match(partial.display.caveat, /earthquakes error/);
|
||||
});
|
||||
|
||||
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
|
||||
const engine = makeEngine();
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
|
||||
for (const item of first.items) {
|
||||
item.altitudeM = -1;
|
||||
item.id = 'MUTATED';
|
||||
}
|
||||
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
|
||||
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
|
||||
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
|
||||
});
|
||||
|
||||
test('ranking 250k rows stays within a budget and yields to the page', async () => {
|
||||
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const started = performance.now();
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
const elapsed = performance.now() - started;
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 250_000);
|
||||
assert.equal(result.items[0].frp, 100_002);
|
||||
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
|
||||
assert.ok(ticks > 0, 'the scan yielded between slices');
|
||||
let current = true;
|
||||
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
|
||||
current = false;
|
||||
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
|
||||
});
|
||||
|
||||
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
const r = await engine.query({ layers: ['local-firms'], followUp: true });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
|
||||
* short way round, so points either side of 180° stay close.
|
||||
*/
|
||||
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
let dLonDeg = lon2 - lon1;
|
||||
dLonDeg -= 360 * Math.round(dLonDeg / 360);
|
||||
const dLon =
|
||||
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,419 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
placeCard,
|
||||
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.split(' ').includes('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();
|
||||
});
|
||||
|
||||
test('a media card stays between the panels when right-to-left swaps their sides', () => {
|
||||
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
|
||||
// Arabic: the scene panel sits on the right, the context rail on the left.
|
||||
const panels = {
|
||||
'#scene-panel': rect(1040, 1420),
|
||||
'#right-context-rail': rect(20, 340),
|
||||
'#command-dock': rect(500, 940, 820, 880),
|
||||
};
|
||||
const card = {
|
||||
offsetWidth: 392,
|
||||
offsetHeight: 360,
|
||||
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
|
||||
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
|
||||
classList: { contains: () => false },
|
||||
style: {},
|
||||
};
|
||||
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
|
||||
const left = parseFloat(card.style.left);
|
||||
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
|
||||
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,120 @@
|
||||
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
|
||||
// a long translation shrinks to fit the portrait card, action word whole.
|
||||
// English is drawn at full size.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// JetBrains Mono advances every glyph by 0.6 em.
|
||||
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
|
||||
const measure = (text, font) => text.length * 0.6 * fontPx(font);
|
||||
|
||||
function drawSourceLine(observation, publisher) {
|
||||
const layout = evidenceCardLayout(observation);
|
||||
const drawn = [];
|
||||
const context = {
|
||||
font: '',
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
measureText(text) {
|
||||
return { width: measure(String(text), this.font) };
|
||||
},
|
||||
fillText(value, x) {
|
||||
drawn.push({ text: String(value), font: this.font, x });
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
width: layout.canvasWidth,
|
||||
height: layout.canvasHeight,
|
||||
_bhoteEvidenceLayout: layout,
|
||||
getContext: () => context,
|
||||
};
|
||||
const subject = publisher
|
||||
? { ...observation, media: { ...observation.media, publisher } }
|
||||
: observation;
|
||||
assert.equal(updateEvidenceFrame(canvas, subject), true);
|
||||
const line = drawn.find((entry) => entry.text.includes(' / '));
|
||||
assert.ok(line, 'source line drawn');
|
||||
return { ...line, maxWidth: layout.canvasWidth - 32 };
|
||||
}
|
||||
|
||||
async function portraitObservations() {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const portrait = event.evidenceSpine.filter(
|
||||
(o) => evidenceCardLayout(o).kind === 'portrait',
|
||||
);
|
||||
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
|
||||
const facebook = portrait.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube, 'a portrait YouTube card exists');
|
||||
return [youtube, facebook].filter(Boolean);
|
||||
}
|
||||
|
||||
test('English source lines draw unchanged on every card', async () => {
|
||||
setLocale('en');
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
for (const observation of event.evidenceSpine) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
|
||||
assert.match(
|
||||
line.text,
|
||||
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ru/es source lines fit the portrait card with the action word whole', async () => {
|
||||
const observations = await portraitObservations();
|
||||
try {
|
||||
for (const [locale, action] of [
|
||||
['ru', '/ ОТКРЫТЬ'],
|
||||
['es', '/ ABRIR'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
for (const observation of observations) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
|
||||
assert.ok(
|
||||
measure(line.text, line.font) <= line.maxWidth,
|
||||
`${locale}: «${line.text}» at ${line.font} overflows`,
|
||||
);
|
||||
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
|
||||
}
|
||||
}
|
||||
setLocale('ru');
|
||||
const ru = drawSourceLine(observations[0]);
|
||||
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
|
||||
const [observation] = await portraitObservations();
|
||||
try {
|
||||
setLocale('ru');
|
||||
const line = drawSourceLine(
|
||||
observation,
|
||||
'A very long regional broadcaster',
|
||||
);
|
||||
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
|
||||
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
|
||||
assert.equal(fontPx(line.font), 21 * 0.8);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
// The publisher on the evidence frame ("YouTube source") is pack text shown
|
||||
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function captureFrameText(observation) {
|
||||
const originalDocument = globalThis.document;
|
||||
const text = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const context = {
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
fillText(value) {
|
||||
text.push(String(value));
|
||||
},
|
||||
drawImage() {},
|
||||
};
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
},
|
||||
};
|
||||
try {
|
||||
assert.ok(createEvidenceFrame(observation));
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test('evidence card source line is fully in the UI language (es, ru)', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
const facebook = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube && facebook);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const observation of [youtube, facebook]) {
|
||||
const lines = captureFrameText(observation);
|
||||
const sourceLine = lines.find((line) => line.includes(' / '));
|
||||
assert.ok(sourceLine, 'source line drawn');
|
||||
assert.doesNotMatch(
|
||||
sourceLine,
|
||||
/SOURCE/,
|
||||
`${locale}: English "SOURCE" left in "${sourceLine}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence card source line keeps its English wording', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
setLocale('en');
|
||||
const sourceLine = captureFrameText(youtube).find((line) =>
|
||||
line.includes(' / '),
|
||||
);
|
||||
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
|
||||
});
|
||||
@@ -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]),
|
||||
]);
|
||||
@@ -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
@@ -0,0 +1,965 @@
|
||||
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 { setLocale } from '../i18n/index.js';
|
||||
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() {}, removeStaticCredit() {} },
|
||||
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 overview pins translate their descriptive titles and keep place names', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => 0,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const titles = new Map(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.map(({ id, title }) => [id, title]),
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
|
||||
'01 · ВИД НА ОБРУШЕНИЕ',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-gyirong-border-gate'),
|
||||
'04 · ПОГРАНПЕРЕХОД',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
|
||||
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
|
||||
'14 · МОСТ BIDUR',
|
||||
);
|
||||
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
|
||||
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,
|
||||
_bumpSceneSeekGeneration:
|
||||
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||
_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);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
|
||||
// Koshi reconstruction panel the event layer presents and sends its clicks
|
||||
// and slider moves back.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import ContextView from '../ui/views/ContextView.vue';
|
||||
|
||||
const record = (initial = {}) => ({
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
attributes: {},
|
||||
...initial,
|
||||
});
|
||||
|
||||
function presentation(patch = {}) {
|
||||
return {
|
||||
owner: {},
|
||||
sceneBeat: false,
|
||||
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
|
||||
afterDate: { name: 'Post-event', date: '' },
|
||||
reports: [
|
||||
{ label: 'Report A', status: 'Verified' },
|
||||
{ label: 'Report B', status: 'Unverified' },
|
||||
],
|
||||
refs: {
|
||||
imagerySection: record(),
|
||||
observedStatus: record(),
|
||||
time: record({ textContent: '~0:00 / 2:00' }),
|
||||
caveat: record({ textContent: 'Schematic only.' }),
|
||||
imageryCloudNote: record(),
|
||||
playButton: record({ textContent: '▶ PLAY' }),
|
||||
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
|
||||
cinematicButton: record({
|
||||
textContent: '◉ CINEMATIC',
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
|
||||
corridorButton: record(),
|
||||
previousButton: record({
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Previous story beat' },
|
||||
}),
|
||||
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
|
||||
openSourceButton: record(),
|
||||
progressInput: record({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': 'Schematic downstream progression' },
|
||||
}),
|
||||
splitInput: record({ value: '50' }),
|
||||
splitReadout: record({ textContent: '50 / 50' }),
|
||||
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
|
||||
beatIndex: record({ textContent: '01 / 06' }),
|
||||
beatTitle: record({ textContent: 'CAUSE' }),
|
||||
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
|
||||
},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ContextView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const store = useBhoteKoshiPanelStore(pinia);
|
||||
const calls = [];
|
||||
store.connect(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
),
|
||||
);
|
||||
const rail = document.getElementById('right-context-rail');
|
||||
const $ = (selector) => rail.querySelector(selector);
|
||||
return { store, calls, rail, $ };
|
||||
}
|
||||
|
||||
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
|
||||
const { store, rail, $ } = fixture(t);
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const panel = $('#bhote-koshi-event-panel');
|
||||
assert.equal(panel.tagName, 'ASIDE');
|
||||
assert.ok(rail.lastElementChild === panel);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
assert.ok(rail.classList.contains('bhote-event-active'));
|
||||
assert.equal(
|
||||
panel.getAttribute('aria-label'),
|
||||
'Bhote Koshi flood reconstruction controls',
|
||||
);
|
||||
assert.match(
|
||||
panel.textContent,
|
||||
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
|
||||
);
|
||||
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"]').textContent,
|
||||
'Historical reference · 2021-05-01',
|
||||
);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"] .bhote-event-date').textContent,
|
||||
'2021-05-01',
|
||||
);
|
||||
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
|
||||
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
|
||||
assert.equal($('[data-action="play-scene"]').hidden, true);
|
||||
assert.ok($('[data-action="open-source"]'));
|
||||
assert.equal($('[data-action="witness"]'), null);
|
||||
assert.doesNotMatch(panel.innerHTML, /witness/i);
|
||||
assert.equal($('[data-action="previous-beat"]').disabled, true);
|
||||
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
|
||||
assert.equal(
|
||||
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
|
||||
assert.equal($('[data-role="progress"]').value, '0');
|
||||
assert.equal($('[data-role="split"]').value, '50');
|
||||
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
|
||||
assert.deepEqual(
|
||||
[...$('.bhote-event-report-list').children].map(
|
||||
(button) => button.textContent,
|
||||
),
|
||||
['Report AVerified', 'Report BUnverified'],
|
||||
);
|
||||
store.publish(null);
|
||||
await nextTick();
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
});
|
||||
|
||||
test('a later presentation updates the controls in place', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const play = $('[data-action="play"]');
|
||||
const next = presentation({ sceneBeat: true });
|
||||
next.refs.playButton = record({
|
||||
textContent: '▶ PLAY SHOT',
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Play Global context' },
|
||||
});
|
||||
next.refs.progressInput = record({ value: '250', hidden: true });
|
||||
next.refs.imagerySection = record({ hidden: true });
|
||||
store.publish(next);
|
||||
await nextTick();
|
||||
assert.ok($('[data-action="play"]') === play);
|
||||
assert.equal(play.textContent, '▶ PLAY SHOT');
|
||||
assert.equal(play.disabled, true);
|
||||
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
|
||||
assert.ok(
|
||||
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
|
||||
);
|
||||
assert.equal($('[data-role="progress"]').value, '250');
|
||||
assert.equal($('[data-role="progress"]').hidden, true);
|
||||
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
|
||||
});
|
||||
|
||||
test('buttons, sliders and reports call the connected controls', async (t) => {
|
||||
const { store, calls, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
for (const action of [
|
||||
'close',
|
||||
'play',
|
||||
'cinematic',
|
||||
'story-replay',
|
||||
'open-source',
|
||||
'corridor',
|
||||
'next-beat',
|
||||
'geolocation-map',
|
||||
])
|
||||
$(`[data-action="${action}"]`).click();
|
||||
$('.bhote-event-report-list').children[1].click();
|
||||
const progress = $('[data-role="progress"]');
|
||||
progress.value = '500';
|
||||
progress.dispatchEvent(new Event('input'));
|
||||
progress.dispatchEvent(new Event('change'));
|
||||
const split = $('[data-role="split"]');
|
||||
split.value = '25';
|
||||
split.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [
|
||||
['close'],
|
||||
['play'],
|
||||
['toggleCinematic'],
|
||||
['replayCinematic'],
|
||||
['openActiveSource'],
|
||||
['focusCorridor'],
|
||||
['nextBeat'],
|
||||
['openGeolocationMap'],
|
||||
['openReport', 1],
|
||||
['previewProgress', 0.5],
|
||||
['commitProgress', 0.5, { final: true }],
|
||||
['setSplit', 0.25],
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
+23
-1674
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file Load a JSON file shipped beside the source modules.
|
||||
*
|
||||
* The browser fetches the file from `url` — a `new URL('./….json',
|
||||
* import.meta.url)` written in the calling module, which Vite serves as-is in
|
||||
* dev and emits as an asset in the production build. Under Node (node:test)
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
|
||||
* from disk instead.
|
||||
*
|
||||
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
|
||||
* serve the browser (the Vite dev server answers it with a JavaScript module,
|
||||
* which the browser rejects), and a literal `import('./….json')` makes the
|
||||
* production build emit a JavaScript copy of every pack that the browser
|
||||
* never loads.
|
||||
*
|
||||
* @module data/bundledJson
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (url.protocol === 'file:') {
|
||||
// Node's built-in, reached without an import, so the browser build never
|
||||
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
|
||||
// read is synchronous so the result settles as promptly as the cached
|
||||
// module import it replaces; callers already memoize each pack.
|
||||
const getBuiltinModule = globalThis.process?.getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== 'function')
|
||||
throw new Error(
|
||||
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
|
||||
);
|
||||
const { readFileSync } = getBuiltinModule('node:fs');
|
||||
return JSON.parse(readFileSync(url, 'utf8'));
|
||||
}
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
|
||||
const MARINE = new URL(
|
||||
'./local_data/natural_earth/marine.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch');
|
||||
const pack = await loadBundledJson(MARINE);
|
||||
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requests.push(String(url));
|
||||
return new Response('{"features":[]}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const url = new URL(
|
||||
'http://localhost/src/data/local_data/natural_earth/marine.json',
|
||||
);
|
||||
assert.deepEqual(await loadBundledJson(url), { features: [] });
|
||||
assert.deepEqual(requests, [url.href]);
|
||||
});
|
||||
|
||||
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 404 }),
|
||||
);
|
||||
await assert.rejects(
|
||||
loadBundledJson(new URL('http://localhost/missing.json')),
|
||||
/HTTP 404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bundled JSON: an aborted load rejects before reading', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('bundled JSON: no data pack is imported as a module', async () => {
|
||||
// A literal JSON import in app code makes the production build emit a
|
||||
// JavaScript copy of the pack beside the fetched JSON, which the browser
|
||||
// never loads. Tests are not bundled, so they may still import JSON.
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
[
|
||||
'grep',
|
||||
'-nE',
|
||||
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
|
||||
'--',
|
||||
'src',
|
||||
':!*.test.mjs',
|
||||
],
|
||||
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
||||
);
|
||||
// git grep exits 1 when nothing matches.
|
||||
assert.equal(result.status, 1, result.stdout);
|
||||
});
|
||||
|
||||
test('bundled JSON: an older Node says which version is needed', async (t) => {
|
||||
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
|
||||
const original = process.getBuiltinModule;
|
||||
t.after(() => {
|
||||
process.getBuiltinModule = original;
|
||||
});
|
||||
process.getBuiltinModule = undefined;
|
||||
await assert.rejects(loadBundledJson(MARINE), {
|
||||
message: /needs Node 24\.14 or newer to read marine\.json/,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Arrival corrections belong to one viewer and yield to its next camera owner. */
|
||||
const arrivals = new WeakMap();
|
||||
|
||||
/** Cancel an outstanding arrival correction before navigation or teardown. */
|
||||
export function cancelCameraArrival(viewer) {
|
||||
if (viewer) arrivals.get(viewer)?.();
|
||||
}
|
||||
|
||||
/** Register a correction and return an idempotent ownership release. */
|
||||
export function ownCameraArrival(viewer, cancel) {
|
||||
cancelCameraArrival(viewer);
|
||||
arrivals.set(viewer, cancel);
|
||||
return () => {
|
||||
if (arrivals.get(viewer) === cancel) arrivals.delete(viewer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Camera range with breathing room in both viewport dimensions. */
|
||||
export function rangeForBoundingSphere(viewer, radius) {
|
||||
const frustum = viewer.camera?.frustum;
|
||||
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
|
||||
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
|
||||
const limitingFov = Math.min(verticalFov, horizontalFov);
|
||||
// Target 57% sphere occupancy so perspective still leaves at least 40%
|
||||
// measured roof-to-base breathing room in ordinary oblique building views.
|
||||
const occupiedViewportFraction = 0.57;
|
||||
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
|
||||
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
|
||||
}
|
||||
@@ -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()] : [];
|
||||
}
|
||||
+77
-4823
File diff suppressed because it is too large.
Load diff
+721
-194
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = 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 === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
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,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
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'],
|
||||
);
|
||||
assert.equal(sources[0].pack, 'env');
|
||||
const [trimmed] = getSources.trimmedPacks();
|
||||
assert.deepEqual(
|
||||
{ ...trimmed, region: undefined },
|
||||
{ pack: 'env', available: 12, served: 9, region: undefined },
|
||||
);
|
||||
assert.equal(trimmed.region.south, 30);
|
||||
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
|
||||
const { prioritizeSources } =
|
||||
await import('../../server/providers/cctv/normalize.js');
|
||||
const cameras = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c${i}`,
|
||||
lat: i,
|
||||
lon: 0,
|
||||
}));
|
||||
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
|
||||
assert.deepEqual(
|
||||
kept.map((camera) => camera.id),
|
||||
['c0', 'c1'],
|
||||
);
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
|
||||
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
|
||||
const envSources = (prefix, count = 3) =>
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
name: `${prefix} ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Let a background refresh that needs no timers run to completion. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||
// the refresh open.
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const hung = [];
|
||||
let hang = false;
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
hang
|
||||
? new Promise((_, reject) => {
|
||||
hung.push(() => reject(new Error('upstream gave up')));
|
||||
})
|
||||
: Promise.reject(new Error('offline')),
|
||||
);
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Past the TTL, with every live pack's upstream hanging.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
hang = true;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
let timer;
|
||||
const served = await Promise.race([
|
||||
getSources(),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('waited'), 200);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
assert.notEqual(
|
||||
served,
|
||||
'waited',
|
||||
'a stale request waited for the background refresh',
|
||||
);
|
||||
assert.deepEqual(
|
||||
served.map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Requests during the refresh share it rather than starting another.
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await pause();
|
||||
const inFlight = hung.length;
|
||||
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||
await getSources();
|
||||
await getSources();
|
||||
await pause();
|
||||
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||
|
||||
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||
hang = false;
|
||||
for (const giveUp of hung) giveUp();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const ids = (await getSources()).map((s) => s.id);
|
||||
if (ids[0] === 'new-0') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
|
||||
process.env.CCTV_MAX_SOURCES = '8';
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
// Nine cameras over a cap of eight make every refresh warn; throwing from
|
||||
// that warning is how this test makes a refresh reject.
|
||||
let failRefresh = false;
|
||||
let failedRefreshes = 0;
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => {
|
||||
const text = args.join(' ');
|
||||
if (failRefresh && text.includes('exceeds cap')) {
|
||||
failedRefreshes += 1;
|
||||
throw new Error('refresh broke');
|
||||
}
|
||||
warnings.push(text);
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
assert.equal(old.length, 8);
|
||||
|
||||
// Past the TTL the refresh throws: the old catalog is still served.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = true;
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1);
|
||||
assert.equal(
|
||||
warnings.filter((w) => w.includes('background catalog refresh failed'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// The failure is not retried on every request until the next TTL.
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
|
||||
|
||||
// After it, a refresh that works replaces the catalog. With no live packs
|
||||
// the refresh finishes before the request that started it returns.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = false;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
await getSources();
|
||||
await settle();
|
||||
const fresh = await getSources();
|
||||
assert.deepEqual(
|
||||
fresh.map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
// Trim notes stay with the list they describe, so a request served the
|
||||
// old list during the refresh never reports the new list's notes.
|
||||
assert.deepEqual(
|
||||
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
|
||||
[['env', 9, 8]],
|
||||
);
|
||||
assert.deepEqual(getSources.trimmedPacks(fresh), []);
|
||||
assert.deepEqual(getSources.trimmedPacks(), []);
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('offline'));
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
|
||||
// Past the TTL every pack comes back empty, as in an upstream outage.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
process.env.CCTV_SOURCES_JSON = '[]';
|
||||
assert.equal(await getSources(), old);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (warnings.some((w) => w.includes('source refresh returned empty')))
|
||||
break;
|
||||
await settle();
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes('serving 3 stale cameras')),
|
||||
'the empty refresh kept the old catalog',
|
||||
);
|
||||
|
||||
// Until the next TTL, requests are served without touching any upstream.
|
||||
const fetchesAfterRefresh = fetches;
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(
|
||||
fetches,
|
||||
fetchesAfterRefresh,
|
||||
'an empty refresh reran at once',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,683 @@
|
||||
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 {
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
parseCataloniaXml,
|
||||
normalizeCataloniaImageUrl,
|
||||
cataloniaCameraName,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
} 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 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('<?xml'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One `<gml:featureMember>` block matching the real feed's shape. */
|
||||
function cataloniaFeature({
|
||||
fid,
|
||||
lon,
|
||||
lat,
|
||||
carretera = '',
|
||||
municipi = '',
|
||||
pk = '',
|
||||
link = '',
|
||||
font = '',
|
||||
}) {
|
||||
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
|
||||
return (
|
||||
`<gml:featureMember><cite:cameres fid="${fid}">` +
|
||||
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
|
||||
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
|
||||
`</gml:Point></cite:geom>` +
|
||||
`<cite:carretera>${carretera}</cite:carretera>` +
|
||||
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
|
||||
`<cite:link>${link}</cite:link>` +
|
||||
`<cite:font>${font}</cite:font>` +
|
||||
`</cite:cameres></gml:featureMember>`
|
||||
);
|
||||
}
|
||||
|
||||
function cataloniaFeed(features) {
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
|
||||
features.join('') +
|
||||
'</wfs:FeatureCollection>'
|
||||
);
|
||||
}
|
||||
|
||||
test('parseCataloniaXml extracts every field from a feature block', () => {
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
const rows = parseCataloniaXml(xml);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: 2.1849528,
|
||||
lat: 41.45989301,
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
|
||||
// RenderService always 302s to a hardcoded http:// Location (which
|
||||
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
|
||||
// setup fails the handshake with Node's fetch regardless — TransitCamera
|
||||
// over plain http is the only transport that actually works here, so we
|
||||
// build that URL from RenderService's sctidcam id instead of registering
|
||||
// the redirector.
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
|
||||
'an already-https RenderService link is rewritten the same way',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
|
||||
'',
|
||||
'a RenderService link with no sctidcam id is rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
|
||||
'https://www.bcn.cat/transit/imatges/a.gif',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
),
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
|
||||
'',
|
||||
'a look-alike host is rejected',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
|
||||
'',
|
||||
'any host outside the allowlist is rejected',
|
||||
);
|
||||
assert.equal(normalizeCataloniaImageUrl(''), '');
|
||||
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
|
||||
// The hostname matches the allowlist, so only an explicit userinfo check
|
||||
// stops catalog data injecting credentials into the frame request.
|
||||
for (const link of [
|
||||
'https://user:[email protected]/transit/imatges/a.gif',
|
||||
'http://[email protected]/transit/imatges/a.gif',
|
||||
'https://:[email protected]/it_terrassa/cam02.jpeg',
|
||||
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
|
||||
// An allowlisted hostname alone would let catalog data aim the frame
|
||||
// request at another service or path on that host.
|
||||
for (const link of [
|
||||
'http://www.bcn.cat:8080/transit/imatges/a.gif',
|
||||
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
|
||||
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
'http://www.bcn.cat/admin/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/',
|
||||
'http://www.bcn.cat/transit/imatges/sub/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
|
||||
'https://emap.terrassa.cat/other/cam02.jpeg',
|
||||
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
|
||||
for (const camId of [
|
||||
'../../etc/passwd',
|
||||
'..%2F..%2Fetc%2Fpasswd',
|
||||
'nc87.png',
|
||||
'nc87',
|
||||
'nc87.gif/../x.gif',
|
||||
'nc87.gif&nom=other.gif',
|
||||
'nc-87.gif',
|
||||
]) {
|
||||
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
|
||||
);
|
||||
});
|
||||
|
||||
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦');
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
for (const raw of [
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
]) {
|
||||
assert.equal(decodeNumericEntities(raw), raw, raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
|
||||
assert.equal(
|
||||
cataloniaCameraName('C-58', 'Nus Trinitat'),
|
||||
'C-58 (Nus Trinitat)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
|
||||
'Plaça Urquinaona (Barcelona)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
|
||||
'Barcelona Ronda Litoral',
|
||||
);
|
||||
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
|
||||
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
|
||||
});
|
||||
|
||||
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-2',
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-3',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
carretera: 'Cam02',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
// Rejected: link host not in the allowlist.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-4',
|
||||
lon: '2.0',
|
||||
lat: '41.5',
|
||||
link: 'https://example.com/fake.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
// Rejected: outside the Catalonia bounding box.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-5',
|
||||
lon: '-3.7',
|
||||
lat: '40.4',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadCataloniaSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.ok(
|
||||
CATALONIA_CAMERAS_URL.startsWith('https://'),
|
||||
'the catalog is fetched over https',
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).sort(),
|
||||
[
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
],
|
||||
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
|
||||
);
|
||||
|
||||
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
|
||||
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
|
||||
assert.equal(sct.provider, 'Servei Català de Trànsit');
|
||||
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
|
||||
assert.equal(
|
||||
sct.url,
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
|
||||
);
|
||||
assert.equal(sct.groundElevationM, 80);
|
||||
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
|
||||
|
||||
const bcn = cameras.find(
|
||||
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
);
|
||||
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
|
||||
assert.equal(bcn.city, 'Barcelona');
|
||||
assert.equal(bcn.groundElevationM, 20);
|
||||
|
||||
const terrassa = cameras.find(
|
||||
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
);
|
||||
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
|
||||
assert.equal(terrassa.groundElevationM, 270);
|
||||
});
|
||||
|
||||
test('Catalonia loader fails soft on HTTP errors, empty 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 () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Catalonia 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 or
|
||||
// downgraded. 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: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// An HTTP error body is released too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const validFeed = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
|
||||
// A body that declares a size over the cap is refused rather than buffered,
|
||||
// even though its actual content would parse.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(validFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body with no declared size that only turns out too long while
|
||||
// streaming — a valid feed padded past the cap.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized =
|
||||
validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
|
||||
);
|
||||
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_CATALONIA_ENABLED: undefined,
|
||||
CCTV_CATALONIA_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== CATALONIA_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, { status: 200 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['cat-sct-nc87'],
|
||||
);
|
||||
|
||||
process.env.CCTV_CATALONIA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
|
||||
* under one upstream run's `fid` prefix. */
|
||||
function snapshotFeed(fidPrefix) {
|
||||
return cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_386f`,
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_b48`,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c2`,
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c3`,
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFromFeed(t, xml) {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await loadCataloniaSourcesFromOpenData();
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
}
|
||||
|
||||
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The fid embeds the upstream server-start stamp: the PR fixture's
|
||||
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
|
||||
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
|
||||
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
|
||||
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
|
||||
assert.deepEqual(ids(before), [
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
|
||||
'cat-sct-nc85',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
]);
|
||||
assert.deepEqual(ids(after), ids(before));
|
||||
});
|
||||
|
||||
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
|
||||
// different fids.
|
||||
const row = (fid, camId) =>
|
||||
cataloniaFeature({
|
||||
fid,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
|
||||
font: 'SCT',
|
||||
});
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
row('cameres.fid-x_1', 'nc85.gif'),
|
||||
row('cameres.fid-x_2', 'cb5.gif'),
|
||||
row('cameres.fid-x_3', 'nc85.gif'),
|
||||
row('cameres.fid-x_4', 'cb5.gif'),
|
||||
row('cameres.fid-x_5', 'cb10.gif'),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'cat-sct-cb10',
|
||||
'cat-sct-cb5',
|
||||
'cat-sct-nc85',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.17',
|
||||
lat: '41.39',
|
||||
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_3',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_4',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => [camera.id, camera.url]),
|
||||
[
|
||||
[
|
||||
'cat-sct-nc87',
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31 �',
|
||||
municipi: 'Bad�',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'Plaça Catalunya',
|
||||
municipi: 'Llinars del Vallès',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(cameras.length, 2);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc85').name,
|
||||
'C-31 � (Bad�)',
|
||||
'an invalid reference is kept as written',
|
||||
);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc87').name,
|
||||
'Plaça Catalunya (Llinars del Vallès)',
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
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,
|
||||
DEFAULT_CATALONIA_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 caps of the original packs 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 +
|
||||
DEFAULT_CATALONIA_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), '');
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_LOCATIONS_URL,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTarkteeSourcesFromDatex,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||
quiet(t);
|
||||
const dupLocationsXml = `<?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="loc-1" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
<predefinedLocation id="loc-2" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init?.redirect]);
|
||||
const body =
|
||||
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(seen, [
|
||||
[TARKTEE_LOCATIONS_URL, 'manual'],
|
||||
[TARKTEE_IMAGES_URL, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'ee-tarktee-42');
|
||||
assert.equal(cameras[0].name, 'Tallinn Ringtee');
|
||||
});
|
||||
|
||||
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = { locations: false, images: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
if (isLocations) cancelled.locations = true;
|
||||
else cancelled.images = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: isLocations ? 302 : 200,
|
||||
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
|
||||
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
|
||||
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const delayedSibling of [false, true]) {
|
||||
await t.test(
|
||||
`${failedUrl}, delayed sibling: ${delayedSibling}`,
|
||||
async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === failedUrl) throw new TypeError('fetch failed');
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
return response;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
cancelled,
|
||||
true,
|
||||
'fulfilled sibling body is cancelled',
|
||||
);
|
||||
} finally {
|
||||
await response.body.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
let siblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
siblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
siblingCancelled,
|
||||
true,
|
||||
'sibling 200 stream is cancelled on 503',
|
||||
);
|
||||
|
||||
let overflowSiblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
const padded =
|
||||
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
|
||||
return new Response(padded, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(padded.length) },
|
||||
});
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
overflowSiblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
overflowSiblingCancelled,
|
||||
true,
|
||||
'sibling stream is cancelled when the other feed exceeds the byte cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
floridaCameraId,
|
||||
floridaCameraToSource,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyFloridaCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
|
||||
|
||||
/** A response with a live body, plus a flag that flips when it is cancelled. */
|
||||
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 FL511 feature, shaped like the live FeatureServer payload. */
|
||||
const feature = (overrides = {}) => ({
|
||||
attributes: {
|
||||
OBJECTID_1: 6,
|
||||
ID: '1220',
|
||||
DESCRIPT: 'I-75 @ MM 346.5 SB',
|
||||
COUNTY: 'Marion',
|
||||
HIGHWAY: 'I-75',
|
||||
DIRECTION: 'S',
|
||||
LATITUDE: 29.107363,
|
||||
LONGITUDE: -82.188334,
|
||||
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
/** One catalog page of `count` distinct cameras, channels from `start`. */
|
||||
const page = (start, count, extra = {}) => ({
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
|
||||
),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const offsetOf = (url) =>
|
||||
Number(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
|
||||
/** Lift the pack cap for one test so every loaded camera comes back. */
|
||||
const uncapped = (t) => {
|
||||
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
|
||||
});
|
||||
};
|
||||
|
||||
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
|
||||
const source = floridaCameraToSource(feature());
|
||||
assert.equal(source.id, 'fl-9431');
|
||||
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
|
||||
assert.equal(source.city, 'Marion County');
|
||||
assert.equal(source.cityId, 'florida');
|
||||
assert.equal(source.provider, 'FL511');
|
||||
assert.equal(source.lat, 29.107363);
|
||||
assert.equal(source.lon, -82.188334);
|
||||
assert.equal(source.headingDeg, 180);
|
||||
assert.equal(
|
||||
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
|
||||
90,
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.equal(source.sourceKind, 'fl511-open-data');
|
||||
assert.match(source.license, /personal use only/);
|
||||
assert.match(source.license, /written consent/);
|
||||
});
|
||||
|
||||
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
|
||||
const source = floridaCameraToSource(
|
||||
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
|
||||
});
|
||||
|
||||
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
|
||||
const dropped = [
|
||||
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
|
||||
feature({ LATITUDE: 0, LONGITUDE: 0 }),
|
||||
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
|
||||
feature({ LATITUDE: '', LONGITUDE: '' }),
|
||||
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
|
||||
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
|
||||
feature({
|
||||
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
|
||||
}),
|
||||
feature({ IMAGE: 'not a url' }),
|
||||
{ attributes: null },
|
||||
null,
|
||||
];
|
||||
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
|
||||
});
|
||||
|
||||
test('camera ids come from the image channel, not the repeating ID column', () => {
|
||||
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
|
||||
const marion = floridaCameraToSource(feature());
|
||||
const broward = floridaCameraToSource(
|
||||
feature({
|
||||
DESCRIPT: 'I-95 SB before E Cypress Rd',
|
||||
COUNTY: 'Broward',
|
||||
LATITUDE: 26.22006,
|
||||
LONGITUDE: -80.13676,
|
||||
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(marion.id, 'fl-9431');
|
||||
assert.equal(broward.id, 'fl-6351');
|
||||
// Any resolution suffix keys the same way.
|
||||
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
|
||||
// A filename without a channel falls back to a stable path slug.
|
||||
assert.equal(
|
||||
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
|
||||
'fl-us-1-20at-20sr-5',
|
||||
);
|
||||
assert.equal(floridaCameraId('not a url'), null);
|
||||
});
|
||||
|
||||
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
|
||||
const inside = [
|
||||
[24.5551, -81.78], // Key West
|
||||
[30.4213, -87.2169], // Pensacola
|
||||
[30.99, -84.0], // Georgia line
|
||||
[25.7617, -80.1918], // Miami
|
||||
];
|
||||
const outside = [
|
||||
[33.749, -84.388], // Atlanta
|
||||
[25.0443, -77.3504], // Nassau
|
||||
[0, 0],
|
||||
[-81.78, 24.5551], // swapped
|
||||
[NaN, -81],
|
||||
];
|
||||
for (const [lat, lon] of inside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
|
||||
for (const [lat, lon] of outside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
|
||||
});
|
||||
|
||||
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
|
||||
calls.push([offsetOf(url), init.redirect]);
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
// Last page (no exceededTransferLimit): two new cameras and a repeat.
|
||||
return Response.json(page(1999, 3));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(calls, [
|
||||
[0, 'manual'],
|
||||
[2000, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 2002);
|
||||
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
|
||||
});
|
||||
|
||||
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const offsets = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const params = new URL(String(url)).searchParams;
|
||||
// Offset paging is only stable over a fixed order on the object id.
|
||||
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
|
||||
const offset = offsetOf(url);
|
||||
offsets.push(offset);
|
||||
// A layer whose max record count was lowered to 1,000.
|
||||
return Response.json(
|
||||
page(offset, offset < 2000 ? 1000 : 500, {
|
||||
exceededTransferLimit: offset < 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(offsets, [0, 1000, 2000]);
|
||||
assert.equal(cameras.length, 2500);
|
||||
});
|
||||
|
||||
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
uncapped(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
|
||||
assert.equal(cameras.length, 2000);
|
||||
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
|
||||
});
|
||||
|
||||
test('a redirect on any page drops the whole pack and releases it', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: redirected.response,
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed later page keeps the pages already read', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
uncapped(t);
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: failed.response,
|
||||
);
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
// A network error on a later page keeps them too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
});
|
||||
|
||||
test('an oversized catalog page is refused', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(JSON.stringify(page(0, 1)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only turns out too long while streaming.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(FLORIDA_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 loadFloridaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve a two-camera FL511 page to the Florida 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(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
|
||||
return Response.json([]);
|
||||
});
|
||||
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Put process.env back exactly as it was. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key in saved) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_FLORIDA_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
'the catalog refresh invokes the Florida loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources
|
||||
.filter((s) => s.cityId === 'florida')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
['fl-0', 'fl-1'],
|
||||
'Florida cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_FLORIDA_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'florida'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
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}
|
||||
*/
|
||||
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)}`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeDimensions,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
projectPoint,
|
||||
requiredPlaneLift,
|
||||
} from './cctvFootprint.js';
|
||||
|
||||
const pose = {
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
};
|
||||
|
||||
test('support points form a 3×3 grid across the pitched far cap', () => {
|
||||
const { mount, capCenter, supports } = planeSupportPoints(pose);
|
||||
assert.deepEqual(mount, { lat: pose.lat, lon: pose.lon });
|
||||
assert.deepEqual(Object.keys(supports).sort(), [...SUPPORT_KEYS].sort());
|
||||
const dims = planeDimensions(pose);
|
||||
// Cap center sits R·cos(pitch) east of the mount (heading 90).
|
||||
const east = projectPoint(pose.lat, pose.lon, 90, dims.horiz);
|
||||
assert.ok(
|
||||
Math.abs(capCenter.lat - east.lat) < 1e-9 &&
|
||||
Math.abs(capCenter.lon - east.lon) < 1e-9,
|
||||
);
|
||||
// Middle-center is the cap center; left/right columns straddle it across the heading.
|
||||
assert.ok(Math.abs(supports.mc.lat - capCenter.lat) < 1e-9);
|
||||
assert.ok(
|
||||
supports.ml.lat > capCenter.lat && supports.mr.lat < capCenter.lat,
|
||||
'left is north of centre when looking east',
|
||||
);
|
||||
// A downward pitch tilts the top row forward (further along the heading).
|
||||
assert.ok(
|
||||
supports.tm.lon > supports.mc.lon && supports.bm.lon < supports.mc.lon,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rigid lift is the largest clearance deficit over the supports', () => {
|
||||
const dims = planeDimensions(pose);
|
||||
const ground = 149;
|
||||
const capAlt = ground + pose.mountHeightM + dims.vert; // far below ground for -24°
|
||||
const flat = requiredPlaneLift(capAlt, dims, null, ground, 2);
|
||||
assert.ok(
|
||||
Math.abs(flat.liftM - (ground + 2 - (capAlt - dims.upVert))) < 1e-9,
|
||||
);
|
||||
assert.equal(flat.limitingKey[0], 'b');
|
||||
const bump = requiredPlaneLift(capAlt, dims, { tr: ground + 200 }, ground, 2);
|
||||
assert.equal(bump.limitingKey, 'tr');
|
||||
assert.ok(bump.liftM > flat.liftM);
|
||||
// Already-clear planes need no lift at all.
|
||||
assert.deepEqual(requiredPlaneLift(ground + 500, dims, null, ground, 2), {
|
||||
liftM: 0,
|
||||
limitingKey: null,
|
||||
});
|
||||
// Unusable measurements fall back to the mount ground rather than zero.
|
||||
assert.equal(
|
||||
requiredPlaneLift(capAlt, dims, { bl: null, br: 'x' }, ground, 2).liftM,
|
||||
flat.liftM,
|
||||
);
|
||||
});
|
||||
|
||||
test('poseHash is stable for the same pose and changes with any pose field', () => {
|
||||
const h = poseHash(pose);
|
||||
assert.equal(poseHash({ ...pose }), h);
|
||||
assert.match(h, /^p1-[0-9a-z]+$/);
|
||||
for (const [key, delta] of [
|
||||
['lat', 0.00001],
|
||||
['headingDeg', 0.1],
|
||||
['pitchDeg', 0.1],
|
||||
['fovDeg', 0.1],
|
||||
['rangeM', 1],
|
||||
['mountHeightM', 0.1],
|
||||
]) {
|
||||
assert.notEqual(poseHash({ ...pose, [key]: pose[key] + delta }), h, key);
|
||||
}
|
||||
});
|
||||
+176
-44
@@ -24,6 +24,7 @@
|
||||
* invariant is untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
// Grazing guard (spec §5): reject plane intersections when the view ray is
|
||||
// nearly parallel to the constraint plane — the hit point races to infinity
|
||||
@@ -73,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
||||
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
||||
if (s < 0) return null;
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(
|
||||
rayDir,
|
||||
s,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
||||
}
|
||||
|
||||
@@ -88,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
*/
|
||||
export function ringAngle(hitPoint, center, basisA, basisB) {
|
||||
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
||||
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
|
||||
return Math.atan2(
|
||||
Cesium.Cartesian3.dot(v, basisB),
|
||||
Cesium.Cartesian3.dot(v, basisA),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +157,11 @@ function enuAxes(position) {
|
||||
|
||||
/** a*sa + b*sb (fresh Cartesian3). */
|
||||
function combine2(a, sa, b, sb) {
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(
|
||||
a,
|
||||
sa,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(out, t, out);
|
||||
}
|
||||
@@ -164,7 +176,12 @@ function combine2(a, sa, b, sb) {
|
||||
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
||||
const h = toRadians(headingDeg);
|
||||
const p = toRadians(pitchDeg);
|
||||
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
|
||||
const forwardHoriz = combine2(
|
||||
axes.east,
|
||||
Math.sin(h),
|
||||
axes.north,
|
||||
Math.cos(h),
|
||||
);
|
||||
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
||||
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
||||
return { forwardHoriz, right, view };
|
||||
@@ -175,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
const positions = [];
|
||||
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
||||
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
||||
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
|
||||
const offset = combine2(
|
||||
basisA,
|
||||
Math.cos(angle) * radius,
|
||||
basisB,
|
||||
Math.sin(angle) * radius,
|
||||
);
|
||||
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
||||
}
|
||||
return positions;
|
||||
@@ -201,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
* destroy: function(): void, isDragging: function(): boolean,
|
||||
* isEnabled: function(): boolean}}
|
||||
*/
|
||||
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
|
||||
export function createCalibrationGizmo({
|
||||
viewer,
|
||||
getActiveRecord,
|
||||
applyPatch,
|
||||
endPatch,
|
||||
}) {
|
||||
let enabled = false;
|
||||
let drag = null; // { part, startCal, basePose, refs... }
|
||||
let hoveredId = null;
|
||||
@@ -224,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
|
||||
function polylinePart(part, color, width, arrow = false) {
|
||||
addEntity(part, {
|
||||
...(arrow ? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
} : {}),
|
||||
...(arrow
|
||||
? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
polyline: {
|
||||
positions: [],
|
||||
width,
|
||||
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
|
||||
material: arrow
|
||||
? new Cesium.PolylineArrowMaterialProperty(color)
|
||||
: color.withAlpha(0.9),
|
||||
// Standard gizmo convention: parts behind geometry stay clearly
|
||||
// visible, just dimmed (a street-level mount buries half the heading
|
||||
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
||||
@@ -285,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
||||
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
|
||||
const radius = Math.min(
|
||||
RING_RADIUS_MAX_M,
|
||||
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
|
||||
);
|
||||
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
||||
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
|
||||
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
|
||||
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
|
||||
)];
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
axes.north,
|
||||
axes.east,
|
||||
);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
forwardHoriz,
|
||||
axes.up,
|
||||
);
|
||||
const arrowTo = (dir) => [
|
||||
mount,
|
||||
Cesium.Cartesian3.add(
|
||||
mount,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
dir,
|
||||
arrowLen,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
];
|
||||
const eastArrow = arrowTo(axes.east);
|
||||
const northArrow = arrowTo(axes.north);
|
||||
const upArrow = arrowTo(axes.up);
|
||||
@@ -305,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
entities.get('move-up').polyline.positions = upArrow;
|
||||
entities.get('move-up').position = upArrow[1];
|
||||
entities.get('handle-range').position = positions.capCenter;
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tl,
|
||||
positions.bl,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tr,
|
||||
positions.br,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
for (const entity of entities.values()) entity.show = true;
|
||||
}
|
||||
|
||||
@@ -342,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
|
||||
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
|
||||
console.debug(
|
||||
'[CCTV:gizmo] drillPick @',
|
||||
windowPosition?.x,
|
||||
windowPosition?.y,
|
||||
'→',
|
||||
JSON.stringify(
|
||||
results.map((r) =>
|
||||
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const picked of results) {
|
||||
const part = gizmoPartFrom(picked);
|
||||
@@ -378,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
for (const [name, entity] of entities.entries()) {
|
||||
if (!entity.point) continue;
|
||||
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
||||
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
entity.point.pixelSize =
|
||||
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
}
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
}
|
||||
@@ -396,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const mount = Cesium.Cartesian3.clone(positions.mount);
|
||||
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const startCal = { ...camera.calibration };
|
||||
const basePose = { ...camera.basePose };
|
||||
const state = {
|
||||
@@ -404,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
||||
// different camera — its basePose makes the captured offsets wrong.
|
||||
record,
|
||||
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
effectiveRangeM: geometry.rangeM,
|
||||
startAngle: null,
|
||||
startParam: null,
|
||||
@@ -418,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return false;
|
||||
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
||||
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
|
||||
} else if (
|
||||
part === 'move-east' ||
|
||||
part === 'move-north' ||
|
||||
part === 'move-up'
|
||||
) {
|
||||
const dir =
|
||||
part === 'move-east'
|
||||
? axes.east
|
||||
: part === 'move-north'
|
||||
? axes.north
|
||||
: axes.up;
|
||||
state.axisDir = dir;
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
||||
if (t === null) return false;
|
||||
@@ -447,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
function dragPatch(windowPosition) {
|
||||
const ray = pickRay(windowPosition);
|
||||
if (!ray || !drag) return null;
|
||||
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
|
||||
const {
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
} = drag;
|
||||
|
||||
if (part === 'ring-heading') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
||||
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
headingDeg:
|
||||
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'ring-pitch') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
||||
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
pitchDeg:
|
||||
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
|
||||
const t = closestParamOnAxis(
|
||||
ray.origin,
|
||||
ray.direction,
|
||||
mount,
|
||||
drag.axisDir,
|
||||
);
|
||||
if (t === null) return null;
|
||||
const delta = t - drag.startParam;
|
||||
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
if (part === 'move-east')
|
||||
return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north')
|
||||
return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
return { heightM: startCal.heightM + delta };
|
||||
}
|
||||
if (part === 'handle-range') {
|
||||
@@ -477,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||
if (!hit) return null;
|
||||
const lateral = Math.abs(Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
|
||||
));
|
||||
const lateral = Math.abs(
|
||||
Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
|
||||
right,
|
||||
),
|
||||
);
|
||||
const halfW = Math.max(0.5, lateral);
|
||||
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
|
||||
const fovDeg = toDeg(
|
||||
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
|
||||
);
|
||||
return { fovDeg: fovDeg - basePose.fovDeg };
|
||||
}
|
||||
return null;
|
||||
@@ -506,6 +630,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
}
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
|
||||
if (!isPointerFree()) return;
|
||||
if (!enabled) return;
|
||||
const part = pickGizmoPart(event.position);
|
||||
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
||||
@@ -528,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return;
|
||||
}
|
||||
const patch = dragPatch(event.endPosition);
|
||||
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
|
||||
debugLog(
|
||||
'dragPatch',
|
||||
drag.part,
|
||||
event.endPosition,
|
||||
'→',
|
||||
patch ? JSON.stringify(patch) : null,
|
||||
);
|
||||
if (patch) applyPatch(patch, drag.record);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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 {
|
||||
joinGroundHeights,
|
||||
loadGroundHeights,
|
||||
} from '../../server/providers/cctv/groundHeights.js';
|
||||
import { poseHash } from './cctvFootprint.js';
|
||||
|
||||
const source = (over = {}) => ({
|
||||
id: 'cam-1',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
...over,
|
||||
});
|
||||
|
||||
test('a shipped entry attaches only while the nominal pose still matches', () => {
|
||||
const s = source();
|
||||
const entry = {
|
||||
status: 'ok',
|
||||
poseHash: poseHash(s),
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5, tl: null, tm: 'x' },
|
||||
};
|
||||
const [joined] = joinGroundHeights([s], { 'cam-1': entry });
|
||||
assert.deepEqual(joined.groundHeights, {
|
||||
poseHash: entry.poseHash,
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5 },
|
||||
});
|
||||
|
||||
const moved = source({ lat: 30.2673 });
|
||||
const [notJoined] = joinGroundHeights([moved], { 'cam-1': entry });
|
||||
assert.equal(
|
||||
notJoined.groundHeights,
|
||||
undefined,
|
||||
'a moved camera loses the shipped value',
|
||||
);
|
||||
|
||||
const [missed] = joinGroundHeights([source()], {
|
||||
'cam-1': { ...entry, status: 'miss', mountGroundM: null },
|
||||
});
|
||||
assert.equal(missed.groundHeights, undefined, 'a miss ships nothing');
|
||||
assert.deepEqual(
|
||||
joinGroundHeights([source()], null)[0].groundHeights,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sidecar loads from the source root and tolerates a missing or malformed file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-heights-'));
|
||||
fs.mkdirSync(
|
||||
path.join(dir, 'src', 'data', 'local_data', 'cctv_ground_heights'),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'missing file');
|
||||
const file = path.join(
|
||||
dir,
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'cctv_ground_heights',
|
||||
'cctv_ground_heights.json',
|
||||
);
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'malformed file');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, cameras: { a: { status: 'ok' } } }),
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), { a: { status: 'ok' } });
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
hkCameraName,
|
||||
hkCameraToSource,
|
||||
hkImageUrlFromKey,
|
||||
loadHkSourcesFromOpenData,
|
||||
normalizeHkImageUrl,
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
HK_IMAGE_ORIGIN,
|
||||
HK_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
prioritizeSources,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.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('<image-list>'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One Transport Department `<image>` block as a parsed row. */
|
||||
const row = (overrides = {}) => ({
|
||||
key: 'BC101F',
|
||||
region: 'New Territories',
|
||||
district: 'North',
|
||||
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
|
||||
latitude: '22.508',
|
||||
longitude: '114.078',
|
||||
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Fixture XML matching the published English locations schema. */
|
||||
const fixtureXml = (...records) => {
|
||||
const images = records
|
||||
.map(
|
||||
(record) => `
|
||||
<image>
|
||||
<key>${record.key}</key>
|
||||
<region>${record.region}</region>
|
||||
<district>${record.district}</district>
|
||||
<description>${record.description}</description>
|
||||
<latitude>${record.latitude}</latitude>
|
||||
<longitude>${record.longitude}</longitude>
|
||||
<url>${record.url}</url>
|
||||
</image>`,
|
||||
)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
|
||||
};
|
||||
|
||||
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = hkCameraToSource(row());
|
||||
assert.equal(source.id, 'hk-bc101f');
|
||||
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
|
||||
assert.equal(source.cityId, 'hk');
|
||||
assert.equal(source.provider, 'Transport Department');
|
||||
assert.equal(source.lat, 22.508);
|
||||
assert.equal(source.lon, 114.078);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'hk-td-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: '' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
hkCameraToSource(
|
||||
row({
|
||||
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
|
||||
assert.equal(hkCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
|
||||
assert.equal(
|
||||
hkImageUrlFromKey('bc101f'),
|
||||
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
);
|
||||
assert.equal(hkImageUrlFromKey(''), null);
|
||||
assert.equal(hkImageUrlFromKey('bad/key'), null);
|
||||
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(normalizeHkImageUrl(''), null);
|
||||
assert.equal(normalizeHkImageUrl('not a url'), null);
|
||||
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
hkCameraName(
|
||||
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
|
||||
'hk-h429f',
|
||||
),
|
||||
'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
);
|
||||
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
|
||||
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
|
||||
});
|
||||
|
||||
test('the XML parser reads each image block', () => {
|
||||
const rows = parseHkCameraLocationsXml(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H429F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Southern',
|
||||
description: 'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
latitude: '22.24845',
|
||||
longitude: '114.1505',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].key, 'BC101F');
|
||||
assert.equal(rows[1].key, 'H429F');
|
||||
assert.equal(rows[1].latitude, '22.24845');
|
||||
});
|
||||
|
||||
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 new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
row({ url: 'https://evil.example/x.jpg' }),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
const cameras = await loadHkSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest Central/TST first: Wan Chai before the northern New Territories.
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
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 loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the description, trimmed to the code width', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
|
||||
'SAN TIN HIGHWAY [BC101F]',
|
||||
);
|
||||
const long = hkCameraToSource(
|
||||
row({
|
||||
description:
|
||||
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
|
||||
}),
|
||||
);
|
||||
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', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = fixtureXml(row());
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[
|
||||
lane('austin', 250),
|
||||
lane('calgary', 215),
|
||||
lane('hk', DEFAULT_HK_MAX_SOURCES),
|
||||
],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['hk', 10],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the HK catalog to the HK 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.includes('Traffic_Camera_Locations_En.xml')) {
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the HK 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_HK_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
'the catalog refresh invokes the HK loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
'HK 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_HK_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_HK_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk'),
|
||||
[],
|
||||
);
|
||||
} 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);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES >= 1013,
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
|
||||
);
|
||||
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
|
||||
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
|
||||
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
|
||||
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
|
||||
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
|
||||
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
|
||||
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
|
||||
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
|
||||
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
|
||||
});
|
||||
|
||||
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
|
||||
const cameras = [
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'H001F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Central & Western',
|
||||
description: 'Central harbour [H001F]',
|
||||
latitude: '22.2819',
|
||||
longitude: '114.158',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'ST101F',
|
||||
region: 'New Territories',
|
||||
district: 'Sha Tin',
|
||||
description: 'Sha Tin Road [ST101F]',
|
||||
latitude: '22.3827',
|
||||
longitude: '114.19',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'K999F',
|
||||
region: 'Kowloon',
|
||||
district: 'Yau Tsim Mong',
|
||||
description: 'Far Kowloon filler [K999F]',
|
||||
latitude: '22.32',
|
||||
longitude: '114.18',
|
||||
}),
|
||||
),
|
||||
].filter(Boolean);
|
||||
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
|
||||
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
|
||||
'hk-h001f',
|
||||
'hk-st101f',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createHlsPuller,
|
||||
fetchHlsBytes,
|
||||
parseHlsMedia,
|
||||
HLS_LIMITS,
|
||||
} from '../../server/providers/cctv/stream.js';
|
||||
const playlist =
|
||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
||||
const base = 'https://camera.example/live/list.m3u8';
|
||||
|
||||
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
|
||||
assert.deepEqual(
|
||||
parseHlsMedia(playlist, base).map((s) => s.seq),
|
||||
[12, 13, 14],
|
||||
);
|
||||
for (const bad of [
|
||||
playlist.replace('a.ts', 'https://evil.example/a.ts'),
|
||||
playlist.replace('a.ts', '//evil.example/a.ts'),
|
||||
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
|
||||
playlist.replace('12', '-1'),
|
||||
playlist.replace('2,', 'Infinity,'),
|
||||
])
|
||||
assert.throws(() => parseHlsMedia(bad, base));
|
||||
});
|
||||
|
||||
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
|
||||
let init;
|
||||
await assert.rejects(
|
||||
fetchHlsBytes(base, {
|
||||
maxBytes: 5,
|
||||
fetchImpl: async (_url, options) => {
|
||||
init = options;
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.example' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(init.redirect, 'error');
|
||||
await assert.rejects(
|
||||
fetchHlsBytes(base, {
|
||||
maxBytes: 5,
|
||||
fetchImpl: async () => new Response('abcdef'),
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchHlsBytes(base, {
|
||||
maxBytes: 5,
|
||||
fetchImpl: async () =>
|
||||
new Response('a', { headers: { 'content-length': '100' } }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
|
||||
const manager = createHlsPuller({
|
||||
limits: {
|
||||
...HLS_LIMITS,
|
||||
sessions: 1,
|
||||
segmentBytes: 4,
|
||||
sessionBytes: 6,
|
||||
segments: 2,
|
||||
pollMs: 100000,
|
||||
},
|
||||
fetchImpl: async (url) =>
|
||||
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||
});
|
||||
const [a, b] = await Promise.all([
|
||||
manager.ensure('a', base),
|
||||
manager.ensure('a', base),
|
||||
]);
|
||||
assert.equal(a, b);
|
||||
await assert.rejects(manager.ensure('b', base));
|
||||
assert.equal(await manager.waitReady(a), true);
|
||||
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
|
||||
const text = await manager.buildPlaylist(a, 'a');
|
||||
assert.match(text, /seg_0\.ts\?session=/);
|
||||
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
|
||||
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
|
||||
manager.stop('a', 'stale-token');
|
||||
assert.equal(manager.stats().sessions, 1);
|
||||
manager.stop('a', a.token);
|
||||
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
|
||||
let observed;
|
||||
const manager = createHlsPuller({
|
||||
fetchImpl: async (_url, { signal }) => {
|
||||
observed = signal;
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const entry = await manager.ensure('a', base);
|
||||
await manager.shutdown();
|
||||
assert.equal(observed.aborted, true);
|
||||
assert.equal(entry.stopping, true);
|
||||
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
||||
await assert.rejects(manager.ensure('a', base));
|
||||
});
|
||||
|
||||
test('idle cleanup stops all polling without a background sweep', async () => {
|
||||
let calls = 0;
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
|
||||
},
|
||||
});
|
||||
await manager.ensure('a', base);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
||||
const stoppedCalls = calls;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
assert.equal(calls, stoppedCalls);
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
|
||||
let current = playlist;
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, pollMs: 5 },
|
||||
fetchImpl: async (url) =>
|
||||
new Response(url.endsWith('.m3u8') ? current : 'abc'),
|
||||
});
|
||||
const entry = await manager.ensure('a', base);
|
||||
await manager.waitReady(entry);
|
||||
const before = await manager.buildPlaylist(entry, 'a');
|
||||
assert.match(before, /seg_0\.ts/);
|
||||
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const after = await manager.buildPlaylist(entry, 'a');
|
||||
assert.match(
|
||||
after,
|
||||
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
|
||||
);
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('upstream discontinuity tags survive the media parser', () => {
|
||||
const parsed = parseHlsMedia(
|
||||
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
|
||||
base,
|
||||
);
|
||||
assert.equal(parsed[0].discontinuity, false);
|
||||
assert.equal(parsed[1].discontinuity, true);
|
||||
});
|
||||
|
||||
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
|
||||
let current = playlist;
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, pollMs: 5 },
|
||||
fetchImpl: async (url) =>
|
||||
new Response(url.endsWith('.m3u8') ? current : 'abc'),
|
||||
});
|
||||
const entry = await manager.ensure('a', base);
|
||||
await manager.waitReady(entry);
|
||||
current = playlist.replaceAll('.ts', '.ts?generation=2');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
assert.match(
|
||||
await manager.buildPlaylist(entry, 'a'),
|
||||
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
|
||||
);
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
|
||||
let downloads = 0;
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
|
||||
fetchImpl: async (url) => {
|
||||
downloads++;
|
||||
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
|
||||
},
|
||||
});
|
||||
const [a, b] = await Promise.all([
|
||||
manager.ensure('camera', base, 'viewer-a'),
|
||||
manager.ensure('camera', base, 'viewer-b'),
|
||||
]);
|
||||
assert.equal(a, b);
|
||||
await manager.waitReady(a);
|
||||
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
|
||||
assert.equal(a.leases.size, 2);
|
||||
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
|
||||
manager.release('camera', 'viewer-a');
|
||||
assert.equal(manager.stats().sessions, 1);
|
||||
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
|
||||
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
|
||||
assert.match(
|
||||
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
|
||||
/lease=viewer-b/,
|
||||
);
|
||||
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
|
||||
assert.equal(b.controller.signal.aborted, false);
|
||||
manager.release('camera', 'viewer-b');
|
||||
assert.equal(b.controller.signal.aborted, true);
|
||||
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
|
||||
fetchImpl: async (url) =>
|
||||
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||
});
|
||||
const entry = await manager.ensure('camera', base, 'abandoned');
|
||||
await manager.ensure('camera', base, 'active');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await manager.ensure('camera', base, 'active');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.equal(entry.leases.has('abandoned'), false);
|
||||
assert.equal(entry.leases.has('active'), true);
|
||||
assert.equal(entry.controller.signal.aborted, false);
|
||||
await manager.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_KING_COUNTY_MAX_SOURCES,
|
||||
KING_COUNTY_CAMERAS_URL,
|
||||
KING_COUNTY_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.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 FeatureServer GeoJSON feature carrying the fields the loader reads. */
|
||||
function kingCountyFeature(id, lat, lon, overrides = {}) {
|
||||
const { properties = {}, geometry } = overrides;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry:
|
||||
geometry === undefined
|
||||
? { type: 'Point', coordinates: [lon, lat] }
|
||||
: geometry,
|
||||
properties: {
|
||||
AssetID: id,
|
||||
Location: `Road ${id} at Test Ave`,
|
||||
ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`,
|
||||
Live: 1,
|
||||
Model: '3950',
|
||||
Manufacturer: 'Cohu',
|
||||
Owner: 'King County',
|
||||
CurrentStatus: 'Active',
|
||||
...properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: {
|
||||
Location: 'NE Novelty Hill Road at Trilogy Parkway NE',
|
||||
// The live layer carries stray whitespace in model strings.
|
||||
Model: 'Q6135-LE\n',
|
||||
Manufacturer: 'Axis',
|
||||
},
|
||||
}),
|
||||
// City-owned camera the county hosts: kept, owner becomes the credit.
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Owner: 'City of Newcastle', Model: null },
|
||||
}),
|
||||
// WSDOT-owned row: its frames are WSDOT-hosted, so it is skipped.
|
||||
kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }),
|
||||
// Off-host frame, not live, retired, bad id, bad geometry, out of county.
|
||||
kingCountyFeature(71, 47.6, -122.3, {
|
||||
properties: { ImageURL: 'https://example.com/cam71.jpg' },
|
||||
}),
|
||||
kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }),
|
||||
kingCountyFeature(73, 47.6, -122.3, {
|
||||
properties: { CurrentStatus: 'Retired' },
|
||||
}),
|
||||
kingCountyFeature('74', 47.6, -122.3),
|
||||
kingCountyFeature(75, 47.6, -122.3, { geometry: null }),
|
||||
kingCountyFeature(76, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
|
||||
const novelty = cameras.find((camera) => camera.id === 'kingcounty-56');
|
||||
assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE');
|
||||
assert.equal(novelty.provider, 'King County Road Services');
|
||||
assert.equal(
|
||||
novelty.url,
|
||||
'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg',
|
||||
);
|
||||
assert.equal(novelty.snapshotUrl, novelty.url);
|
||||
assert.equal(novelty.feedType, 'image');
|
||||
// Published hardware model rides along, whitespace-collapsed.
|
||||
assert.equal(novelty.model, 'Q6135-LE');
|
||||
assert.equal(novelty.manufacturer, 'Axis');
|
||||
assert.equal(novelty.credit, '');
|
||||
// No facing in the layer: id-hash fallback at low confidence.
|
||||
assert.equal(novelty.headingConfidence, 'low');
|
||||
assert.equal(novelty.fovDeg, 44);
|
||||
assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360);
|
||||
|
||||
const newcastle = cameras.find((camera) => camera.id === 'kingcounty-90');
|
||||
assert.equal(newcastle.credit, 'City of Newcastle');
|
||||
assert.equal(newcastle.model, undefined);
|
||||
// A manufacturer without a model identifies nothing, so it is dropped too.
|
||||
assert.equal(newcastle.manufacturer, undefined);
|
||||
});
|
||||
|
||||
test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => {
|
||||
const withModel = normalizeSourceItem({
|
||||
id: 'kingcounty-56',
|
||||
lat: 47.7,
|
||||
lon: -122.0,
|
||||
model: ' Q6135-LE ',
|
||||
manufacturer: ' Axis ',
|
||||
});
|
||||
assert.equal(withModel.model, 'Q6135-LE');
|
||||
assert.equal(withModel.manufacturer, 'Axis');
|
||||
|
||||
const withoutModel = normalizeSourceItem({
|
||||
id: 'austin-1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
});
|
||||
assert.equal(withoutModel.model, undefined);
|
||||
assert.equal(withoutModel.manufacturer, undefined);
|
||||
assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined);
|
||||
});
|
||||
|
||||
/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
/** The browser catalog with the pose math stubbed out: only field
|
||||
* passthrough is under test here. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// No file/env catalog, so the live packs load; every pack but King County
|
||||
// sees an upstream failure and contributes nothing.
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_KINGCOUNTY_ENABLED: undefined,
|
||||
CCTV_KINGCOUNTY_MAX_SOURCES: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) !== KING_COUNTY_CAMERAS_URL)
|
||||
return new Response('', { status: 503 });
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' },
|
||||
}),
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Model: null, Manufacturer: 'Cohu' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: root }));
|
||||
const served = new Map(body.sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...served.keys()].sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
assert.equal(served.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(served.get('kingcounty-56').manufacturer, 'Axis');
|
||||
// Absent hardware is an explicit empty string on the wire, like
|
||||
// headingConfidence, so consumers never have to probe for the key.
|
||||
assert.equal(served.get('kingcounty-90').model, '');
|
||||
assert.equal(served.get('kingcounty-90').manufacturer, '');
|
||||
|
||||
const cameras = browserCatalog().buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis');
|
||||
assert.equal(byId.get('kingcounty-90').model, '');
|
||||
assert.equal(byId.get('kingcounty-90').manufacturer, '');
|
||||
});
|
||||
|
||||
test('King County loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(
|
||||
kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02),
|
||||
);
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('King County loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader does not follow a redirected catalog', async (t) => {
|
||||
quiet(t);
|
||||
let init;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
init = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [kingCountyFeature(56, 47.70059, -122.02443)],
|
||||
}),
|
||||
{ status: 302, headers: { Location: 'https://example.com/cams' } },
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
assert.equal(init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('King County loader rejects an oversized catalog', async (t) => {
|
||||
quiet(t);
|
||||
const features = [kingCountyFeature(56, 47.70059, -122.02443)];
|
||||
const body = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
padding: 'x'.repeat(KING_COUNTY_MAX_CATALOG_BYTES),
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response(body));
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader drops duplicate ids before the per-pack cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
// Repeats of the camera nearest the anchor would fill an 8-camera cap
|
||||
// before the second, farther camera if duplicates reached prioritization.
|
||||
const features = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
features.push(kingCountyFeature(56, 47.4829, -122.2171));
|
||||
}
|
||||
features.push(kingCountyFeature(57, 47.70059, -122.02443));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-57',
|
||||
]);
|
||||
});
|
||||
|
||||
test('King County loader omits vendor-generic model strings', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(61, 47.5, -122.2, {
|
||||
properties: { Model: 'PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(62, 47.51, -122.2, {
|
||||
properties: { Model: 'PTZ IP', Manufacturer: 'Cohu' },
|
||||
}),
|
||||
kingCountyFeature(63, 47.52, -122.2, {
|
||||
properties: { Model: 'BOSCH/PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(64, 47.53, -122.2, {
|
||||
properties: { Model: 'AUTODOME IP 5000', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(
|
||||
(await loadKingCountySourcesFromOpenData()).map((camera) => [
|
||||
camera.id,
|
||||
camera,
|
||||
]),
|
||||
);
|
||||
for (const id of ['kingcounty-61', 'kingcounty-62', 'kingcounty-63']) {
|
||||
assert.equal(byId.get(id).model, undefined, id);
|
||||
assert.equal(byId.get(id).manufacturer, undefined, id);
|
||||
}
|
||||
assert.equal(byId.get('kingcounty-64').model, 'AUTODOME IP 5000');
|
||||
assert.equal(byId.get('kingcounty-64').manufacturer, 'Bosch');
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lithuaniaCameraToSource,
|
||||
loadLithuaniaSources,
|
||||
LITHUANIA_CAMERAS_URL,
|
||||
LITHUANIA_LOCATIONS_URL,
|
||||
LITHUANIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/lithuania.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const row = {
|
||||
id: 72,
|
||||
name: 'Vilnius A1 10,04',
|
||||
x: 576154,
|
||||
y: 6056867,
|
||||
image:
|
||||
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
};
|
||||
const feature = {
|
||||
id: '72',
|
||||
points: [{ point: [54.642550940833566, 25.17981587877114] }],
|
||||
};
|
||||
const layers = [{ layer: 'VKR', features: [feature] }];
|
||||
|
||||
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
|
||||
const camera = lithuaniaCameraToSource(row, feature);
|
||||
assert.equal(camera.id, 'lithuania-72');
|
||||
assert.equal(camera.lat, 54.642550940833566);
|
||||
assert.equal(camera.lon, 25.17981587877114);
|
||||
assert.equal(camera.name, 'Vilnius A1 10,04');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.url, row.image);
|
||||
assert.match(camera.credit, /Via Lietuva/);
|
||||
});
|
||||
|
||||
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...feature, id: '8' },
|
||||
{ ...feature, points: [] },
|
||||
{ ...feature, points: [{ point: [25.18, 54.64] }] },
|
||||
{ ...feature, points: [{ point: [576154, 6056867] }] },
|
||||
{ ...feature, points: [{ point: [null, 25] }] },
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource(row, invalid), null);
|
||||
}
|
||||
for (const image of [
|
||||
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
|
||||
row.image.replace('https://', 'https://user:pass@'),
|
||||
row.image.replace('id=72', 'id=8'),
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
|
||||
}
|
||||
assert.equal(
|
||||
lithuaniaCameraToSource(
|
||||
{ ...row, image: row.image + '&extra=ignored' },
|
||||
feature,
|
||||
).url,
|
||||
row.image,
|
||||
);
|
||||
assert.equal(lithuaniaCameraToSource(null, feature), null);
|
||||
});
|
||||
|
||||
function mockCatalogs(t) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return Response.json([row, row, { ...row, id: 999 }]);
|
||||
}
|
||||
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
assert.deepEqual(
|
||||
(await loadLithuaniaSources()).map((c) => c.id),
|
||||
['lithuania-72'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requested.sort(),
|
||||
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
|
||||
* (undefined leaves it unset), restoring the environment afterwards. */
|
||||
async function runCatalogWithLithuaniaFlag(t, flag) {
|
||||
const requested = mockCatalogs(t);
|
||||
const saved = process.env.CCTV_LITHUANIA_ENABLED;
|
||||
try {
|
||||
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
|
||||
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'lithuania-72')?.cityId,
|
||||
'lithuania',
|
||||
);
|
||||
});
|
||||
|
||||
// The camera endpoints are the eismoinfo.lt website backend, not a service the
|
||||
// open-data statement lists, so an install that has not opted in must never
|
||||
// contact them.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_LITHUANIA_ENABLED', undefined],
|
||||
['an empty CCTV_LITHUANIA_ENABLED', ''],
|
||||
['CCTV_LITHUANIA_ENABLED=0', '0'],
|
||||
['CCTV_LITHUANIA_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no Lithuania request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('eismoinfo.lt')),
|
||||
false,
|
||||
'the pack never reaches its upstream',
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let siblingSignal;
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
siblingSignal = options.signal;
|
||||
// Never settles on its own; only an abort ends it.
|
||||
return new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () =>
|
||||
reject(options.signal.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.equal(siblingSignal?.aborted, true);
|
||||
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
|
||||
});
|
||||
|
||||
test('a redirected catalog is reported as not followed', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
|
||||
});
|
||||
|
||||
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const makeResponse of [
|
||||
() => new Response('', { status: 503 }),
|
||||
() =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
() => new Response('invalid json'),
|
||||
() => Response.json({ unexpected: true }),
|
||||
() =>
|
||||
new Response('[]', {
|
||||
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
|
||||
}),
|
||||
]) {
|
||||
t.mock.method(globalThis, 'fetch', async () => makeResponse());
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
}
|
||||
});
|
||||
+132
-50
@@ -31,6 +31,20 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'austin transportation & public works': 5 * 60 * 1000,
|
||||
'transport for london': 3 * 60 * 1000,
|
||||
caltrans: 3 * 60 * 1000,
|
||||
'ontario 511': 3 * 60 * 1000,
|
||||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||||
txdot: 3 * 60 * 1000,
|
||||
// Digitraffic publishes a new weathercam frame on each station's
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -42,7 +56,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
* @returns {{cardLimit:number}}
|
||||
*/
|
||||
export function cctvLodBudgets(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
|
||||
const height = Number.isFinite(cameraHeightM)
|
||||
? Math.max(0, cameraHeightM)
|
||||
: CITY_HEIGHT_M;
|
||||
if (height <= STREET_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||||
}
|
||||
@@ -68,7 +84,11 @@ export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||||
* @param {number} [factor]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
|
||||
export function incumbentRankKm(
|
||||
distanceKm,
|
||||
isIncumbent,
|
||||
factor = CCTV_CARD_INCUMBENT_FACTOR,
|
||||
) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
return isIncumbent ? km * factor : km;
|
||||
}
|
||||
@@ -77,12 +97,13 @@ export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCU
|
||||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||||
|
||||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||||
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
return Number.isFinite(viewW) && viewW > 0
|
||||
&& Number.isFinite(viewH) && viewH > 0;
|
||||
return (
|
||||
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +138,10 @@ export function cctvCandidateSpreadKm(
|
||||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!finite.length) return 0;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(percentile) ? percentile : 0));
|
||||
const p = Math.min(
|
||||
1,
|
||||
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
|
||||
);
|
||||
return finite[Math.round(p * (finite.length - 1))];
|
||||
}
|
||||
|
||||
@@ -151,8 +175,8 @@ export function blendCenterRankKm(
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Owner-tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
const CCTV_CARD_GRID_COLS = 5;
|
||||
const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||||
@@ -176,32 +200,47 @@ export const CCTV_CARD_GRID_ROWS = 4;
|
||||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||||
* global-rank fill).
|
||||
*/
|
||||
export function distributeCctvCards(candidates, {
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {}) {
|
||||
export function distributeCctvCards(
|
||||
candidates,
|
||||
{
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {},
|
||||
) {
|
||||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy));
|
||||
const valid = (Array.isArray(candidates) ? candidates : []).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
);
|
||||
if (!cap || !valid.length) return [];
|
||||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||||
const width = validViewport ? viewW : 1;
|
||||
const height = validViewport ? viewH : 1;
|
||||
const nCols = Math.max(1, Math.floor(cols));
|
||||
const nRows = Math.max(1, Math.floor(rows));
|
||||
const byRank = (a, b) => (Number.isFinite(a.rankKm) ? a.rankKm : Infinity)
|
||||
- (Number.isFinite(b.rankKm) ? b.rankKm : Infinity)
|
||||
|| a.id.localeCompare(b.id);
|
||||
const byRank = (a, b) =>
|
||||
(Number.isFinite(a.rankKm) ? a.rankKm : Infinity) -
|
||||
(Number.isFinite(b.rankKm) ? b.rankKm : Infinity) ||
|
||||
a.id.localeCompare(b.id);
|
||||
|
||||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||||
const cells = new Map();
|
||||
for (const candidate of valid) {
|
||||
const col = Math.min(nCols - 1, Math.max(0, Math.floor((candidate.sx / width) * nCols)));
|
||||
const row = Math.min(nRows - 1, Math.max(0, Math.floor((candidate.sy / height) * nRows)));
|
||||
const col = Math.min(
|
||||
nCols - 1,
|
||||
Math.max(0, Math.floor((candidate.sx / width) * nCols)),
|
||||
);
|
||||
const row = Math.min(
|
||||
nRows - 1,
|
||||
Math.max(0, Math.floor((candidate.sy / height) * nRows)),
|
||||
);
|
||||
const key = row * nCols + col;
|
||||
const bucket = cells.get(key);
|
||||
if (bucket) bucket.push(candidate);
|
||||
@@ -249,29 +288,42 @@ export function distributeCctvCards(candidates, {
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||||
*/
|
||||
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
|
||||
export function selectCctvLod(
|
||||
candidates,
|
||||
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
|
||||
) {
|
||||
const budgets = cctvLodBudgets(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const incumbents =
|
||||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||||
if (!screened) {
|
||||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||||
// including its acceptance of any non-empty string ID. The stricter
|
||||
// valid-viewport rules below must not leak into this compatibility branch.
|
||||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((candidate) => candidate && typeof candidate.id === 'string' && candidate.id)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate && typeof candidate.id === 'string' && candidate.id,
|
||||
)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
distanceKm: Number.isFinite(candidate.distanceKm) ? Math.max(0, candidate.distanceKm) : Infinity,
|
||||
rankKm: incumbentRankKm(candidate.distanceKm, incumbents.has(candidate.id)),
|
||||
distanceKm: Number.isFinite(candidate.distanceKm)
|
||||
? Math.max(0, candidate.distanceKm)
|
||||
: Infinity,
|
||||
rankKm: incumbentRankKm(
|
||||
candidate.distanceKm,
|
||||
incumbents.has(candidate.id),
|
||||
),
|
||||
inView: candidate.inView === true,
|
||||
isVideo: candidate.isVideo === true,
|
||||
}))
|
||||
.filter((candidate) => candidate.inView)
|
||||
.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const cardIds = [];
|
||||
const seen = new Set();
|
||||
for (const candidate of inView) {
|
||||
@@ -284,9 +336,15 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
const eligibleById = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id.trim()
|
||||
|| candidate.inView !== true || candidate.isVideo === true
|
||||
|| !Number.isFinite(candidate.distanceKm)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
!candidate.id.trim() ||
|
||||
candidate.inView !== true ||
|
||||
candidate.isVideo === true ||
|
||||
!Number.isFinite(candidate.distanceKm)
|
||||
)
|
||||
continue;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceKm: Math.max(0, candidate.distanceKm),
|
||||
@@ -295,7 +353,14 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||||
};
|
||||
const current = eligibleById.get(normalized.id);
|
||||
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
|
||||
if (
|
||||
!current ||
|
||||
compareCctvRepresentative(normalized, current, {
|
||||
screened,
|
||||
viewW,
|
||||
viewH,
|
||||
}) < 0
|
||||
) {
|
||||
eligibleById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +369,9 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
// robust distance scale or consume a bounded ambient-card slot.
|
||||
const stills = [...eligibleById.values()];
|
||||
|
||||
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
|
||||
const spreadKm = cctvCandidateSpreadKm(
|
||||
stills.map((candidate) => candidate.distanceKm),
|
||||
);
|
||||
for (const candidate of stills) {
|
||||
const blendedKm = blendCenterRankKm(
|
||||
candidate.distanceKm,
|
||||
@@ -313,17 +380,22 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
);
|
||||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||||
}
|
||||
stills.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
stills.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
let cardIds;
|
||||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||||
// dropped by the distribution pass; top up from the ranked pool
|
||||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||||
cardIds = distributeCctvCards(stills, { budget: budgets.cardLimit, viewW, viewH });
|
||||
cardIds = distributeCctvCards(stills, {
|
||||
budget: budgets.cardLimit,
|
||||
viewW,
|
||||
viewH,
|
||||
});
|
||||
if (cardIds.length < budgets.cardLimit) {
|
||||
const chosen = new Set(cardIds);
|
||||
for (const candidate of stills) {
|
||||
@@ -336,10 +408,12 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
|
||||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (a.distanceKm !== b.distanceKm)
|
||||
return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (screened) {
|
||||
const centerDelta = screenCenterFraction(a.sx, a.sy, viewW, viewH)
|
||||
- screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
const centerDelta =
|
||||
screenCenterFraction(a.sx, a.sy, viewW, viewH) -
|
||||
screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
if (centerDelta) return centerDelta;
|
||||
}
|
||||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||||
@@ -414,7 +488,10 @@ export function applyEvictionGrace({
|
||||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||||
// first (they have had the longest chance to return); ties break on more
|
||||
// misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
graced.sort(
|
||||
(a, b) =>
|
||||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
@@ -423,7 +500,10 @@ export function applyEvictionGrace({
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
nextGrace.set(graced[i].id, {
|
||||
misses: graced[i].misses,
|
||||
since: graced[i].since,
|
||||
});
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
@@ -442,6 +522,8 @@ export function staticFrameRefreshMs(camera) {
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||||
}
|
||||
const provider = String(camera?.provider || '').trim().toLowerCase();
|
||||
const provider = String(camera?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||||
}
|
||||
+219
-74
@@ -41,7 +41,9 @@ test('cctvLodBudgets scales the card budget from 20 to 40 with view height', ()
|
||||
assert.equal(CCTV_AMBIENT_CARD_MAX, 40);
|
||||
assert.deepEqual(cctvLodBudgets(500), { cardLimit: CCTV_AMBIENT_CARD_MIN });
|
||||
assert.deepEqual(cctvLodBudgets(8_000), { cardLimit: CCTV_AMBIENT_CARD_MID });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), { cardLimit: CCTV_AMBIENT_CARD_MAX });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), {
|
||||
cardLimit: CCTV_AMBIENT_CARD_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
test('selectCctvLod caps ambient cards at the 40 hard cap regardless of catalog size', () => {
|
||||
@@ -54,19 +56,30 @@ test('selectCctvLod uses only in-view cameras and orders by viewer distance', ()
|
||||
const input = candidates(20, { hiddenAt: 0 }).reverse();
|
||||
const selected = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), ['cam-001', 'cam-002', 'cam-003']);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), [
|
||||
'cam-001',
|
||||
'cam-002',
|
||||
'cam-003',
|
||||
]);
|
||||
});
|
||||
|
||||
test('video cameras never consume ambient card slots', () => {
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), { cameraHeightM: 500 });
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), {
|
||||
cameraHeightM: 500,
|
||||
});
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
});
|
||||
|
||||
test('selectCctvLod tolerates malformed candidate rows', () => {
|
||||
const selected = selectCctvLod(
|
||||
[null, {}, { id: '', inView: true }, { id: 'cam-ok', inView: true, distanceKm: NaN }],
|
||||
{ cameraHeightM: 500 }
|
||||
[
|
||||
null,
|
||||
{},
|
||||
{ id: '', inView: true },
|
||||
{ id: 'cam-ok', inView: true, distanceKm: NaN },
|
||||
],
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.deepEqual(selected.cardIds, ['cam-ok']);
|
||||
});
|
||||
@@ -84,7 +97,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
// incumbent at 5.0 km holds its card against a newcomer at 4.5 km (only
|
||||
// 10% closer).
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -101,7 +116,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
|
||||
test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incumbent', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -111,13 +128,19 @@ test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incu
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(['incumbent']),
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('newcomer'), 'meaningfully closer camera wins');
|
||||
assert.ok(
|
||||
selected.cardIds.includes('newcomer'),
|
||||
'meaningfully closer camera wins',
|
||||
);
|
||||
assert.equal(selected.cardIds.includes('incumbent'), false);
|
||||
});
|
||||
|
||||
test('selectCctvLod: no incumbents means plain nearest-first (unchanged behavior)', () => {
|
||||
const plain = selectCctvLod(candidates(20), { cameraHeightM: 500 });
|
||||
const withEmpty = selectCctvLod(candidates(20), { cameraHeightM: 500, incumbentIds: new Set() });
|
||||
const withEmpty = selectCctvLod(candidates(20), {
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(),
|
||||
});
|
||||
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
|
||||
});
|
||||
|
||||
@@ -129,25 +152,39 @@ test('distributeCctvCards spreads clustered candidates across cells', () => {
|
||||
// sits alone bottom-right. Nearest-first would take the whole cluster —
|
||||
// distribution gives every occupied cell its best first.
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 2, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 2,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery'],
|
||||
);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: leftover budget falls back to global rank order', () => {
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
// 2 occupied cells, budget 4: both cell winners, then the cluster's next
|
||||
// two by rank.
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 4, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery', 'center-1', 'center-2']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 4,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery', 'center-1', 'center-2'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,64 +192,101 @@ test('distributeCctvCards: empty cells are skipped, budget respected', () => {
|
||||
// Everything in ONE cell — the 19 empty cells claim nothing; the single
|
||||
// occupied cell fills the whole budget in rank order.
|
||||
const oneCell = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c-${i}`, sx: 20 + i * 5, sy: 20, rankKm: i,
|
||||
id: `c-${i}`,
|
||||
sx: 20 + i * 5,
|
||||
sy: 20,
|
||||
rankKm: i,
|
||||
}));
|
||||
const ids = distributeCctvCards(oneCell, { budget: 3, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(oneCell, {
|
||||
budget: 3,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.deepEqual(ids, ['c-0', 'c-1', 'c-2']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: an under-budget cut keeps the nearest cell winners', () => {
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['near-cell']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: incumbency (effective distance) is honored inside a cell', () => {
|
||||
// Same cell: the incumbent at 5.0 km (discounted to 4.0) beats the
|
||||
// non-carded newcomer at 4.5 km for the cell slot.
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['incumbent']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: malformed rows dropped; offscreen anchors clamp to edge cells', () => {
|
||||
const ids = distributeCctvCards([
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
], { budget: 5, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
],
|
||||
{ budget: 5, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids.sort(), ['edge', 'ok']);
|
||||
assert.deepEqual(distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }), []);
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('selectCctvLod with viewport dims routes the fill through screen distribution', () => {
|
||||
// A cluster larger than the street budget sits mid-screen; a periphery
|
||||
// camera ranks past the budget. Nearest-first drops it — distribution
|
||||
// keeps it because its cell is otherwise empty.
|
||||
const clustered = Array.from({ length: CCTV_AMBIENT_CARD_MIN + 2 }, (_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
const clustered = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN + 2 },
|
||||
(_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}),
|
||||
);
|
||||
const periphery = {
|
||||
id: 'periphery',
|
||||
distanceKm: 9,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}));
|
||||
const periphery = { id: 'periphery', distanceKm: 9, inView: true, sx: 950, sy: 750 };
|
||||
sx: 950,
|
||||
sy: 750,
|
||||
};
|
||||
const selected = selectCctvLod([...clustered, periphery], {
|
||||
cameraHeightM: 500, viewW: 1000, viewH: 800,
|
||||
cameraHeightM: 500,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN, 'budget respected');
|
||||
assert.ok(selected.cardIds.includes('periphery'), 'periphery cell holds a card');
|
||||
assert.equal(
|
||||
selected.cardIds.length,
|
||||
CCTV_AMBIENT_CARD_MIN,
|
||||
'budget respected',
|
||||
);
|
||||
assert.ok(
|
||||
selected.cardIds.includes('periphery'),
|
||||
'periphery cell holds a card',
|
||||
);
|
||||
// Without screen info the original nearest-first cap applies unchanged.
|
||||
const plain = selectCctvLod(
|
||||
[...clustered, periphery].map(({ sx, sy, ...rest }) => rest),
|
||||
{ cameraHeightM: 500 }
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(plain.cardIds.includes('periphery'), false);
|
||||
});
|
||||
@@ -253,11 +327,13 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
const legacy = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
|
||||
@@ -266,7 +342,8 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
{ id: 'normal', distanceKm: 2, inView: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 }).cardIds,
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 })
|
||||
.cardIds,
|
||||
[' ', 'normal'],
|
||||
'invalid viewport preserves every non-empty string ID accepted by the legacy path',
|
||||
);
|
||||
@@ -294,18 +371,34 @@ test('center weighting deterministically favors center without starving peripher
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const selected = selectCctvLod([...edge, ...center], view);
|
||||
const reordered = selectCctvLod([...center].reverse().concat([...edge].reverse()), view);
|
||||
const reordered = selectCctvLod(
|
||||
[...center].reverse().concat([...edge].reverse()),
|
||||
view,
|
||||
);
|
||||
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('center-')).length, 10);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('edge-')).length, 10);
|
||||
assert.deepEqual(reordered.cardIds, selected.cardIds, 'input order cannot affect winners or ordering');
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('edge-')).length,
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(
|
||||
reordered.cardIds,
|
||||
selected.cardIds,
|
||||
'input order cannot affect winners or ordering',
|
||||
);
|
||||
|
||||
const legacy = selectCctvLod(
|
||||
[...edge, ...center].map(({ sx, sy, ...candidate }) => candidate),
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(legacy.cardIds.filter((id) => id.startsWith('center-')).length, 0);
|
||||
assert.equal(
|
||||
legacy.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('ineligible video, hidden, and malformed-distance rows cannot alter eligible winners', () => {
|
||||
@@ -322,13 +415,26 @@ test('ineligible video, hidden, and malformed-distance rows cannot alter eligibl
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
})),
|
||||
{ id: 'hidden-outlier', distanceKm: 9_000, inView: false, sx: 900, sy: 700 },
|
||||
{
|
||||
id: 'hidden-outlier',
|
||||
distanceKm: 9_000,
|
||||
inView: false,
|
||||
sx: 900,
|
||||
sy: 700,
|
||||
},
|
||||
{ id: 'bad-distance', distanceKm: NaN, inView: true, sx: 900, sy: 700 },
|
||||
{ id: '', distanceKm: 0, inView: true, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...noise], view).cardIds, baseline);
|
||||
assert.deepEqual(selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...noise], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view)
|
||||
.cardIds,
|
||||
baseline,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed-distance rows remain slot-neutral when valid candidates leave spare capacity', () => {
|
||||
@@ -339,7 +445,9 @@ test('malformed-distance rows remain slot-neutral when valid candidates leave sp
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod(candidates, view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, [
|
||||
'valid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('duplicate IDs are slot-neutral and choose a deterministic representative', () => {
|
||||
@@ -353,7 +461,10 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
{ ...center[0], distanceKm: NaN, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...duplicates], view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...duplicates], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...duplicates, ...eligible].reverse(), view).cardIds,
|
||||
baseline,
|
||||
@@ -362,13 +473,16 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
});
|
||||
|
||||
test('center weighting has deterministic ID ties and preserves invalid-anchor eligibility', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}));
|
||||
const anchors = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN - 1 },
|
||||
(_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}),
|
||||
);
|
||||
const tied = [
|
||||
{ id: 'tie-b', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'tie-a', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
@@ -380,13 +494,26 @@ test('center weighting has deterministic ID ties and preserves invalid-anchor el
|
||||
viewH: 800,
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('tie-a'));
|
||||
assert.equal(selected.cardIds.includes('tie-b'), false, 'ID breaks an exact contested tie');
|
||||
assert.equal(selected.cardIds.includes('invalid-anchor'), false, 'invalid anchor does not displace valid winners');
|
||||
assert.equal(
|
||||
selected.cardIds.includes('tie-b'),
|
||||
false,
|
||||
'ID breaks an exact contested tie',
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.includes('invalid-anchor'),
|
||||
false,
|
||||
'invalid anchor does not displace valid winners',
|
||||
);
|
||||
|
||||
const onlyInvalid = selectCctvLod([
|
||||
{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN },
|
||||
], { cameraHeightM: 500, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(onlyInvalid.cardIds, ['invalid-only'], 'defensive top-up preserves eligibility');
|
||||
const onlyInvalid = selectCctvLod(
|
||||
[{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN }],
|
||||
{ cameraHeightM: 500, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
onlyInvalid.cardIds,
|
||||
['invalid-only'],
|
||||
'defensive top-up preserves eligibility',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace keeps a fallen-out card and clears grace when it returns', () => {
|
||||
@@ -480,7 +607,11 @@ test('applyEvictionGrace never exceeds the hard cap; graced cards go first, olde
|
||||
// cam-c is newly selected and enters immediately despite grace pressure.
|
||||
assert.ok(result.keepIds.includes('cam-c'));
|
||||
assert.ok(result.keepIds.includes('cam-new'), 'newest grace entry survives');
|
||||
assert.deepEqual(result.evictIds, ['cam-old'], 'oldest grace entry evicted for the cap');
|
||||
assert.deepEqual(
|
||||
result.evictIds,
|
||||
['cam-old'],
|
||||
'oldest grace entry evicted for the cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
@@ -497,9 +628,23 @@ test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
});
|
||||
|
||||
test('staticFrameRefreshMs follows known pack cadences and bounds explicit values', () => {
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Transport for London' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
// /api/cctv/media is the live-feed relay. A still camera used to be relayed
|
||||
// through it as well, by an ordinary redirect-following fetch with no content
|
||||
// type check and no body cap, which side-stepped everything /api/cctv/frame
|
||||
// enforces for the same URL. These pin that a still is refused there and only
|
||||
// reachable through /frame, while live feeds keep their relay.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const STILL = {
|
||||
id: 'still-fixture',
|
||||
name: 'Still fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'image',
|
||||
url: 'https://cams.upstream.invalid/still.jpg',
|
||||
};
|
||||
// 'jpg' normalizes to 'image'; the alias must not reopen the relay.
|
||||
const STILL_ALIAS = {
|
||||
...STILL,
|
||||
id: 'still-alias',
|
||||
feedType: 'jpg',
|
||||
url: 'https://cams.upstream.invalid/alias.jpg',
|
||||
};
|
||||
const VIDEO = {
|
||||
id: 'video-fixture',
|
||||
name: 'Video fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const WEBM = {
|
||||
id: 'webm-fixture',
|
||||
name: 'WebM fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'webm',
|
||||
url: 'https://cams.upstream.invalid/live.webm',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'mjpeg',
|
||||
url: 'https://cams.upstream.invalid/live.mjpg',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = { ...res.headers, ...headers };
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider over the fixture cameras and answer every upstream
|
||||
* request from the supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY,
|
||||
browserKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
WEBM,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
// No Street View key: a /frame miss lands on the synthetic billboard rather
|
||||
// than a network call this file does not model.
|
||||
delete process.env.GOOGLE_MAPS_SERVER_API_KEY;
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url: String(url), redirect: init.redirect });
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey],
|
||||
['GOOGLE_MAPS_API_KEY', before.browserKey],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
const health = async (id) => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find((entry) => entry.id === id);
|
||||
};
|
||||
return { call, health, requests };
|
||||
}
|
||||
|
||||
test('a still camera is refused by /media without any upstream request', async (t) => {
|
||||
// Everything the old relay would have done with this camera: redirect to a
|
||||
// private address, then answer with HTML.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.includes('169.254.169.254')
|
||||
? upstreamResponse({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: '<html>metadata</html>',
|
||||
})
|
||||
: upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
}),
|
||||
);
|
||||
for (const camera of [STILL, STILL_ALIAS]) {
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 404, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'No live media for this camera; use the frame endpoint',
|
||||
});
|
||||
assert.doesNotMatch(res.body, /metadata/);
|
||||
// Being asked on the wrong route is not a camera fault.
|
||||
assert.equal(await app.health(camera.id), undefined);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a still camera reached its upstream');
|
||||
});
|
||||
|
||||
test('an unregistered camera id is refused by /media the same way', async (t) => {
|
||||
const app = mount(t, () => {
|
||||
throw new Error('an unknown camera must not fetch');
|
||||
});
|
||||
const res = await app.call('/media/not-a-camera');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.equal(app.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a video camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '5' },
|
||||
body: 'video',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'live-media');
|
||||
assert.equal(res.body, 'video');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[VIDEO.url],
|
||||
);
|
||||
assert.equal((await app.health(VIDEO.id)).status, 'ok');
|
||||
});
|
||||
|
||||
test('an MJPEG camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' },
|
||||
body: '--frame',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${MJPEG.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/);
|
||||
assert.equal(res.body, '--frame');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[MJPEG.url],
|
||||
);
|
||||
});
|
||||
|
||||
test('a still camera is still served through /frame with its bounded fetch', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'jpeg-bytes',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(res.body, 'jpeg-bytes');
|
||||
assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
// The protection /media used to bypass, shown on the route that keeps it.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === STILL.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'private',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.doesNotMatch(res.body, /private/);
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
|
||||
/** An upstream Response whose body records whether it was cancelled. */
|
||||
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const state = { cancelled: false };
|
||||
const response = new Response(
|
||||
new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
),
|
||||
{ status, headers },
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current.response);
|
||||
for (const [camera, contentType, body] of [
|
||||
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
|
||||
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
|
||||
[VIDEO, 'video/webm', 'video'],
|
||||
[VIDEO, undefined, 'video'],
|
||||
[WEBM, 'video/mp4', 'video'],
|
||||
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
|
||||
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
|
||||
[MJPEG, 'image/jpeg', 'jpeg'],
|
||||
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
|
||||
]) {
|
||||
current = trackedUpstream({
|
||||
headers: contentType === undefined ? {} : { 'content-type': contentType },
|
||||
body,
|
||||
});
|
||||
const label = `${camera.id}: ${contentType}`;
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 502, label);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json', label);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
|
||||
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
|
||||
const health = await app.health(camera.id);
|
||||
assert.equal(health.status, 'degraded', label);
|
||||
assert.equal(health.message, 'Unexpected media type', label);
|
||||
}
|
||||
});
|
||||
|
||||
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current);
|
||||
for (const [camera, upstream, expected] of [
|
||||
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
|
||||
[
|
||||
VIDEO,
|
||||
{
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-4/100',
|
||||
'content-length': '5',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
'video/mp4',
|
||||
],
|
||||
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
|
||||
'multipart/x-mixed-replace; boundary=frame',
|
||||
],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
|
||||
'multipart/x-mixed-replace; boundary="a b"',
|
||||
],
|
||||
]) {
|
||||
const status = upstream['content-range'] ? 206 : 200;
|
||||
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, status, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], expected);
|
||||
assert.equal(res.body, 'bytes');
|
||||
if (status === 206)
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
|
||||
assert.equal((await app.health(camera.id)).status, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
test('/media does not follow a live camera off its origin', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === VIDEO.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://elsewhere.invalid/live.mp4' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'elsewhere',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.doesNotMatch(res.body, /elsewhere/);
|
||||
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
upstreamResponse({
|
||||
headers: {
|
||||
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
|
||||
},
|
||||
body: 'bytes',
|
||||
}),
|
||||
);
|
||||
const host = 'localhost:5173';
|
||||
const paths = [
|
||||
'/sources',
|
||||
'/health',
|
||||
`/stream/${STILL.id}`,
|
||||
`/frame/${STILL.id}`,
|
||||
`/media/${VIDEO.id}`,
|
||||
];
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'cross-site' },
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://elsewhere.example' },
|
||||
{ host, origin: 'null' },
|
||||
])
|
||||
for (const path of paths) {
|
||||
const res = await app.call(path, headers);
|
||||
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'same-origin' },
|
||||
{ host, origin: `http://${host}` },
|
||||
{},
|
||||
]) {
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4');
|
||||
}
|
||||
});
|
||||
|
||||
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
|
||||
let streetViewType;
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/')
|
||||
? trackedUpstream({
|
||||
headers:
|
||||
streetViewType === undefined
|
||||
? {}
|
||||
: { 'content-type': streetViewType },
|
||||
body: 'street-view',
|
||||
}).response
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/svg+xml' },
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
}),
|
||||
);
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
for (const type of [
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'application/xml',
|
||||
undefined,
|
||||
]) {
|
||||
streetViewType = type;
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
|
||||
assert.doesNotMatch(res.body, /street-view/);
|
||||
}
|
||||
streetViewType = 'image/JPEG; charset=x';
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.body, 'street-view');
|
||||
});
|
||||
|
||||
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
|
||||
const app = mount(t, ({ url }) => {
|
||||
if (url.startsWith('https://maps.googleapis.com/'))
|
||||
return trackedUpstream({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'street-view',
|
||||
}).response;
|
||||
return url === VIDEO.url
|
||||
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
|
||||
: upstreamResponse({ status: 503, body: '' });
|
||||
});
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
const host = 'localhost:5173';
|
||||
const streetViewCalls = () =>
|
||||
app.requests.filter(({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/'),
|
||||
).length;
|
||||
for (const forwarded of [
|
||||
{ 'x-forwarded-for': '203.0.113.9' },
|
||||
{ via: '1.1 proxy' },
|
||||
{ forwarded: 'for=203.0.113.9' },
|
||||
{ 'x-real-ip': '203.0.113.9' },
|
||||
{ 'x-forwarded-for': '' },
|
||||
{ 'x-forwarded-for': ' ' },
|
||||
{ 'cf-connecting-ip': '' },
|
||||
]) {
|
||||
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
|
||||
const label = JSON.stringify(forwarded);
|
||||
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200, label);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200, label);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
|
||||
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
|
||||
// Cross-site stays refused whatever the forwarding headers say.
|
||||
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
|
||||
assert.equal(
|
||||
(
|
||||
await app.call(path, {
|
||||
...forwarded,
|
||||
host,
|
||||
'sec-fetch-site': 'cross-site',
|
||||
})
|
||||
).statusCode,
|
||||
403,
|
||||
`${label} ${path}`,
|
||||
);
|
||||
}
|
||||
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
|
||||
const direct = await app.call(`/frame/${STILL.id}`, {
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(direct.body, 'street-view');
|
||||
assert.equal(streetViewCalls(), 1);
|
||||
});
|
||||
@@ -0,0 +1,703 @@
|
||||
// Client `Range` handling for the CCTV media proxy. The route used to relay
|
||||
// req.headers.range verbatim to a third-party upstream; these pin what is now
|
||||
// allowed through, both as string policy and through the mounted route with an
|
||||
// injected upstream.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { proxyMediaResponse } from '../../server/providers/cctv/media.js';
|
||||
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
|
||||
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
|
||||
|
||||
test('single well-formed byte ranges pass through canonicalized', () => {
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023');
|
||||
// A single range that names the same byte twice is legal.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7');
|
||||
// The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal.
|
||||
assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99');
|
||||
});
|
||||
|
||||
test('multi-range is dropped so the upstream never answers multipart/byteranges', () => {
|
||||
// The relay forwards Content-Length/Content-Range and caps on a single
|
||||
// declared body; a multipart response defeats that accounting.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), '');
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), '');
|
||||
});
|
||||
|
||||
test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => {
|
||||
for (const bad of [
|
||||
'bytes=abc-def',
|
||||
'bytes=',
|
||||
'bytes=-',
|
||||
'bytes=1-2-3',
|
||||
'0-100', // no unit
|
||||
'items=0-100', // a unit the proxy does not understand
|
||||
'bytes=1.5-2',
|
||||
'bytes=-0', // zero-length suffix is unsatisfiable
|
||||
'bytes=0x10-0x20',
|
||||
'bytes=+5-10',
|
||||
'bytes=-5-10',
|
||||
// An outbound request refuses to carry these, and the failure used to be
|
||||
// written into the camera's health message.
|
||||
'bytes=0-10\r\nX-Injected: 1',
|
||||
'bytes=0-10\nHost: evil.example',
|
||||
]) {
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader(bad),
|
||||
'',
|
||||
`expected drop: ${JSON.stringify(bad)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-string and empty inputs yield no Range header', () => {
|
||||
// req.headers.range is undefined on a normal GET, and Node hands back an
|
||||
// array when a client sends the header twice.
|
||||
assert.equal(sanitizeCctvRangeHeader(undefined), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(null), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(''), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(' '), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(42), '');
|
||||
});
|
||||
|
||||
test('every accepted form is bounded to the relay body cap', () => {
|
||||
// Explicit span.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=0-999999999999'),
|
||||
`bytes=0-${CAP - 1}`,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=1000-999999999999'),
|
||||
`bytes=1000-${1000 + CAP - 1}`,
|
||||
);
|
||||
// Open-ended: bounded too, rather than asking an upstream for a whole file
|
||||
// of unknown size.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=500-'),
|
||||
`bytes=500-${500 + CAP - 1}`,
|
||||
);
|
||||
// Suffix: bounded to one span of the tail.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500');
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`);
|
||||
// Exactly at the cap, and comfortably under it, are untouched.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`),
|
||||
`bytes=0-${CAP - 1}`,
|
||||
);
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575');
|
||||
});
|
||||
|
||||
test('inverted and unsafe-integer positions are dropped', () => {
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), '');
|
||||
// Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), '');
|
||||
// Absurd digit counts are refused before any Number() conversion.
|
||||
assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), '');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The mounted route, with the upstream injected through globalThis.fetch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAMERA = {
|
||||
id: 'range-fixture',
|
||||
name: 'Range fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://upstream.invalid/live.mp4',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body:
|
||||
body === null
|
||||
? null
|
||||
: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
arrayBuffer: async () => Buffer.from(String(body)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider, serve one camera, and answer its upstream from the
|
||||
* supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
headers: init.headers || {},
|
||||
signal: init.signal || null,
|
||||
});
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
const plugin = cctvProxy();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
/** Drive one request through the mounted handler. */
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
/** Read the camera's current health entry. */
|
||||
const health = async () => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id);
|
||||
};
|
||||
return { call, health, requests, handler: (req, res) => handler(req, res) };
|
||||
}
|
||||
|
||||
test('a canonical range reaches the upstream and its 206 is passed through', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-1023/4096',
|
||||
'content-length': '1024',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
body: 'partial',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
|
||||
assert.equal(res.statusCode, 206);
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096');
|
||||
assert.equal(res.headers['Accept-Ranges'], 'bytes');
|
||||
assert.equal(res.body, 'partial');
|
||||
});
|
||||
|
||||
test('an unbounded seek is bounded before it is forwarded', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'tail',
|
||||
}),
|
||||
);
|
||||
await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`);
|
||||
});
|
||||
|
||||
test('a rejected range is dropped and the request proceeds without one', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-10\r\nX-Injected: 1',
|
||||
});
|
||||
// Documented choice: drop the header rather than answer 400, so a client
|
||||
// that sends something this proxy will not carry still gets its media.
|
||||
assert.equal(app.requests.at(-1).headers.Range, undefined);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body, 'whole body');
|
||||
});
|
||||
|
||||
test('a rejected range leaves the camera health report untouched', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-10\r\nX-Injected: 1',
|
||||
});
|
||||
const entry = await app.health();
|
||||
assert.equal(entry.status, 'ok');
|
||||
// The client's own string must never appear in a report other callers read.
|
||||
assert.doesNotMatch(JSON.stringify(entry), /X-Injected/);
|
||||
});
|
||||
|
||||
test('a multi-range request is served whole rather than as multipart', async (t) => {
|
||||
const app = mount(t, ({ init }) =>
|
||||
upstreamResponse({
|
||||
status: init?.headers?.Range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': init?.headers?.Range
|
||||
? 'multipart/byteranges; boundary=x'
|
||||
: 'video/mp4',
|
||||
},
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-1,2-3',
|
||||
});
|
||||
assert.equal(app.requests.at(-1).headers.Range, undefined);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
});
|
||||
|
||||
test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 416,
|
||||
headers: { 'content-range': 'bytes */1024' },
|
||||
body: null,
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: `bytes=${2 ** 40}-${2 ** 40 + 10}`,
|
||||
});
|
||||
assert.equal(res.statusCode, 416);
|
||||
assert.match(res.body, /416/);
|
||||
const entry = await app.health();
|
||||
assert.equal(entry.status, 'degraded');
|
||||
assert.equal(entry.message, 'Upstream HTTP 416');
|
||||
});
|
||||
|
||||
test('an upstream that ignores the range still serves its whole body', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '10' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body, 'whole body');
|
||||
});
|
||||
|
||||
test('an oversized declared body is refused even when a bounded range asked for it', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-length': String(CAP + 1),
|
||||
},
|
||||
body: 'too big',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.match(res.body, /exceeds size cap/);
|
||||
});
|
||||
|
||||
test('a client that disconnects mid-stream stops the upstream read', async (t) => {
|
||||
// A live feed with no end of its own, counting what it is asked to produce.
|
||||
let produced = 0;
|
||||
let stopped = false;
|
||||
const endlessBody = () =>
|
||||
Readable.toWeb(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
try {
|
||||
for (;;) {
|
||||
produced += 1;
|
||||
yield Buffer.from('frame-chunk-');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
} finally {
|
||||
stopped = true;
|
||||
}
|
||||
})(),
|
||||
),
|
||||
);
|
||||
const app = mount(t, () => ({
|
||||
ok: true,
|
||||
status: 206,
|
||||
headers: {
|
||||
get: (name) =>
|
||||
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
|
||||
},
|
||||
body: endlessBody(),
|
||||
arrayBuffer: async () => Buffer.alloc(0),
|
||||
}));
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
const failures = [];
|
||||
const onUncaught = (error) => failures.push(error);
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'),
|
||||
headers: { Range: 'bytes=0-1023' },
|
||||
},
|
||||
(response) => {
|
||||
response.once('data', () => {
|
||||
// Walk away mid-body, the way a browser does when the operator
|
||||
// switches camera.
|
||||
request.destroy();
|
||||
resolve();
|
||||
});
|
||||
response.on('error', () => resolve());
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const afterDisconnect = produced;
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
// The point: the camera host is no longer being read from. Without the
|
||||
// release this counter keeps climbing for as long as the upstream will feed.
|
||||
assert.equal(
|
||||
produced,
|
||||
afterDisconnect,
|
||||
`the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`,
|
||||
);
|
||||
assert.equal(stopped, true, 'the upstream body was never released');
|
||||
assert.deepEqual(
|
||||
failures.map((error) => error?.message),
|
||||
[],
|
||||
'a client walking away must not fault the server',
|
||||
);
|
||||
const entry = await app.health();
|
||||
// The camera was serving fine; the client left. Nothing about that is a
|
||||
// camera fault other viewers should see.
|
||||
assert.equal(entry.status, 'ok');
|
||||
});
|
||||
|
||||
test('a client that leaves before the headers arrive cancels the upstream request', async (t) => {
|
||||
// The camera is slow, or dead: nothing comes back until the request is
|
||||
// cancelled. Everything worth testing here happens before any header.
|
||||
let aborted = false;
|
||||
const app = mount(t, ({ init }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
const failures = [];
|
||||
const onUncaught = (error) => failures.push(error);
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
|
||||
const request = http.get({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/api/cctv/media/${CAMERA.id}`,
|
||||
headers: { Range: 'bytes=0-1023' },
|
||||
});
|
||||
request.on('error', () => {});
|
||||
// Let the route reach its upstream request, then walk away before it answers.
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(app.requests.length, 1, 'the upstream request went out');
|
||||
assert.equal(aborted, false, 'nothing was cancelled while the client waited');
|
||||
request.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
assert.equal(aborted, true, 'the upstream request outlived the client');
|
||||
assert.deepEqual(
|
||||
failures.map((error) => error?.message),
|
||||
[],
|
||||
'a client leaving early must not fault the server',
|
||||
);
|
||||
// The camera did nothing wrong; the viewer left.
|
||||
const entry = await app.health();
|
||||
assert.notEqual(entry?.status, 'degraded');
|
||||
});
|
||||
|
||||
test('a folded CR/LF range never reaches the upstream through a real client', async (t) => {
|
||||
// Node's own parser, not a hand-built headers object, so the reachability of
|
||||
// this shape through an ordinary HTTP request is shown rather than assumed.
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
// A client can fold a value across a continuation line; Node's parser
|
||||
// rejoins it, so what the route sees contains whitespace a header cannot
|
||||
// carry back out.
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1', () => {
|
||||
socket.write(
|
||||
`GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` +
|
||||
'Host: 127.0.0.1\r\n' +
|
||||
'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' +
|
||||
'Connection: close\r\n\r\n',
|
||||
);
|
||||
});
|
||||
let text = '';
|
||||
socket.on('data', (chunk) => {
|
||||
text += chunk.toString('utf8');
|
||||
});
|
||||
socket.on('end', () => resolve(text));
|
||||
socket.on('error', reject);
|
||||
});
|
||||
|
||||
// Either Node's parser refuses the request outright, or it reaches the route
|
||||
// and the route drops the value — but it must never be forwarded.
|
||||
const reachedRoute = app.requests.length > 0;
|
||||
assert.match(raw, /^HTTP\/1\.1 (200|400)/);
|
||||
if (reachedRoute) {
|
||||
assert.equal(
|
||||
app.requests.at(-1).headers.Range,
|
||||
undefined,
|
||||
'nothing was forwarded',
|
||||
);
|
||||
assert.match(raw, /^HTTP\/1\.1 200/);
|
||||
} else {
|
||||
assert.match(raw, /^HTTP\/1\.1 400/);
|
||||
}
|
||||
console.log(
|
||||
` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`,
|
||||
);
|
||||
const entry = await app.health();
|
||||
assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/);
|
||||
});
|
||||
|
||||
test('two sequential seeks are served as two partial responses', async (t) => {
|
||||
const app = mount(t, ({ init }) => {
|
||||
const range = init?.headers?.Range || '';
|
||||
const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || [];
|
||||
return upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': `bytes ${first}-${last}/4096`,
|
||||
'content-length': String(Number(last) - Number(first) + 1),
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
body: `chunk-${first}`,
|
||||
});
|
||||
});
|
||||
const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' });
|
||||
const second = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=100-199',
|
||||
});
|
||||
assert.equal(app.requests[0].headers.Range, 'bytes=0-99');
|
||||
assert.equal(app.requests[1].headers.Range, 'bytes=100-199');
|
||||
assert.equal(first.statusCode, 206);
|
||||
assert.equal(second.statusCode, 206);
|
||||
assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096');
|
||||
assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096');
|
||||
assert.equal(first.body, 'chunk-0');
|
||||
assert.equal(second.body, 'chunk-100');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The body idle deadline. proxyMediaResponse is driven directly here because
|
||||
// the deadline is only injectable through its options; the route always passes
|
||||
// the production value.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A response to pipe into, built on a real Writable so the backpressure the
|
||||
* proxy reads is the stream's own rather than a flag the test sets. With
|
||||
* `drains: false` the bytes are accepted and never flushed, which is what a
|
||||
* viewer on a slow link looks like from the server side.
|
||||
*/
|
||||
function pipeTarget({ drains = true } = {}) {
|
||||
const res = new Writable({
|
||||
// One byte is enough to leave a target that never empties its buffer
|
||||
// permanently in need of a drain.
|
||||
highWaterMark: drains ? undefined : 1,
|
||||
write(_chunk, _encoding, callback) {
|
||||
if (drains) callback();
|
||||
},
|
||||
});
|
||||
res.writeHead = (status, headers) => {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upstream that answers with headers, sends one chunk, and then either
|
||||
* keeps producing every `everyMs` or goes silent. `released` reports the body's
|
||||
* own cleanup: a cancelled fetch body lands in the web stream's cancel hook.
|
||||
*/
|
||||
function mediaUpstream({ everyMs = 0 } = {}) {
|
||||
const state = { produced: 0, released: false };
|
||||
let ticker = null;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = () => {
|
||||
state.produced += 1;
|
||||
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]));
|
||||
};
|
||||
send();
|
||||
if (everyMs > 0) {
|
||||
ticker = setInterval(send, everyMs);
|
||||
ticker.unref();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
state.released = true;
|
||||
if (ticker) clearInterval(ticker);
|
||||
},
|
||||
});
|
||||
state.upstream = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (name) =>
|
||||
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
|
||||
},
|
||||
body,
|
||||
arrayBuffer: async () => Buffer.alloc(0),
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
test('an upstream that goes silent after its headers is released at the idle deadline', async (t) => {
|
||||
const feed = mediaUpstream();
|
||||
const res = pipeTarget();
|
||||
t.after(() => res.destroy());
|
||||
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(feed.released, false, 'released before the deadline was due');
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
// Without the deadline this stream is held open against the camera host for
|
||||
// as long as that host will keep the socket, with no bytes ever arriving.
|
||||
assert.equal(feed.released, true, 'the silent upstream was never released');
|
||||
assert.equal(res.writableEnded, true, 'the client was left waiting');
|
||||
});
|
||||
|
||||
test('a live feed that keeps producing is not cut off by the idle deadline', async (t) => {
|
||||
const feed = mediaUpstream({ everyMs: 10 });
|
||||
const res = pipeTarget();
|
||||
t.after(() => res.destroy());
|
||||
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assert.equal(feed.released, false, 'a healthy feed was torn down');
|
||||
assert.equal(res.writableEnded, false, 'a healthy feed was ended early');
|
||||
assert.ok(
|
||||
feed.produced > 5,
|
||||
`the feed stopped producing (${feed.produced} chunks)`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a client that cannot keep up is not mistaken for a stalled upstream', async (t) => {
|
||||
// Nothing arrives from upstream while the pipe is paused, which looks exactly
|
||||
// like silence unless the response is asked whether it is still draining.
|
||||
const feed = mediaUpstream({ everyMs: 10 });
|
||||
const res = pipeTarget({ drains: false });
|
||||
t.after(() => res.destroy());
|
||||
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 30 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
assert.equal(
|
||||
res.writableNeedDrain,
|
||||
true,
|
||||
'the client was not the bottleneck',
|
||||
);
|
||||
assert.equal(feed.released, false, 'a slow viewer tore down a healthy feed');
|
||||
assert.equal(res.writableEnded, false, 'a slow viewer ended the response');
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyCataloniaCoordinate,
|
||||
toFiniteNumber,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTflSourcesFromOpenData,
|
||||
parseCataloniaXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
import { createModel } from '../layers/cctv/model.js';
|
||||
|
||||
/** Values `Number` reads as 0 (or 1) although they carry no number. */
|
||||
const ABSENT = [null, undefined, '', ' ', false, true];
|
||||
|
||||
/** Set env vars for one test and restore them afterwards. */
|
||||
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', () => {});
|
||||
}
|
||||
|
||||
/** The production browser model; only the catalog's pose pass is stubbed. */
|
||||
function browserModel() {
|
||||
const parts = {};
|
||||
parts.model = createModel({
|
||||
state: {},
|
||||
services: { focus: {} },
|
||||
parts,
|
||||
source: {},
|
||||
});
|
||||
return parts.model;
|
||||
}
|
||||
|
||||
/** The browser catalog over the production model. */
|
||||
function browserCatalog(model, cityPois = {}) {
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: cityPois } },
|
||||
parts: { model: { ...model, ensureCameraPose: () => {} } },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/cctv/sources through the real proxy middleware. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
|
||||
const { safeNumber } = browserModel();
|
||||
for (const value of ABSENT) {
|
||||
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
|
||||
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
|
||||
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
|
||||
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
|
||||
}
|
||||
for (const [value, expected] of [
|
||||
[0, 0],
|
||||
['0', 0],
|
||||
[-17, -17],
|
||||
[' 12.5 ', 12.5],
|
||||
]) {
|
||||
assert.equal(toFiniteNumber(value, 99), expected);
|
||||
assert.equal(safeNumber(value, 99), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
|
||||
quiet(t);
|
||||
const pose = {
|
||||
headingDeg: null,
|
||||
pitchDeg: null,
|
||||
fovDeg: null,
|
||||
rangeM: null,
|
||||
mountHeightM: null,
|
||||
groundElevationM: null,
|
||||
};
|
||||
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
|
||||
CCTV_SOURCES_JSON: JSON.stringify([
|
||||
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
|
||||
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
|
||||
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
|
||||
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
|
||||
]),
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
|
||||
const model = browserModel();
|
||||
const cameras = browserCatalog(model, {
|
||||
austin: { name: 'Austin', groundElevation: 150 },
|
||||
}).buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
|
||||
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
|
||||
const camera = byId.get(id);
|
||||
assert.ok(camera, id);
|
||||
assert.equal(camera.headingDeg, model.headingFromId(id), id);
|
||||
assert.equal(camera.pitchDeg, -17, id);
|
||||
assert.equal(camera.fovDeg, 74, id);
|
||||
assert.equal(camera.rangeM, 700, id);
|
||||
assert.equal(camera.mountHeightM, 24, id);
|
||||
assert.equal(camera.groundElevationM, 150, id);
|
||||
}
|
||||
// No position is not a camera in the Gulf of Guinea.
|
||||
assert.equal(byId.has('pack-coords-null'), false);
|
||||
assert.equal(byId.has('pack-coords-blank'), false);
|
||||
});
|
||||
|
||||
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, 'tallinn.json');
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'tln-null-heading',
|
||||
lat: 59.437,
|
||||
lon: 24.753,
|
||||
headingDeg: null,
|
||||
groundElevationM: null,
|
||||
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
|
||||
},
|
||||
]),
|
||||
);
|
||||
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
|
||||
|
||||
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
|
||||
assert.equal(camera.groundElevationM, 15);
|
||||
});
|
||||
|
||||
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
|
||||
quiet(t);
|
||||
const jamCam = (id, lat, lon) => ({
|
||||
id: `JamCams_${id}`,
|
||||
commonName: `JamCam ${id}`,
|
||||
lat,
|
||||
lon,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{
|
||||
key: 'imageUrl',
|
||||
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
|
||||
},
|
||||
],
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json([
|
||||
jamCam('00001.00001', 51.5072, -0.1276),
|
||||
jamCam('00001.00002', null, null),
|
||||
jamCam('00001.00003', 51.5072, ''),
|
||||
]),
|
||||
);
|
||||
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['tfl-00001.00001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
|
||||
const feature = (coordinates) => `<gml:featureMember>
|
||||
<cite:cameres fid="cameres.1">
|
||||
<gml:coordinates>${coordinates}</gml:coordinates>
|
||||
<cite:link>https://example.org/cam.jpg</cite:link>
|
||||
</cite:cameres>
|
||||
</gml:featureMember>`;
|
||||
const [blank, valid] = parseCataloniaXml(
|
||||
feature(',41.5') + feature('2.17,41.39'),
|
||||
);
|
||||
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
|
||||
assert.ok(Number.isNaN(blank.lon));
|
||||
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
|
||||
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
|
||||
});
|
||||
|
||||
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
cctv: {
|
||||
inService: 'true',
|
||||
location: {
|
||||
latitude: '37.8044',
|
||||
longitude: '-122.2712',
|
||||
elevation: '',
|
||||
locationName: 'TV101 -- I-880 : Test Ave',
|
||||
},
|
||||
imageData: {
|
||||
static: {
|
||||
currentImageURL:
|
||||
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [camera] = await loadCaltransSourcesFromOpenData();
|
||||
assert.ok(camera);
|
||||
assert.equal(camera.groundElevationM, 150);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadNswSourcesFromOpenData,
|
||||
nswCameraLabel,
|
||||
nswCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
cctvUpstreamUserAgent,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from '../../server/providers/cctv/media.js';
|
||||
import {
|
||||
NSW_CAMERAS_URL,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
|
||||
const feature = (overrides = {}, props = {}) => ({
|
||||
type: 'Feature',
|
||||
id: '5_ways_miranda',
|
||||
geometry: { type: 'Point', coordinates: [151.1036, -34.0337] },
|
||||
properties: {
|
||||
region: 'SYD_SOUTH',
|
||||
title: '5 Ways (Miranda)',
|
||||
view: '5 Ways at The Boulevarde looking west towards Sutherland.',
|
||||
direction: 'W',
|
||||
href: 'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/5_ways_miranda.jpeg',
|
||||
...props,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('NSW label prefers the view sentence and falls back to the title for works notices', () => {
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: '5 Ways (Miranda)', view: '5 Ways looking west.' }),
|
||||
'5 Ways looking west.',
|
||||
);
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: 'Victoria Pass', view: 'x'.repeat(200) }),
|
||||
'Victoria Pass',
|
||||
);
|
||||
assert.equal(nswCameraLabel({ title: 'T', view: 'line one\nline two' }), 'T');
|
||||
assert.equal(nswCameraLabel({}), '');
|
||||
});
|
||||
|
||||
test('NSW feature maps to a source with a compass heading and a pinned image host', () => {
|
||||
const source = nswCameraToSource(feature({}, { direction: 'N-E' }));
|
||||
assert.equal(source.id, 'nsw-5_ways_miranda');
|
||||
assert.equal(source.city, 'SYD SOUTH');
|
||||
assert.equal(source.provider, 'Live Traffic NSW');
|
||||
assert.equal(source.lat, -34.0337);
|
||||
assert.equal(source.lon, 151.1036);
|
||||
assert.equal(source.headingDeg, 45);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.sourceKind, 'nsw-livetraffic');
|
||||
assert.match(source.license, /CC BY 4.0/);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
});
|
||||
|
||||
test('NSW mapping rejects off-host images and unusable geometry', () => {
|
||||
assert.equal(
|
||||
nswCameraToSource(feature({}, { href: 'https://evil.example/x.jpeg' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nswCameraToSource(feature({ geometry: null })), null);
|
||||
assert.equal(nswCameraToSource(feature({ id: '' })), null);
|
||||
const noBearing = nswCameraToSource(feature({}, { direction: '' }));
|
||||
assert.equal(noBearing.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(noBearing.headingDeg));
|
||||
});
|
||||
|
||||
test('NSW loader reads the keyless feed and caps around Sydney', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature({ id: 'other' }, { href: 'https://evil.example/x.jpeg' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadNswSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [NSW_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['nsw-5_ways_miranda'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the browser User-Agent applies to the NSW image host only', async () => {
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/x.jpeg',
|
||||
),
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au.evil.test/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://example.test/webcams.transport.nsw.gov.au/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent('not a url'),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
|
||||
const seen = [];
|
||||
await fetchCctvImageFromUpstream(
|
||||
'https://cctv.austinmobility.io/image/1.jpg',
|
||||
{
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push(init.headers['User-Agent']);
|
||||
return new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(seen, ['gods-eye-view-cctv-proxy/1.0']);
|
||||
});
|
||||
|
||||
test('the frame path follows redirects within the host only', async () => {
|
||||
const jpeg = () =>
|
||||
new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
const redirect = (location) =>
|
||||
new Response(null, { status: 302, headers: { location } });
|
||||
const start = 'https://cctv.austinmobility.io/image/1.jpg';
|
||||
|
||||
const sameHost = [];
|
||||
const followed = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
assert.equal(init.redirect, 'manual', 'the fetch itself never follows');
|
||||
sameHost.push(url);
|
||||
return sameHost.length === 1 ? redirect('/image/moved/1.jpg') : jpeg();
|
||||
},
|
||||
});
|
||||
assert.equal(followed?.ok, true);
|
||||
assert.deepEqual(sameHost, [
|
||||
start,
|
||||
'https://cctv.austinmobility.io/image/moved/1.jpg',
|
||||
]);
|
||||
|
||||
const offHost = [];
|
||||
const refused = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
offHost.push(url);
|
||||
return redirect('https://evil.example/1.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(refused, null);
|
||||
assert.deepEqual(offHost, [start], 'the off-host target is never requested');
|
||||
|
||||
// Same host on another port, a plaintext downgrade, and a protocol-relative
|
||||
// other host are all a different origin and are refused.
|
||||
for (const target of [
|
||||
'https://cctv.austinmobility.io:8443/image/1.jpg',
|
||||
'http://cctv.austinmobility.io/image/1.jpg',
|
||||
'//evil.example/1.jpg',
|
||||
]) {
|
||||
const seen = [];
|
||||
const result = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
seen.push(url);
|
||||
return redirect(target);
|
||||
},
|
||||
});
|
||||
assert.equal(result, null, target);
|
||||
assert.deepEqual(seen, [start], target);
|
||||
}
|
||||
|
||||
let hops = 0;
|
||||
const looped = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => {
|
||||
hops += 1;
|
||||
return redirect('/image/again.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(looped, null);
|
||||
assert.equal(hops, 3, 'at most two redirect hops are followed');
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.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 };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
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, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
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 loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
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(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_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 loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
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 === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
+279
-2
@@ -1,9 +1,27 @@
|
||||
import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_MAX_BODY_BYTES, CCTV_MEDIA_FETCH_TIMEOUT_MS, CCTV_MEDIA_MAX_BODY_BYTES } from '../../server/providers/cctv/constants.js';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from '../../vite.config.js';
|
||||
fetchCctvMediaUpstream,
|
||||
} from '../../server/providers/cctv/media.js';
|
||||
|
||||
/** A body that arrives in chunks and never declares a Content-Length. */
|
||||
function chunkedImageResponse(chunkBytes, chunkCount, { onChunk = () => {}, onCancel = () => {} } = {}) {
|
||||
const stream = new ReadableStream({
|
||||
async pull(controller) {
|
||||
if (chunkCount <= 0) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
chunkCount -= 1;
|
||||
onChunk();
|
||||
controller.enqueue(new Uint8Array(chunkBytes));
|
||||
},
|
||||
cancel: onCancel,
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: { 'Content-Type': 'image/jpeg' } });
|
||||
}
|
||||
|
||||
test('CCTV upstream frame fetch supplies a bounded abort signal', async () => {
|
||||
let observedSignal = null;
|
||||
@@ -36,3 +54,262 @@ test('CCTV upstream frame fetch returns a valid image response', async () => {
|
||||
assert.equal(result?.contentType, 'image/jpeg');
|
||||
assert.deepEqual(result?.body, Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch rejects a declared oversize body without draining it', async () => {
|
||||
const chunkBytes = 64 * 1024;
|
||||
let pulled = 0;
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: chunkBytes * 4,
|
||||
fetchImpl: async () => {
|
||||
const response = chunkedImageResponse(chunkBytes, 64, {
|
||||
onChunk: () => { pulled += 1; },
|
||||
onCancel: () => { cancelled = true; },
|
||||
});
|
||||
response.headers.set('Content-Length', String(chunkBytes * 64));
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
// Assert on the byte count, not the object: a regressed proxy returns a
|
||||
// multi-megabyte Buffer here, and diffing one into the failure report is
|
||||
// slower than the check it is reporting on.
|
||||
assert.equal(result?.body?.length ?? null, null, 'a declared oversize snapshot is a miss, not a buffered body');
|
||||
assert.equal(cancelled, true, 'the declared cap is enforced by cancelling, not reading');
|
||||
// Only the stream's own one-chunk prefetch may have run; the proxy pulls none.
|
||||
assert.ok(pulled <= 1, `declared cap short-circuits the read, pulled ${pulled} chunks`);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch aborts an undeclared body once it crosses the cap', async () => {
|
||||
const chunkBytes = 64 * 1024;
|
||||
let pulled = 0;
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: chunkBytes * 4,
|
||||
// Sixty-four chunks are offered with no Content-Length; a proxy that
|
||||
// buffers first would take all of them.
|
||||
fetchImpl: async () => chunkedImageResponse(chunkBytes, 64, {
|
||||
onChunk: () => { pulled += 1; },
|
||||
onCancel: () => { cancelled = true; },
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result?.body?.length ?? null, null, 'a chunked body over the cap is a miss');
|
||||
// Four chunks fit, the fifth crosses the cap, and one more sits in the
|
||||
// stream's prefetch queue — nothing past that is ever pulled.
|
||||
assert.ok(pulled <= 6, `stopped reading at the cap, pulled ${pulled} chunks`);
|
||||
assert.equal(cancelled, true, 'the upstream stream is cancelled, not drained');
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch still returns a chunked body under the cap', async () => {
|
||||
const chunkBytes = 1024;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: chunkBytes * 8,
|
||||
fetchImpl: async () => chunkedImageResponse(chunkBytes, 3),
|
||||
});
|
||||
|
||||
assert.equal(result?.ok, true);
|
||||
assert.equal(result?.body?.length, chunkBytes * 3, 'every chunk is reassembled in order');
|
||||
assert.ok(CCTV_FRAME_MAX_BODY_BYTES > 0 && CCTV_FRAME_MAX_BODY_BYTES <= CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
'the frame cap must be at or under the media route cap — pinned against the real constant');
|
||||
});
|
||||
|
||||
test('CCTV media upstream fetch aborts when response headers never arrive', async () => {
|
||||
let observedSignal = null;
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
fetchCctvMediaUpstream('https://example.com/stream.m3u8', {
|
||||
timeoutMs: 25,
|
||||
fetchImpl: (url, { signal }) => new Promise((resolve, reject) => {
|
||||
observedSignal = signal;
|
||||
signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
||||
}),
|
||||
}),
|
||||
(error) => error.name === 'AbortError',
|
||||
);
|
||||
assert.equal(observedSignal?.aborted, true, 'the header deadline aborts the attempt');
|
||||
assert.ok(Date.now() - startedAt < 2_000, 'the deadline is the injected one, not the production one');
|
||||
assert.ok(CCTV_MEDIA_FETCH_TIMEOUT_MS >= 10_000, 'production keeps a generous header deadline for slow cameras');
|
||||
});
|
||||
|
||||
test('CCTV media upstream fetch never cuts a stream whose headers arrived in time', async () => {
|
||||
let observedSignal = null;
|
||||
const upstream = await fetchCctvMediaUpstream('https://example.com/stream.m3u8', {
|
||||
timeoutMs: 20,
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
observedSignal = signal;
|
||||
return { ok: true, status: 200, headers: new Map([['content-type', 'video/mp2t']]), body: null };
|
||||
},
|
||||
});
|
||||
assert.equal(upstream.ok, true);
|
||||
// Well past the header deadline: the timer was cleared at header arrival,
|
||||
// so the live body is still allowed to flow.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
assert.equal(observedSignal?.aborted, false, 'a slow body after timely headers is never aborted here');
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch keeps a body that lands exactly on the cap', async () => {
|
||||
const chunkBytes = 512;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: chunkBytes * 4,
|
||||
fetchImpl: async () => chunkedImageResponse(chunkBytes, 4),
|
||||
});
|
||||
assert.equal(result?.ok, true, 'exactly-at-cap is under the ceiling, not over it');
|
||||
assert.equal(result?.body?.length, chunkBytes * 4);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch ignores a garbage Content-Length and still caps the stream', async () => {
|
||||
const chunkBytes = 1024;
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: chunkBytes * 2,
|
||||
fetchImpl: async () => {
|
||||
const response = chunkedImageResponse(chunkBytes, 8, { onCancel: () => { cancelled = true; } });
|
||||
response.headers.set('Content-Length', 'not-a-number');
|
||||
return response;
|
||||
},
|
||||
});
|
||||
assert.equal(result?.body?.length ?? null, null, 'an unparseable length falls back to counting bytes');
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch retains owned bytes, not the chunk\'s backing allocation', async () => {
|
||||
const backing = new ArrayBuffer(4 * 1024 * 1024);
|
||||
const view = new Uint8Array(backing, 8, 3);
|
||||
view.set([7, 8, 9]);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () => new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(view);
|
||||
controller.close();
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'image/jpeg' } }),
|
||||
});
|
||||
assert.equal(result?.ok, true);
|
||||
assert.deepEqual(Array.from(result.body), [7, 8, 9]);
|
||||
assert.ok(result.body.buffer.byteLength < backing.byteLength,
|
||||
'the 4 MB backing allocation is not retained behind a 3-byte frame');
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch refuses a body it cannot stream instead of buffering it uncapped', async () => {
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 1000,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([['content-type', 'image/jpeg']]),
|
||||
body: { /* neither async-iterable nor reader-backed */ },
|
||||
arrayBuffer: async () => { throw new Error('arrayBuffer must never be used as an uncapped fallback'); },
|
||||
}),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('reader-only snapshots release their lock on success, overflow and read failure', async () => {
|
||||
for (const mode of ['success', 'overflow', 'failure']) {
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (mode === 'failure') controller.error(new Error('read failed'));
|
||||
else { controller.enqueue(new Uint8Array(4)); controller.close(); }
|
||||
},
|
||||
});
|
||||
Object.defineProperty(body, Symbol.asyncIterator, { value: undefined });
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
maxBytes: mode === 'overflow' ? 2 : 8,
|
||||
fetchImpl: async () => ({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }), body }),
|
||||
});
|
||||
assert.equal(body.locked, false, mode);
|
||||
assert.equal(result?.body.length ?? null, mode === 'success' ? 4 : null);
|
||||
}
|
||||
});
|
||||
|
||||
test('the snapshot deadline remains active after headers while reading a stalled body', async () => {
|
||||
let signal;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 20,
|
||||
fetchImpl: async (_url, init) => {
|
||||
signal = init.signal;
|
||||
return new Response(new ReadableStream({ start(controller) {
|
||||
signal.addEventListener('abort', () => controller.error(signal.reason), { once: true });
|
||||
}}), { headers: { 'content-type': 'image/jpeg' } });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.equal(signal.aborted, true);
|
||||
});
|
||||
|
||||
test('rejected snapshot responses abort the upstream download', async () => {
|
||||
for (const [status, contentType] of [[503, 'image/jpeg'], [200, 'text/html']]) {
|
||||
let signal;
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async (_url, init) => {
|
||||
signal = init.signal;
|
||||
return new Response('rejected', { status, headers: { 'content-type': contentType } });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.equal(signal.aborted, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
|
||||
const frame = (contentType, body = 'frame') => {
|
||||
const state = { cancelled: false };
|
||||
const headers = contentType === null ? {} : { 'Content-Type': contentType };
|
||||
const response = new Response(new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
}, { highWaterMark: 0 }), { status: 200, headers });
|
||||
return { response, state };
|
||||
};
|
||||
for (const [contentType, canonical] of [
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/JPEG; charset=x', 'image/jpeg'],
|
||||
[' Image/PNG ', 'image/png'],
|
||||
['image/gif', 'image/gif'],
|
||||
['image/webp;q=1', 'image/webp'],
|
||||
['image/avif', 'image/avif'],
|
||||
]) {
|
||||
const { response } = frame(contentType);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result?.contentType, canonical, contentType);
|
||||
assert.equal(result.body.toString(), 'frame');
|
||||
}
|
||||
for (const [contentType, body] of [
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
|
||||
['application/xml', '<x/>'],
|
||||
['text/xml', '<x/>'],
|
||||
[null, 'frame'],
|
||||
['', 'frame'],
|
||||
['image', 'frame'],
|
||||
['image/', 'frame'],
|
||||
['image/jpeg/x', 'frame'],
|
||||
['image/jpeg, text/html', 'frame'],
|
||||
['application/octet-stream', 'frame'],
|
||||
]) {
|
||||
const { response, state } = frame(contentType, body);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result, null, String(contentType));
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Camera catalogs are pinned to their agency's image directory. The pin is a
|
||||
// parsed-URL check, not a string prefix, so a path that only starts with the
|
||||
// directory before dot segments, an encoded separator, credentials or another
|
||||
// port are resolved never counts as inside it, and a redirect on a pinned host
|
||||
// cannot leave the directory either.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
|
||||
/** One available JamCam whose frame is at `imageUrl`. */
|
||||
const jamCam = (n, imageUrl) => ({
|
||||
id: `JamCams_00001.0000${n}`,
|
||||
commonName: `JamCam ${n}`,
|
||||
lat: 51.5072,
|
||||
lon: -0.1276,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{ key: 'imageUrl', value: imageUrl },
|
||||
],
|
||||
});
|
||||
|
||||
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const accepted = [
|
||||
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
|
||||
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
|
||||
[
|
||||
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
|
||||
`${BUCKET}00001.00003.jpg`,
|
||||
],
|
||||
];
|
||||
const rejected = [
|
||||
`${BUCKET}../other/x.jpg`,
|
||||
`${BUCKET}%2e%2e/other/x.jpg`,
|
||||
`${BUCKET}%2E%2E/other/x.jpg`,
|
||||
`${BUCKET}.%2e/other/x.jpg`,
|
||||
`${BUCKET}..%2fother/x.jpg`,
|
||||
`${BUCKET}..%5Cother/x.jpg`,
|
||||
`${BUCKET}..\\other\\x.jpg`,
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
|
||||
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
|
||||
'not a url',
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
|
||||
jamCam(i + 1, url),
|
||||
),
|
||||
),
|
||||
);
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.url),
|
||||
accepted.map(([, normalized]) => normalized),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === `${BUCKET}00001.00001.jpg`
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/../other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo(location),
|
||||
),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
// A host with no pinned directory keeps the same-origin rule alone.
|
||||
const unpinned = await fetchWithinHost(
|
||||
'https://cams.example/a/frame.jpg',
|
||||
{},
|
||||
async (url) =>
|
||||
url === 'https://cams.example/a/frame.jpg'
|
||||
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
|
||||
: new Response('frame'),
|
||||
);
|
||||
assert.equal(await unpinned.text(), 'frame');
|
||||
});
|
||||
|
||||
test('a pin prefix without a trailing slash still names a directory', () => {
|
||||
assert.equal(
|
||||
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTaiwanSources,
|
||||
parseTaiwanCctvXml,
|
||||
resetTaiwanSourcesCache,
|
||||
taiwanCameraToSource,
|
||||
taiwanFrame,
|
||||
TAIWAN_FREEWAY_CCTV_URL,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
TAIWAN_RETRY_AFTER_FAILURE_MS,
|
||||
} from '../../server/providers/cctv/taiwan.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
// Settings that would steer the loader or the catalog away from the mocks.
|
||||
const ISOLATED_ENV = [
|
||||
'CCTV_SOURCES_FILE',
|
||||
'CCTV_SOURCES_JSON',
|
||||
'CCTV_PREFER_AUSTIN',
|
||||
'CCTV_FORCE_AUSTIN',
|
||||
'CCTV_TAIWAN_ENABLED',
|
||||
'CCTV_TAIWAN_URL',
|
||||
'CCTV_TAIWAN_MAX_SOURCES',
|
||||
];
|
||||
|
||||
let savedEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaiwanSourcesCache();
|
||||
savedEnv = Object.fromEntries(
|
||||
ISOLATED_ENV.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
for (const key of ISOLATED_ENV) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function cctvXml(...blocks) {
|
||||
return `<?xml version='1.0' encoding='utf-8'?>
|
||||
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
|
||||
<AuthorityCode>NFB</AuthorityCode>
|
||||
<CCTVs>${blocks.join('')}</CCTVs>
|
||||
</CCTVList>`;
|
||||
}
|
||||
|
||||
function block({
|
||||
id = 'CCTV-N1-S-0.000-M',
|
||||
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
lat = '25.1229931',
|
||||
lon = '121.735695',
|
||||
} = {}) {
|
||||
return `<CCTV>
|
||||
<CCTVID>${id}</CCTVID>
|
||||
<VideoStreamURL>${stream}</VideoStreamURL>
|
||||
<PositionLon>${lon}</PositionLon>
|
||||
<PositionLat>${lat}</PositionLat>
|
||||
<RoadName>國道1號</RoadName>
|
||||
<RoadDirection>S</RoadDirection>
|
||||
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
|
||||
<LocationMile>0K+000</LocationMile>
|
||||
</CCTV>`;
|
||||
}
|
||||
|
||||
test('parses the MOTC 2.0 table and registers the same camera still', () => {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block()));
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(camera.lat, 25.1229931);
|
||||
assert.equal(camera.lon, 121.735695);
|
||||
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
|
||||
assert.equal(camera.cityId, 'taiwan');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
camera.url,
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.match(camera.license, /Open Government Data License, Taiwan/);
|
||||
});
|
||||
|
||||
test('each camera server maps its stream path to its own still path only', () => {
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
|
||||
).frameUrl,
|
||||
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
).frameUrl,
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
|
||||
).frameUrl,
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
|
||||
);
|
||||
});
|
||||
|
||||
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
|
||||
for (const stream of [
|
||||
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
|
||||
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(taiwanFrame(stream), null, stream);
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
|
||||
assert.equal(taiwanCameraToSource(row), null, stream);
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
|
||||
for (const [lat, lon] of [
|
||||
['121.735695', '25.1229931'],
|
||||
['35.68', '139.76'],
|
||||
['', '121.7'],
|
||||
['NaN', '121.7'],
|
||||
]) {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
|
||||
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
|
||||
}
|
||||
assert.equal(taiwanCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
|
||||
const [row] = parseTaiwanCctvXml(
|
||||
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })),
|
||||
);
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
|
||||
});
|
||||
|
||||
function mockCatalog(t, xml) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return new Response(xml, {
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
mockCatalog(
|
||||
t,
|
||||
cctvXml(
|
||||
block(),
|
||||
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
|
||||
block({
|
||||
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
|
||||
lat: '24.1',
|
||||
lon: '120.6',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
|
||||
'tw-freeway-cctvc-495',
|
||||
'tw-freeway-cctvn-10000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(cctvXml(block()), {
|
||||
status,
|
||||
headers: status === 302 ? { location: 'https://evil.test/' } : {},
|
||||
}),
|
||||
);
|
||||
resetTaiwanSourcesCache();
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
}
|
||||
});
|
||||
|
||||
async function runCatalogWithTaiwanFlag(t, flag) {
|
||||
const requested = mockCatalog(t, cctvXml(block()));
|
||||
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
}
|
||||
|
||||
test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
|
||||
'taiwan',
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block()) + padding),
|
||||
);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
resetTaiwanSourcesCache();
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
});
|
||||
|
||||
test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block())),
|
||||
);
|
||||
const first = await loadTaiwanSources();
|
||||
assert.deepEqual(
|
||||
first.map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await loadTaiwanSources()).map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const rows = Array.from({ length: 3 }, (_, i) =>
|
||||
block({
|
||||
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
|
||||
}),
|
||||
);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(...rows)),
|
||||
);
|
||||
for (const [value, expected] of [
|
||||
['0', 1],
|
||||
['2', 2],
|
||||
['5000', 3],
|
||||
['abc', 3],
|
||||
]) {
|
||||
resetTaiwanSourcesCache();
|
||||
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
|
||||
const sources = await loadTaiwanSources();
|
||||
assert.equal(sources.length, expected, value);
|
||||
assert.equal(sources.available, 3, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('names fall back to the published ID when the row has no road details', () => {
|
||||
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
|
||||
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
|
||||
.replace('<LocationMile>0K+000</LocationMile>', '')
|
||||
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
|
||||
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
|
||||
assert.equal(camera.name, 'CCTV-N3-N-100-M');
|
||||
});
|
||||
|
||||
test('numeric character references in the table are decoded', () => {
|
||||
const xml = cctvXml(block()).replace(
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
);
|
||||
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
|
||||
});
|
||||
|
||||
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
|
||||
const camera = taiwanCameraToSource(
|
||||
parseTaiwanCctvXml(
|
||||
cctvXml(
|
||||
block({
|
||||
stream:
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
|
||||
}),
|
||||
),
|
||||
)[0],
|
||||
);
|
||||
assert.equal(
|
||||
camera.id,
|
||||
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseRescuCameraList,
|
||||
torontoCameraToSource,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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('j'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
Number: '8048',
|
||||
Name: 'BATHURST ST & BLOOR ST W',
|
||||
Latitude: '43.665249',
|
||||
Longitude: '-79.41114',
|
||||
D1: 'n',
|
||||
D2: 'e',
|
||||
D3: 's',
|
||||
D4: 'w',
|
||||
Group: 'Arterial',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** The city wraps the list in a JSONP callback; this is that envelope. */
|
||||
const jsonp = (rows) =>
|
||||
`jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`;
|
||||
|
||||
test('a RESCU row maps to a source on the constructed frame URL', () => {
|
||||
const source = torontoCameraToSource(row());
|
||||
assert.equal(source.id, 'toronto-8048');
|
||||
assert.equal(source.name, 'BATHURST ST & BLOOR ST W');
|
||||
assert.equal(source.city, 'Toronto');
|
||||
assert.equal(source.cityId, 'toronto');
|
||||
assert.equal(source.provider, 'City of Toronto');
|
||||
assert.equal(source.lat, 43.665249);
|
||||
assert.equal(source.lon, -79.41114);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'toronto-rescu-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Contains information licensed under the Open Government Licence – Toronto',
|
||||
);
|
||||
assert.equal(
|
||||
source.url,
|
||||
`${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`,
|
||||
'the documented loc####.jpg convention',
|
||||
);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('the frame URL is built from the camera number, never read from the payload', () => {
|
||||
// The list carries no URL field at all, so an added one must stay inert: the
|
||||
// frame proxy is pinned to the city origin by construction.
|
||||
const planted = torontoCameraToSource(
|
||||
row({
|
||||
url: 'https://evil.example/x.jpg',
|
||||
snapshotUrl: 'https://evil.example/x.jpg',
|
||||
Url: 'https://evil.example/x.jpg',
|
||||
}),
|
||||
);
|
||||
assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`);
|
||||
assert.equal(planted.snapshotUrl, planted.url);
|
||||
|
||||
// A number that is not plain digits cannot reach the URL builder: path
|
||||
// traversal and absolute URLs are refused outright rather than escaped.
|
||||
for (const Number_ of [
|
||||
'../../etc/passwd',
|
||||
'8048/../../x',
|
||||
'https://evil.example/1',
|
||||
'80 48',
|
||||
'8048.jpg',
|
||||
'-8048',
|
||||
'',
|
||||
' ',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Number: Number_ })),
|
||||
null,
|
||||
`refused: ${String(Number_)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the comparison directions never become a camera heading', () => {
|
||||
// D1-D4 are the city's *comparison image* directions (static 2016 reference
|
||||
// stills served from ComparisonImages/), not a facing for the live frame:
|
||||
// these cameras pan. The letters read like a compass and map trivially onto
|
||||
// one (n -> 0, w -> 270), so the temptation to wire them up is real — and a
|
||||
// heading derived from them would be confidently wrong for every camera in
|
||||
// the city. Nobody may "fix" this.
|
||||
const base = torontoCameraToSource(row());
|
||||
for (const dirs of [
|
||||
{ D1: 'n', D2: 'e', D3: 's', D4: 'w' },
|
||||
{ D1: '', D2: 'e', D3: '', D4: 'w' },
|
||||
{ D1: '', D2: '', D3: '', D4: '' },
|
||||
{ D1: 's', D2: '', D3: '', D4: '' },
|
||||
]) {
|
||||
const source = torontoCameraToSource(row(dirs));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
// Same id, same fallback heading, whatever the directions say.
|
||||
assert.equal(source.headingDeg, base.headingDeg);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two cameras at one intersection are kept apart', () => {
|
||||
// 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They
|
||||
// are distinct units, so both survive, and the id-hash fallback fans their
|
||||
// gizmos apart instead of stacking them on one bearing.
|
||||
const a = torontoCameraToSource(
|
||||
row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }),
|
||||
);
|
||||
const b = torontoCameraToSource(row());
|
||||
assert.equal(a.id, 'toronto-8047');
|
||||
assert.equal(b.id, 'toronto-8048');
|
||||
assert.notEqual(a.url, b.url);
|
||||
assert.notEqual(a.headingDeg, b.headingDeg);
|
||||
});
|
||||
|
||||
test('rows outside Toronto and unusable geometry are dropped', () => {
|
||||
// Calgary: a plausible lat/lon, but not a Toronto camera.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })),
|
||||
null,
|
||||
);
|
||||
// Null island, and the empty strings that would become it.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '', Longitude: '' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(torontoCameraToSource({}), null);
|
||||
assert.equal(torontoCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
const source = torontoCameraToSource(row({ Name: ' ' }));
|
||||
assert.equal(source.name, 'Toronto Camera 8048');
|
||||
});
|
||||
|
||||
test('the JSONP envelope is unwrapped, and only that envelope', () => {
|
||||
assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]);
|
||||
// Whitespace and a missing trailing semicolon are both tolerated.
|
||||
assert.equal(
|
||||
parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length,
|
||||
1,
|
||||
);
|
||||
// Should the city ever drop the wrapper, the bare document still parses.
|
||||
assert.equal(
|
||||
parseRescuCameraList(JSON.stringify({ Data: [row()] })).length,
|
||||
1,
|
||||
);
|
||||
|
||||
// Anything else yields no cameras rather than a thrown loader.
|
||||
for (const text of [
|
||||
'otherCallback({"Data":[]})',
|
||||
'jsonTMCEarthCamerasCallback(',
|
||||
'jsonTMCEarthCamerasCallback({"Data":',
|
||||
'{"Data":"not-an-array"}',
|
||||
'{"nope":[]}',
|
||||
'<html>503</html>',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the loader reads the keyless list and collapses duplicate numbers', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
jsonp([
|
||||
row(),
|
||||
// Same camera number twice: one camera, not two.
|
||||
row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }),
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row({ Number: '../evil' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
const cameras = await loadTorontoSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor
|
||||
// is three kilometres out.
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
);
|
||||
assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W');
|
||||
});
|
||||
|
||||
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 loadTorontoSourcesFromOpenData(), []);
|
||||
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 loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the list 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/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[TORONTO_RESCU_LIST_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 () => {
|
||||
return new Response(jsonp([row()]), {
|
||||
headers: {
|
||||
'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
|
||||
// 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(TORONTO_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W');
|
||||
const long = torontoCameraToSource(
|
||||
row({
|
||||
Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY'));
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping Toronto', () => {
|
||||
// Toronto 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('calgary', 215), lane('toronto', 336)],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['toronto', 10],
|
||||
],
|
||||
);
|
||||
// Toronto contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `toronto-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the RESCU list to the Toronto 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 === TORONTO_RESCU_LIST_URL) {
|
||||
return new Response(
|
||||
jsonp([
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the pack is registered, so its cameras reach the served catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_TORONTO_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
'the registered lane reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto').map((s) => s.id),
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
'Toronto 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_TORONTO_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_TORONTO_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto'),
|
||||
[],
|
||||
);
|
||||
} 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);
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTxdotSourcesFromOpenData,
|
||||
normalizeTxdotDistrictPayload,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { fetchTxdotSnapshot } from '../../server/providers/cctv/media.js';
|
||||
import { TXDOT_CCTV_STATUS_URL } from '../../server/providers/cctv/constants.js';
|
||||
|
||||
/** Minimal valid JPEG head: SOI marker plus a byte of payload. */
|
||||
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
|
||||
const JPEG_B64 = JPEG_BYTES.toString('base64');
|
||||
|
||||
/** One "Device Online" record in the shape GetCctvStatusListByDistrict returns. */
|
||||
function cameraRow(overrides = {}) {
|
||||
return {
|
||||
icd_Id: 'FM-734 @ US-290 EB',
|
||||
name: 'FM-734 @ US-290 EB',
|
||||
latitude: 30.34396,
|
||||
longitude: -97.57988,
|
||||
statusDescription: 'Device Online',
|
||||
hasSnapshot: true,
|
||||
dirDescription: 'North',
|
||||
equipLoc: { roadway: 'FM-734', direction: 'North' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const districtPayload = (rows) => ({
|
||||
roadwayCctvStatuses: { 'FM-734': rows },
|
||||
});
|
||||
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
test('TxDOT catalog keeps only online cameras with finite coordinates', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'a', name: 'a' }),
|
||||
cameraRow({
|
||||
icd_Id: 'b',
|
||||
name: 'b',
|
||||
statusDescription: 'Device Offline',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'c', name: 'c', statusDescription: 'Device Error' }),
|
||||
cameraRow({ icd_Id: 'd', name: 'd', latitude: null }),
|
||||
cameraRow({ icd_Id: 'e', name: 'e', longitude: 'not-a-number' }),
|
||||
cameraRow({ icd_Id: 'f', name: 'f', latitude: 0, longitude: 0 }),
|
||||
cameraRow({
|
||||
icd_Id: 'g',
|
||||
name: 'g',
|
||||
latitude: '30.1',
|
||||
longitude: '-97.7',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'h', name: 'h', hasSnapshot: false }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].name, 'a');
|
||||
assert.equal(cameras[0].provider, 'TxDOT');
|
||||
assert.equal(cameras[0].feedType, 'image');
|
||||
assert.equal(cameras[0].sourceKind, 'txdot-its');
|
||||
assert.equal(cameras[0].license, 'Public TxDOT traffic camera data');
|
||||
assert.equal(
|
||||
cameras[0].url,
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=a&districtCode=AUS',
|
||||
);
|
||||
assert.equal(
|
||||
cameras[0].id,
|
||||
`txdot-aus-${Buffer.from('a').toString('base64url')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT ids stay distinct for distinct device keys, whatever they hash or slug to', () => {
|
||||
const ids = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
// These two share a 32-bit FNV hash.
|
||||
cameraRow({ icd_Id: 'costarring', name: 'costarring' }),
|
||||
cameraRow({ icd_Id: 'liquid', name: 'liquid' }),
|
||||
// These two share a lowercase slug and a hash.
|
||||
cameraRow({ icd_Id: 'abcDEfaBCDEFAbcdEFABCdEfAbcdeFaB', name: 'x' }),
|
||||
cameraRow({ icd_Id: 'ABcDeFABCDefaBCDeFAbCdeFabcdefAB', name: 'y' }),
|
||||
]),
|
||||
'AUS',
|
||||
).map((camera) => camera.id);
|
||||
assert.equal(new Set(ids).size, 4);
|
||||
assert.equal(
|
||||
Buffer.from(ids[0].replace(/^txdot-aus-/, ''), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
'costarring',
|
||||
'the device key is recoverable from the id',
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT catalog dedupes a camera listed under two roadways', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
{
|
||||
roadwayCctvStatuses: {
|
||||
'IH-35': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
'SH-71': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
},
|
||||
},
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
|
||||
test('TxDOT heading comes from an explicit travel token, never the roadway direction', () => {
|
||||
const [eastbound, westbound, plain] = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'eb', name: 'FM-734 @ US-290 EB' }),
|
||||
cameraRow({ icd_Id: 'wb', name: 'FM-734 @ US-290 WB' }),
|
||||
cameraRow({ icd_Id: 'plain', name: 'FM-734 @ Bellingham Dr' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(eastbound.headingDeg, 90);
|
||||
assert.equal(eastbound.headingConfidence, 'high');
|
||||
assert.equal(westbound.headingDeg, 270);
|
||||
assert.equal(westbound.headingConfidence, 'high');
|
||||
assert.equal(plain.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(plain.headingDeg));
|
||||
});
|
||||
|
||||
test('TxDOT heading refuses bare cardinals in Texas route names', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'lamar', name: 'N Lamar Blvd @ Rundberg Ln' }),
|
||||
cameraRow({ icd_Id: 'west', name: 'West Ave @ 6th St' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.equal(
|
||||
camera.headingConfidence,
|
||||
'low',
|
||||
`${camera.name} must not claim a heading`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT camera ids are district-scoped and stable across runs', () => {
|
||||
const build = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.id;
|
||||
assert.equal(build('AUS'), build('AUS'));
|
||||
assert.ok(build('AUS').startsWith('txdot-aus-'));
|
||||
assert.ok(build('HOU').startsWith('txdot-hou-'));
|
||||
assert.notEqual(build('AUS'), build('HOU'));
|
||||
});
|
||||
|
||||
test('TxDOT ground elevation uses a per-district prior', () => {
|
||||
const at = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.groundElevationM;
|
||||
assert.equal(at('HOU'), 15);
|
||||
assert.equal(at('ELP'), 1140);
|
||||
assert.equal(at('AUS'), 149);
|
||||
assert.equal(at('SAT'), 198);
|
||||
});
|
||||
|
||||
test('TxDOT catalog tolerates a malformed payload', () => {
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload(null, 'AUS'), []);
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload({}, 'AUS'), []);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload({ roadwayCctvStatuses: [] }, 'AUS'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload(
|
||||
{ roadwayCctvStatuses: { 'IH-35': null } },
|
||||
'AUS',
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT loader fetches each configured district and ignores unknown codes', async (t) => {
|
||||
quiet(t);
|
||||
const saved = process.env.CCTV_TXDOT_DISTRICTS;
|
||||
process.env.CCTV_TXDOT_DISTRICTS = 'aus, SAT, NOPE, aus';
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const district = new URL(String(url)).searchParams.get('districtCode');
|
||||
return Response.json(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: `${district}-1`, name: `${district}-1` }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
try {
|
||||
const cameras = await loadTxdotSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [
|
||||
TXDOT_CCTV_STATUS_URL('AUS'),
|
||||
TXDOT_CCTV_STATUS_URL('SAT'),
|
||||
]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.cityId).sort(), [
|
||||
'tx-aus',
|
||||
'tx-sat',
|
||||
]);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_TXDOT_DISTRICTS;
|
||||
else process.env.CCTV_TXDOT_DISTRICTS = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT snapshot decodes JSON only from the official origin and only as JPEG', async () => {
|
||||
const jsonResponse = (body) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
const official =
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x&districtCode=AUS';
|
||||
|
||||
const frame = await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
});
|
||||
assert.equal(frame?.ok, true);
|
||||
assert.equal(frame?.contentType, 'image/jpeg');
|
||||
assert.deepEqual(frame?.body, JPEG_BYTES);
|
||||
|
||||
// Off-origin with the right path, and right origin with the wrong path,
|
||||
// are each refused on their own.
|
||||
for (const bad of [
|
||||
'https://evil.example/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x',
|
||||
'https://its.txdot.gov/its/DistrictIts/Other?icdId=x',
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(bad, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
}),
|
||||
null,
|
||||
bad,
|
||||
);
|
||||
}
|
||||
// A redirect is never followed, so the pin holds for the request made.
|
||||
let calls = 0;
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (_url, init) => {
|
||||
calls += 1;
|
||||
assert.equal(init.redirect, 'manual');
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/x.json' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
// Junk base64 and oversize bodies are refused before decoding.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9!@#j/4AA=' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// Malformed padding is refused even though Buffer.from() would decode it.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9j/4AA==' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// A real JPEG larger than the frame cap is refused after decoding...
|
||||
const bigJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(59, 0x11)]);
|
||||
assert.equal(bigJpeg.length, 64);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 64,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
})
|
||||
)?.body.length,
|
||||
64,
|
||||
'the same frame passes when it fits the cap',
|
||||
);
|
||||
// ...and an envelope that overflows the streaming cap never gets parsed.
|
||||
const hugeJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(8192, 0x11)]);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: hugeJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const notJpeg = Buffer.from('<html>nope</html>').toString('base64');
|
||||
for (const body of [
|
||||
null,
|
||||
{ snippet: null },
|
||||
{ snippet: '' },
|
||||
{ snippet: notJpeg },
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse(body),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,822 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyVegvesenGroundHeights,
|
||||
clearKartverketHeightCache,
|
||||
fetchKartverketHeights,
|
||||
kartverketBackoffMs,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_CATALOG_WAIT_MS,
|
||||
KARTVERKET_CONCURRENCY,
|
||||
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_NULL_TTL_MS,
|
||||
KARTVERKET_USER_AGENT,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
clearKartverketHeightCache();
|
||||
t.after(clearKartverketHeightCache);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
// The catalog first; the rest is the Kartverket height lookup.
|
||||
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
|
||||
// Two unique positions: one Kartverket request.
|
||||
assert.equal(requested.length, 2);
|
||||
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = 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 === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Answer a Kartverket request the way the live API does: echo every point,
|
||||
* with the height `heightFor(x, y)` returns (null = outside coverage). */
|
||||
const kartverketResponse = (url, heightFor) => {
|
||||
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
|
||||
});
|
||||
};
|
||||
|
||||
/** Cameras at distinct Norwegian positions. */
|
||||
const cameraAt = (i) => ({
|
||||
id: `no-vegvesen-${i}`,
|
||||
lat: 60 + i * 0.001,
|
||||
lon: 7 + i * 0.001,
|
||||
groundElevationM: 150,
|
||||
});
|
||||
|
||||
const withHeightsEnv = (t) => {
|
||||
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
clearKartverketHeightCache();
|
||||
t.after(() => {
|
||||
clearKartverketHeightCache();
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
};
|
||||
|
||||
const sentPoints = (url) =>
|
||||
JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
|
||||
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push(String(url));
|
||||
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
|
||||
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
|
||||
// Redirects are refused, so a request can never leave the fixed host.
|
||||
assert.equal(init.redirect, 'error');
|
||||
// Reply in reverse order: matching must not depend on list position.
|
||||
const response = kartverketResponse(url, (x) =>
|
||||
x > 7.04 ? null : Math.round((x - 7) * 10000),
|
||||
);
|
||||
const body = await response.json();
|
||||
body.punkter.reverse();
|
||||
return Response.json(body);
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
// One pass, no retries: 20 + 20 + 10.
|
||||
assert.deepEqual(
|
||||
requested.map((url) => sentPoints(url).length),
|
||||
[KARTVERKET_MAX_POINTS, 20, 10],
|
||||
);
|
||||
for (const url of requested) {
|
||||
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
|
||||
}
|
||||
assert.equal(heights.size, 50);
|
||||
assert.equal(heights.get('7.010000,60.010000'), 100);
|
||||
// Past x = 7.04 the API has no height: recorded as null, never as 0.
|
||||
assert.equal(heights.get('7.049000,60.049000'), null);
|
||||
});
|
||||
|
||||
test('only the points a request asked about are recorded', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
...points.map(([x, y]) => ({ x, y, z: 10 })),
|
||||
{ x: 7.5, y: 60.5, z: 999 }, // never asked
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
|
||||
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
|
||||
});
|
||||
|
||||
test('either water marker counts as the surface', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const [a, b, c] = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
{
|
||||
x: a[0],
|
||||
y: a[1],
|
||||
z: -547.1,
|
||||
datakilde: 'dybdekurver',
|
||||
terreng: null,
|
||||
},
|
||||
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
|
||||
{
|
||||
x: c[0],
|
||||
y: c[1],
|
||||
z: -0.2,
|
||||
datakilde: 'dtm1',
|
||||
terreng: 'ÅpentOmråde',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
]);
|
||||
assert.equal(heights.get('7.000000,60.000000'), 0);
|
||||
assert.equal(heights.get('7.001000,60.001000'), 0);
|
||||
// A quay just below the datum on land data stays as measured.
|
||||
assert.equal(heights.get('7.002000,60.002000'), -0.2);
|
||||
});
|
||||
|
||||
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const zs = [-20, -20.5, 2500, 2500.5];
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
cameraAt(3),
|
||||
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
|
||||
]);
|
||||
assert.equal(requested.length, 1);
|
||||
assert.equal(sentPoints(requested[0]).length, 4);
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
heights.get(
|
||||
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
|
||||
),
|
||||
),
|
||||
[-20, null, 2500, null],
|
||||
);
|
||||
});
|
||||
|
||||
test('no more than eight requests are in flight', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return kartverketResponse(url, () => 1);
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(heights.size, 400);
|
||||
assert.equal(peak, KARTVERKET_CONCURRENCY);
|
||||
});
|
||||
|
||||
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
// The first wave is already in flight when the first failure lands; nothing
|
||||
// after it starts, and no point is resent.
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, {
|
||||
status: 429,
|
||||
retryAfter: '120',
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
/** A fetch that never answers, and rejects only when its signal aborts. */
|
||||
const hangingFetch = (url, init) => {
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
return new Promise((_, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason));
|
||||
});
|
||||
};
|
||||
|
||||
test('the lookup deadline bounds a hanging service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const started = Date.now();
|
||||
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 30,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
|
||||
});
|
||||
|
||||
test('one hanging request times out on its own, as a failing service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 60_000,
|
||||
requestTimeoutMs: 20,
|
||||
});
|
||||
assert.deepEqual(failure, {
|
||||
status: null,
|
||||
retryAfter: null,
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
calls += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => resolve(kartverketResponse(url, () => 1)),
|
||||
40,
|
||||
);
|
||||
init.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(init.signal.reason);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
|
||||
cameraAt(i),
|
||||
),
|
||||
{ timeoutMs: 100 },
|
||||
);
|
||||
assert.equal(failure?.deadline, true);
|
||||
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
|
||||
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
|
||||
});
|
||||
|
||||
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const answers = {
|
||||
503: () => new Response('down', { status: 503 }),
|
||||
'not json': () =>
|
||||
new Response('<html>maintenance</html>', {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
oversized: () =>
|
||||
Response.json({
|
||||
punkter: [],
|
||||
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
|
||||
}),
|
||||
};
|
||||
for (const [label, answer] of Object.entries(answers)) {
|
||||
clearKartverketHeightCache();
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return answer();
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
|
||||
assert.equal(heights.size, 0, label);
|
||||
assert.equal(failure?.deadline, false, label);
|
||||
assert.equal(failure?.status, label === '503' ? 503 : null, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
sentPoints(url)[0][0] === 7
|
||||
? new Response('bad point', { status: 400 })
|
||||
: kartverketResponse(url, () => 42),
|
||||
);
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
assert.equal(heights.size, 40);
|
||||
assert.equal(heights.get('7.000000,60.000000'), null);
|
||||
assert.equal(heights.get('7.020000,60.020000'), 42);
|
||||
});
|
||||
|
||||
test('a point a 200 answer leaves out is cached as no height', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url).slice(1); // drop the first point
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
|
||||
});
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
// The next refresh asks nothing: the left-out point is a cached miss.
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
|
||||
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||
assert.equal(kartverketBackoffMs('120', now), 120_000);
|
||||
assert.equal(kartverketBackoffMs('5', now), 60_000);
|
||||
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
|
||||
assert.equal(
|
||||
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
});
|
||||
|
||||
test('heights are cached, and misses are not asked again until they expire', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const asked = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
asked.push(sentPoints(url).length);
|
||||
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.groundElevationM),
|
||||
[1043.3, 1043.3, 150],
|
||||
);
|
||||
// The next refresh asks nothing: heights and the miss are both cached.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
|
||||
now,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(asked, [3]);
|
||||
// Once the miss expires, only that point is asked again.
|
||||
clock += KARTVERKET_NULL_TTL_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
|
||||
assert.deepEqual(asked, [3, 1]);
|
||||
});
|
||||
|
||||
test('after a failure no lookup starts until the backoff ends', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// The default backoff outlasts the catalog refresh, so the next refresh
|
||||
// and every one inside the backoff cost nothing.
|
||||
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// After it, the failed point is asked again.
|
||||
clock += 2;
|
||||
fetchMock.mock.mockImplementation(async (url) =>
|
||||
kartverketResponse(url, () => 321),
|
||||
);
|
||||
const later = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
|
||||
assert.equal(later[0].groundElevationM, 321);
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a 429 backs off for its Retry-After, not the default', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const retryAfterS = 50 * 60;
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterS) },
|
||||
}),
|
||||
);
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
// The lookup runs out of time; that is not a failing service.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], {
|
||||
now,
|
||||
timeoutMs: 10,
|
||||
waitMs: 1000,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 64),
|
||||
);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 64);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('overlapping refreshes share one lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 3);
|
||||
});
|
||||
await Promise.all([
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
release();
|
||||
});
|
||||
|
||||
test('a warm cache does not wait for a few new cameras', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 500),
|
||||
);
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 600);
|
||||
});
|
||||
const refresh = [...cameras, cameraAt(20)];
|
||||
const started = Date.now();
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
|
||||
20,
|
||||
);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
// The background lookup fills the cache for the next refresh.
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [...cameras, cameraAt(20)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next), 21);
|
||||
assert.equal(next[20].groundElevationM, 600);
|
||||
});
|
||||
|
||||
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 9),
|
||||
);
|
||||
const cameras = [
|
||||
{ id: 'no-coordinates' },
|
||||
null,
|
||||
{ id: 'strings', lat: '60', lon: '7' },
|
||||
cameraAt(0),
|
||||
];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[3].groundElevationM, 9);
|
||||
});
|
||||
|
||||
test('a lookup started before a cache clear cannot write after it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 111);
|
||||
});
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
|
||||
clearKartverketHeightCache();
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 222),
|
||||
);
|
||||
const fresh = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
|
||||
assert.equal(fresh[0].groundElevationM, 222);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const later = [cameraAt(0)];
|
||||
await applyVegvesenGroundHeights(later, { waitMs: 0 });
|
||||
assert.equal(later[0].groundElevationM, 222);
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 5),
|
||||
);
|
||||
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 812);
|
||||
});
|
||||
const first = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
|
||||
assert.equal(first[0].groundElevationM, 150);
|
||||
release();
|
||||
// The lookup kept running and filled the cache for the next refresh.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
|
||||
assert.equal(next[0].groundElevationM, 812);
|
||||
});
|
||||
|
||||
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return kartverketResponse(url, () => 987.6);
|
||||
}
|
||||
return Response.json({ type: 'FeatureCollection', features: [feature()] });
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => [c.id, c.groundElevationM]),
|
||||
[['no-vegvesen-3000063_1', 987.6]],
|
||||
);
|
||||
});
|
||||
|
||||
test('the loader budgets the height wait from its own start', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const realNow = Date.now.bind(Date);
|
||||
let offset = 0;
|
||||
t.mock.method(Date, 'now', () => realNow() + offset);
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return hangingFetch(url, init);
|
||||
}
|
||||
// The catalog fetch uses up all but 10 ms of the lane's budget.
|
||||
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [feature()] }),
|
||||
);
|
||||
});
|
||||
const started = performance.now();
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.ok(performance.now() - started < 1000);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export function cameraHue(index) {
|
||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
||||
*/
|
||||
export function viewshedColors(hueDeg) {
|
||||
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
|
||||
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||
return {
|
||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
|
||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||
*/
|
||||
export function frustumVolumeGeometryData(positions) {
|
||||
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
||||
const pts = [
|
||||
positions.mount,
|
||||
positions.tl,
|
||||
positions.tr,
|
||||
positions.br,
|
||||
positions.bl,
|
||||
];
|
||||
const flat = new Float64Array(15);
|
||||
pts.forEach((p, i) => {
|
||||
flat[i * 3] = p.x;
|
||||
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
|
||||
});
|
||||
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
||||
const indices = new Uint16Array([
|
||||
0, 1, 2,
|
||||
0, 2, 3,
|
||||
0, 3, 4,
|
||||
0, 4, 1,
|
||||
1, 2, 3,
|
||||
1, 3, 4,
|
||||
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
|
||||
]);
|
||||
return { positions: flat, indices };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { loadWarendorfSourcesFromCatalog } from '../../server/providers/cctv/sources.js';
|
||||
|
||||
test('Warendorf catalog registers the Marktplatz webcam on official hosts only', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const cameras = loadWarendorfSourcesFromCatalog();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['warendorf-marktplatz-rathaus'],
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.ok(
|
||||
/^https?:\/\/(webcam\.warendorf\.de|www\.kreis-warendorf\.de)\//.test(
|
||||
camera.url,
|
||||
),
|
||||
camera.url,
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.equal(camera.sourceKind, 'municipal-webcam');
|
||||
assert.equal(camera.poseSource, 'curated');
|
||||
assert.match(camera.license, /^Public municipal webcam data/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Warendorf loader tolerates a missing catalog file', (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
assert.deepEqual(
|
||||
loadWarendorfSourcesFromCatalog({ sourceRoot: '/nonexistent' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Warendorf loader skips malformed rows without throwing', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-warendorf-'));
|
||||
fs.mkdirSync(path.join(dir, 'config'));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config', 'cctv_sources.warendorf.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: { toString: null },
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'ok',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'text-coords',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: '51.9',
|
||||
lon: '7.9',
|
||||
},
|
||||
{
|
||||
id: 'null-island',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
{
|
||||
id: 'off-host',
|
||||
url: 'https://evil.example/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cameras = loadWarendorfSourcesFromCatalog({ sourceRoot: dir });
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['ok'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
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_WSDOT_MAX_SOURCES,
|
||||
WSDOT_CAMERAS_URL,
|
||||
WSDOT_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadWsdotSourcesFromOpenData,
|
||||
webMercatorToWgs84,
|
||||
} 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', () => {});
|
||||
}
|
||||
|
||||
/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */
|
||||
function mercator(lat, lon) {
|
||||
const R = 6378137;
|
||||
return {
|
||||
x: (lon * Math.PI * R) / 180,
|
||||
y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A `Cameras.json` point feature carrying the fields the loader reads. */
|
||||
function wsdotFeature(id, lat, lon, overrides = {}) {
|
||||
const { attributes = {}, geometry } = overrides;
|
||||
return {
|
||||
attributes: {
|
||||
CameraID: id,
|
||||
CameraTitle: `Camera ${id}`,
|
||||
CompassDirection: 'N',
|
||||
ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`,
|
||||
...attributes,
|
||||
},
|
||||
geometry: geometry === undefined ? mercator(lat, lon) : geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */
|
||||
function cp1252Response(payload) {
|
||||
const text = JSON.stringify(payload);
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
// U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships.
|
||||
bytes[i] = code === 0x2013 ? 0x96 : code;
|
||||
}
|
||||
return new Response(bytes, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => {
|
||||
// Real feature from the live layer: I-5 at the Interstate Bridge.
|
||||
const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545);
|
||||
assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`);
|
||||
assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`);
|
||||
});
|
||||
|
||||
test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return cp1252Response({
|
||||
features: [
|
||||
wsdotFeature(1001, 47.6097, -122.3331, {
|
||||
attributes: {
|
||||
// The 0x2013 below is serialized as the feed's 0x96 byte.
|
||||
CameraTitle: 'I-5 – Seattle, looking south',
|
||||
CompassDirection: 'S',
|
||||
},
|
||||
}),
|
||||
wsdotFeature(9059, 47.66, -117.43, {
|
||||
attributes: { CompassDirection: 'B' },
|
||||
}),
|
||||
// Partner-hosted frame (Oregon DOT): skipped, never proxied.
|
||||
wsdotFeature(1002, 45.62, -122.675, {
|
||||
attributes: {
|
||||
ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg',
|
||||
},
|
||||
}),
|
||||
// Bad rows: non-integer id, missing geometry, out-of-state point.
|
||||
wsdotFeature('1003', 47.6, -122.3),
|
||||
wsdotFeature(1004, 47.6, -122.3, { geometry: null }),
|
||||
wsdotFeature(1005, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [WSDOT_CAMERAS_URL]);
|
||||
// Exactly the two valid official-host cameras survive; the partner-hosted
|
||||
// frame and the malformed rows are dropped.
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'wsdot-1001',
|
||||
'wsdot-9059',
|
||||
]);
|
||||
|
||||
const seattle = cameras.find((camera) => camera.id === 'wsdot-1001');
|
||||
assert.equal(seattle.name, 'I-5 – Seattle, looking south');
|
||||
assert.equal(seattle.provider, 'WSDOT');
|
||||
assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg');
|
||||
assert.equal(seattle.snapshotUrl, seattle.url);
|
||||
assert.equal(seattle.feedType, 'image');
|
||||
assert.equal(seattle.headingDeg, 180);
|
||||
assert.equal(seattle.headingConfidence, 'high');
|
||||
assert.equal(seattle.fovDeg, 56);
|
||||
assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001);
|
||||
assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001);
|
||||
|
||||
// `B` (both directions) carries no single facing: id-hash fallback at low
|
||||
// confidence, with the low-confidence pose personality.
|
||||
const spokane = cameras.find((camera) => camera.id === 'wsdot-9059');
|
||||
assert.equal(spokane.headingConfidence, 'low');
|
||||
assert.equal(spokane.fovDeg, 44);
|
||||
assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360);
|
||||
});
|
||||
|
||||
test('WSDOT loader maps every CompassDirection code', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const codes = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
// Lower-case and padded codes are the same code.
|
||||
' nw ': 315,
|
||||
};
|
||||
// The live feed's no-facing codes, plus an unknown one.
|
||||
const noFacing = ['B', 'O', null, 'BW'];
|
||||
const entries = [...Object.keys(codes), ...noFacing];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
cp1252Response({
|
||||
features: entries.map((code, i) =>
|
||||
wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, {
|
||||
attributes: { CompassDirection: code },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
entries.forEach((code, i) => {
|
||||
const camera = byId.get(`wsdot-${3000 + i}`);
|
||||
if (code in codes) {
|
||||
assert.equal(camera.headingDeg, codes[code], `heading for ${code}`);
|
||||
assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 56, `pose personality for ${code}`);
|
||||
} else {
|
||||
assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 44, `pose personality for ${code}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('WSDOT loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05));
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features }));
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('WSDOT loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a redirect and cancels its body', async (t) => {
|
||||
quiet(t);
|
||||
// A redirect is never followed, and its still-open body is cancelled.
|
||||
const seen = [];
|
||||
const state = { cancelled: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/Cameras.json' },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(state.cancelled, true, 'the redirect body is cancelled');
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a body over the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
// A valid catalog padded past the cap is refused rather than buffered.
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const text = JSON.stringify({
|
||||
features: [wsdotFeature(1001, 47.6097, -122.3331)],
|
||||
});
|
||||
const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV_WSDOT_ENABLED=0 skips the WSDOT 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_WSDOT_ENABLED: undefined,
|
||||
CCTV_WSDOT_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only WSDOT answers.
|
||||
return String(url) === WSDOT_CAMERAS_URL
|
||||
? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] })
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['wsdot-1001'],
|
||||
);
|
||||
|
||||
process.env.CCTV_WSDOT_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(WSDOT_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
|
||||
) {
|
||||
const previous = previousIds instanceof Set ? previousIds : new Set();
|
||||
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
||||
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
|
||||
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
|
||||
const next = new Set();
|
||||
|
||||
for (const [id, distanceSq] of distancesSquared || []) {
|
||||
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
|
||||
@@ -14,9 +15,8 @@ import militaryFlightsLayer, {
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
|
||||
const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readLayerSource(new URL('./flights.js', import.meta.url));
|
||||
const MILITARY_SOURCE = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
@@ -84,27 +84,35 @@ function candidateIds(layer) {
|
||||
}
|
||||
|
||||
test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => {
|
||||
const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/
|
||||
.exec(UI_SOURCE)?.[0];
|
||||
assert.ok(dispatcher, 'Cockpit event dispatcher is defined');
|
||||
assert.match(dispatcher, /info\?\.icao24/);
|
||||
assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/);
|
||||
assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/);
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/,
|
||||
'entry and in-Cockpit handoff publish the active subject');
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/,
|
||||
const previousWindow = globalThis.window;
|
||||
const details = [];
|
||||
globalThis.window = { dispatchEvent: event => details.push(event.detail) };
|
||||
try {
|
||||
dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' });
|
||||
dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' });
|
||||
dispatchCockpitModeChanged(false);
|
||||
assert.deepEqual(details, [
|
||||
{ active: true, subjectId: 'abc123', layerId: 'military' },
|
||||
{ active: true, subjectId: 'def456', layerId: null },
|
||||
{ active: false, subjectId: null, layerId: null },
|
||||
]);
|
||||
} finally { globalThis.window = previousWindow; }
|
||||
for (const action of [enter, _adoptTrackedEntity]) {
|
||||
assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/,
|
||||
'entry and in-Cockpit handoff each publish the active subject');
|
||||
}
|
||||
assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/,
|
||||
'exit clears the active subject');
|
||||
|
||||
for (const [name, source] of [
|
||||
['commercial', FLIGHTS_SOURCE],
|
||||
['military', MILITARY_SOURCE],
|
||||
]) {
|
||||
const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/
|
||||
const consumer = /function\s*(?:parts\.\w+\.)?_applyCockpitState\(\s*detail\s*=\s*\{\},?\s*\)\s*\{[\s\S]*?\n {0,2}\}/
|
||||
.exec(source)?.[0];
|
||||
assert.ok(consumer, `${name} Cockpit consumer is defined`);
|
||||
assert.match(consumer, /detail\?\.subjectId/);
|
||||
assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(consumer, /\.trim\(\s*,?\s*\)\s*\.toLowerCase\(\s*,?\s*\)/);
|
||||
assert.doesNotMatch(consumer, /layerId/,
|
||||
`${name} must also suppress a duplicate subject originating in the sibling AIR feed`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Cockpit 3D aircraft policy.
|
||||
@@ -25,7 +25,7 @@ function numericConstant(source, name) {
|
||||
}
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
const source = readFileSync(layer.path, 'utf8');
|
||||
const source = readLayerSource(layer.path);
|
||||
|
||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
||||
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
|
||||
@@ -37,11 +37,11 @@ for (const layer of LAYERS) {
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
|
||||
const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_modelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
|
||||
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/,
|
||||
'Cockpit must not bypass the owner-visible Display toggle');
|
||||
});
|
||||
|
||||
@@ -51,13 +51,13 @@ for (const layer of LAYERS) {
|
||||
// owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so
|
||||
// it no longer routes through the toggle-gated `_modelRegimeActive` — the
|
||||
// suppression is now an explicit early return.
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_trackedModelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
|
||||
'_trackedModelRegimeActive excludes cockpit');
|
||||
const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0];
|
||||
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(tracked, '_updateTrackedModel is defined');
|
||||
assert.match(tracked, /_trackedModelRegimeActive\(\)/,
|
||||
assert.match(tracked, /(?:parts\.\w+\.)?_trackedModelRegimeActive\(\s*,?\s*\)/,
|
||||
'the tracked-model driver uses the cockpit-aware predicate');
|
||||
});
|
||||
|
||||
@@ -68,15 +68,15 @@ for (const layer of LAYERS) {
|
||||
assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000);
|
||||
assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60);
|
||||
|
||||
const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/);
|
||||
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/);
|
||||
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
|
||||
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
|
||||
|
||||
const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/,
|
||||
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/,
|
||||
'Cockpit keeps its 60-model performance ceiling');
|
||||
});
|
||||
|
||||
@@ -88,19 +88,19 @@ for (const layer of LAYERS) {
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/,
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /bb\.rotation = 0;/,
|
||||
assert.match(source, /bb\.rotation\s*=\s*0;/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/,
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/,
|
||||
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof billboard-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/);
|
||||
assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/);
|
||||
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/);
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
|
||||
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
||||
*/
|
||||
export function canonicalizeContactId(value) {
|
||||
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
|
||||
return String(value ?? '')
|
||||
.replace(/[^a-z0-9]/gi, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
|
||||
* @param {string} [params.registration] Contact's registration, if any.
|
||||
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
||||
*/
|
||||
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
|
||||
const rawQuery = String(query ?? '').trim().toLowerCase();
|
||||
export function rankContactMatch({
|
||||
query,
|
||||
hex = '',
|
||||
callsign = '',
|
||||
registration = '',
|
||||
}) {
|
||||
const rawQuery = String(query ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
||||
|
||||
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
if (
|
||||
String(hex ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === rawQuery
|
||||
)
|
||||
return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
|
||||
const callsignText = String(callsign ?? '').trim().toLowerCase();
|
||||
const callsignText = String(callsign ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
||||
// would silently widen the long-standing callsign contract.
|
||||
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
if (callsignText && callsignText === rawQuery)
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
|
||||
const canonicalQuery = canonicalizeContactId(rawQuery);
|
||||
const canonicalRegistration = canonicalizeContactId(registration);
|
||||
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
||||
}
|
||||
|
||||
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (callsignText && callsignText.startsWith(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
||||
}
|
||||
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (callsignText && callsignText.includes(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
/** Portable retained report-time playback. No scene or scheduling ownership. */
|
||||
export const FIX_BYTES = 48;
|
||||
export const FIX_FLAGS = Object.freeze({
|
||||
VEHICLE_TIME: 1,
|
||||
FEED_TIME: 2,
|
||||
RECEIPT_TIME: 4,
|
||||
FLOOR: 8,
|
||||
BREAK: 16,
|
||||
LIVE: 32,
|
||||
});
|
||||
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
|
||||
|
||||
export function createLagReservoir(capacity = 256) {
|
||||
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
|
||||
}
|
||||
function addLag(reservoir, value) {
|
||||
if (!reservoir || !Number.isFinite(value) || value < 0) return;
|
||||
reservoir.values[reservoir.cursor] = value;
|
||||
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
|
||||
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
|
||||
}
|
||||
function percentile(reservoir, rank) {
|
||||
if (!reservoir?.count) return 0;
|
||||
const sorted = reservoir.values.slice(0, reservoir.count).sort();
|
||||
return sorted[Math.ceil(sorted.length * rank) - 1];
|
||||
}
|
||||
|
||||
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
|
||||
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
|
||||
return { maxBytes, allocatedBytes: 0 };
|
||||
}
|
||||
/** Numeric fields are reserved up front, including unknown mesh heights. */
|
||||
export function createFixSample() {
|
||||
return {
|
||||
t: NaN,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
h: NaN,
|
||||
receivedAt: NaN,
|
||||
bearingDeg: NaN,
|
||||
flags: 0,
|
||||
epoch: 0,
|
||||
seq: -1,
|
||||
};
|
||||
}
|
||||
export function createTrack({
|
||||
capacity = 128,
|
||||
retentionMs = 900_000,
|
||||
policy = {},
|
||||
budget = null,
|
||||
} = {}) {
|
||||
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
|
||||
return {
|
||||
capacity,
|
||||
retentionMs,
|
||||
policy,
|
||||
budget,
|
||||
metricsScratchA: createFixSample(),
|
||||
metricsScratchB: createFixSample(),
|
||||
storage: new DataView(new ArrayBuffer(0)),
|
||||
slots: 0,
|
||||
head: 0,
|
||||
count: 0,
|
||||
baseSeq: 0,
|
||||
revision: 0,
|
||||
truncated: false,
|
||||
epochs: new Map(),
|
||||
lag: createLagReservoir(32),
|
||||
targetDelayMs: 25_000,
|
||||
displayT: NaN,
|
||||
wallAnchor: NaN,
|
||||
monoAnchor: NaN,
|
||||
lastMono: NaN,
|
||||
offset: 0,
|
||||
rate: 1,
|
||||
phase: 'buffering',
|
||||
resets: 0,
|
||||
resetReason: null,
|
||||
acceptedAt: NaN,
|
||||
acceptedMono: NaN,
|
||||
resumePending: false,
|
||||
bracket: 0,
|
||||
metricsRevision: -1,
|
||||
metricsIndex: -1,
|
||||
from: createFixSample(),
|
||||
to: createFixSample(),
|
||||
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
|
||||
latest: createFixSample(),
|
||||
pending: null,
|
||||
pendingCount: 0,
|
||||
};
|
||||
}
|
||||
function address(track, index) {
|
||||
return ((track.head + index) % track.slots) * FIX_BYTES;
|
||||
}
|
||||
export function readFix(track, index, out = {}) {
|
||||
if (index < 0 || index >= track.count) return null;
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
out.t = v.getFloat64(o);
|
||||
out.lat = v.getFloat64(o + 8);
|
||||
out.lon = v.getFloat64(o + 16);
|
||||
out.heightM = v.getFloat32(o + 24);
|
||||
out.h = out.heightM;
|
||||
out.receivedAt = v.getFloat64(o + 28);
|
||||
out.bearingDeg = v.getFloat32(o + 36);
|
||||
out.flags = v.getUint32(o + 40);
|
||||
out.epoch = v.getUint32(o + 44);
|
||||
out.seq = track.baseSeq + index;
|
||||
return out;
|
||||
}
|
||||
function writeFix(track, index, fix) {
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
v.setFloat64(o, fix.t);
|
||||
v.setFloat64(o + 8, fix.lat);
|
||||
v.setFloat64(o + 16, fix.lon);
|
||||
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
|
||||
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
|
||||
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
|
||||
v.setUint32(o + 40, fix.flags || 0);
|
||||
v.setUint32(o + 44, fix.epoch || 0);
|
||||
}
|
||||
function resize(track, slots) {
|
||||
const bytes = slots * FIX_BYTES,
|
||||
delta = bytes - track.storage.byteLength;
|
||||
if (
|
||||
delta > 0 &&
|
||||
track.budget &&
|
||||
track.budget.allocatedBytes + delta > track.budget.maxBytes
|
||||
)
|
||||
return false;
|
||||
const old = track.storage,
|
||||
head = track.head,
|
||||
oldSlots = track.slots;
|
||||
const next = new Uint8Array(bytes),
|
||||
previous = new Uint8Array(old.buffer);
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const o = ((head + i) % oldSlots) * FIX_BYTES;
|
||||
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
|
||||
}
|
||||
track.storage = new DataView(next.buffer);
|
||||
track.slots = slots;
|
||||
track.head = 0;
|
||||
if (track.budget) track.budget.allocatedBytes += delta;
|
||||
return true;
|
||||
}
|
||||
export function destroyTrack(track) {
|
||||
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
|
||||
track.storage = new DataView(new ArrayBuffer(0));
|
||||
track.count = 0;
|
||||
track.slots = 0;
|
||||
track.epochs.clear();
|
||||
}
|
||||
function timeAt(track, i) {
|
||||
return track.storage.getFloat64(address(track, i));
|
||||
}
|
||||
function dropOldest(track) {
|
||||
track.head = (track.head + 1) % track.slots;
|
||||
track.count--;
|
||||
track.baseSeq++;
|
||||
track.revision++;
|
||||
track.truncated = true;
|
||||
}
|
||||
/** Retention never evicts the bracket currently on screen. */
|
||||
export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
while (
|
||||
track.count > 2 &&
|
||||
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
|
||||
) {
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
|
||||
break;
|
||||
dropOldest(track);
|
||||
}
|
||||
if (keep < track.slots && track.count <= keep)
|
||||
resize(track, Math.max(2, track.count));
|
||||
const used = new Set();
|
||||
for (let i = 0; i < track.count; i++)
|
||||
used.add(track.storage.getUint32(address(track, i) + 44));
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
Number.isFinite(fix.lat) &&
|
||||
Math.abs(fix.lat) <= 90 &&
|
||||
Number.isFinite(fix.lon) &&
|
||||
Math.abs(fix.lon) <= 180
|
||||
);
|
||||
}
|
||||
function distance(a, b) {
|
||||
const lon = ((b.lon - a.lon + 540) % 360) - 180;
|
||||
return Math.hypot(
|
||||
(b.lat - a.lat) * 111320,
|
||||
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
|
||||
);
|
||||
}
|
||||
function append(track, fix) {
|
||||
if (track.count === track.slots && track.slots < track.capacity)
|
||||
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
|
||||
if (track.count === track.slots) {
|
||||
if (track.count < 2) return false;
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
|
||||
// Pin both active ends; drop the next future observation and mark the gap.
|
||||
if (track.count < 4) return false;
|
||||
const scratch = {};
|
||||
for (let i = 2; i < track.count - 1; i++) {
|
||||
readFix(track, i + 1, scratch);
|
||||
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
|
||||
writeFix(track, i, scratch);
|
||||
}
|
||||
track.count--;
|
||||
track.truncated = true;
|
||||
} else dropOldest(track);
|
||||
}
|
||||
writeFix(track, track.count++, fix);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function insertFix(track, fix, observation = {}) {
|
||||
if (
|
||||
!validFix(fix) ||
|
||||
(Number.isFinite(observation.wallNowMs) &&
|
||||
fix.t > observation.wallNowMs + 10_000)
|
||||
)
|
||||
return { accepted: false, reason: 'malformed' };
|
||||
const last = readFix(track, track.count - 1, track.latest);
|
||||
if (last && fix.t === last.t)
|
||||
return {
|
||||
accepted: false,
|
||||
reason:
|
||||
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
|
||||
};
|
||||
let reason = last ? 'moved' : 'first';
|
||||
if (
|
||||
last &&
|
||||
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
|
||||
) {
|
||||
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
|
||||
const prior = track.pending;
|
||||
if (
|
||||
!prior ||
|
||||
prior.reason !== reason ||
|
||||
fix.t <= prior.t ||
|
||||
(track.policy.accept && !track.policy.accept(prior, fix))
|
||||
) {
|
||||
if (!prior || fix.t !== prior.t) {
|
||||
track.pending = { ...fix, reason };
|
||||
track.pendingCount = 1;
|
||||
}
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
track.pendingCount++;
|
||||
const required = reason === 'out-of-order' ? 3 : 2;
|
||||
if (track.pendingCount < required) {
|
||||
track.pending = { ...fix, reason };
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
|
||||
if (reason === 'out-of-order') {
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
}
|
||||
const epoch = (last.epoch + 1) >>> 0;
|
||||
append(track, {
|
||||
...prior,
|
||||
epoch,
|
||||
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
|
||||
});
|
||||
fix = { ...fix, epoch };
|
||||
if (reason === 'out-of-order') track.displayT = NaN;
|
||||
track.resetReason = reason;
|
||||
track.resets++;
|
||||
}
|
||||
const receivedAt =
|
||||
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
|
||||
trimTrack(track, receivedAt);
|
||||
const accepted = append(track, {
|
||||
...fix,
|
||||
receivedAt,
|
||||
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
|
||||
});
|
||||
if (accepted) {
|
||||
if (!observation.replayed) {
|
||||
if (last && fix.t > last.t && reason === 'moved') {
|
||||
addLag(track.lag, receivedAt - last.t);
|
||||
addLag(track.policy.feedLag, receivedAt - last.t);
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(
|
||||
percentile(track.lag, 0.95),
|
||||
percentile(track.policy.feedLag, 0.9),
|
||||
) + 5000,
|
||||
);
|
||||
} else if (!last)
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(0, receivedAt - fix.t) + 20_000,
|
||||
);
|
||||
}
|
||||
track.acceptedAt = receivedAt;
|
||||
track.acceptedMono = observation.monoNowMs ?? NaN;
|
||||
track.resumePending = true;
|
||||
track.pending = null;
|
||||
track.pendingCount = 0;
|
||||
}
|
||||
return { accepted, reason: accepted ? reason : 'capacity' };
|
||||
}
|
||||
|
||||
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
|
||||
export function mergeHistory(track, fixes) {
|
||||
const merged = new Map();
|
||||
for (const fix of fixes.slice(-128))
|
||||
if (
|
||||
validFix(fix) &&
|
||||
(!Number.isFinite(track.acceptedAt) ||
|
||||
fix.t >= track.acceptedAt - track.retentionMs)
|
||||
)
|
||||
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const fix = readFix(track, i);
|
||||
merged.set(fix.t, fix);
|
||||
}
|
||||
const retained = Array.from({ length: track.count }, (_, i) =>
|
||||
readFix(track, i),
|
||||
);
|
||||
const active = Number.isFinite(track.displayT)
|
||||
? retained.findLastIndex((f) => f.t <= track.displayT)
|
||||
: -1;
|
||||
const pinnedFrom = retained[active],
|
||||
pinnedTo = retained[active + 1];
|
||||
const trusted = new Set(retained.map((f) => f.t));
|
||||
const candidates = [...merged.values()]
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.filter(
|
||||
(f) =>
|
||||
trusted.has(f.t) ||
|
||||
!pinnedFrom ||
|
||||
!pinnedTo ||
|
||||
f.t < pinnedFrom.t ||
|
||||
f.t > pinnedTo.t,
|
||||
);
|
||||
const ordered = [];
|
||||
// Start from an admitted live position, including when walking backwards
|
||||
// into retained history. A lone outlier is quarantined in either direction.
|
||||
// Never borrow the live pending observation as a second vote for itself.
|
||||
function admit(sequence, anchor, backwards = false) {
|
||||
let last = anchor,
|
||||
pending = null;
|
||||
const plausible = (a, b) =>
|
||||
!track.policy.accept ||
|
||||
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
|
||||
for (const fix of sequence) {
|
||||
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
|
||||
ordered.push(fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else if (pending && plausible(pending, fix)) {
|
||||
// Two distinct coherent observations establish a replacement segment.
|
||||
// Keep a visible gap between it and the authoritative live path.
|
||||
if (backwards) last.flags |= FIX_FLAGS.BREAK;
|
||||
else pending.flags |= FIX_FLAGS.BREAK;
|
||||
ordered.push(pending, fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else pending = fix;
|
||||
}
|
||||
}
|
||||
const first = retained[0];
|
||||
if (first) {
|
||||
admit(
|
||||
candidates.filter((f) => f.t < first.t).reverse(),
|
||||
merged.get(first.t),
|
||||
true,
|
||||
);
|
||||
admit(
|
||||
candidates.filter((f) => f.t >= first.t),
|
||||
null,
|
||||
);
|
||||
} else admit(candidates, null);
|
||||
ordered.sort((a, b) => a.t - b.t);
|
||||
while (ordered.length > track.capacity) {
|
||||
const i = ordered.findIndex(
|
||||
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
|
||||
);
|
||||
if (i > 0 && i + 1 < ordered.length)
|
||||
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
|
||||
ordered.splice(Math.max(0, i), 1);
|
||||
track.truncated = true;
|
||||
}
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
|
||||
ordered[i].flags |= FIX_FLAGS.BREAK;
|
||||
}
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
for (const fix of ordered) append(track, fix);
|
||||
track.revision++;
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function correctHeight(track, seq, heightM) {
|
||||
const i = seq - track.baseSeq;
|
||||
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
|
||||
const o = address(track, i),
|
||||
value = Math.fround(heightM);
|
||||
if (track.storage.getFloat32(o + 24) === value) return false;
|
||||
track.storage.setFloat32(o + 24, value);
|
||||
track.storage.setUint32(
|
||||
o + 40,
|
||||
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
|
||||
);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
function anchoredWall(track, clocks) {
|
||||
if (!Number.isFinite(track.wallAnchor)) {
|
||||
track.wallAnchor = clocks.wallNowMs;
|
||||
track.monoAnchor = clocks.monoNowMs;
|
||||
}
|
||||
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
|
||||
}
|
||||
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
|
||||
export function createSample() {
|
||||
return {
|
||||
displayT: NaN,
|
||||
revision: 0,
|
||||
nextWakeMonoMs: Infinity,
|
||||
phase: 'buffering',
|
||||
motion: 'unknown',
|
||||
fromSeq: -1,
|
||||
toSeq: -1,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
segmentCourseDeg: NaN,
|
||||
segmentSpeedMps: NaN,
|
||||
fraction: NaN,
|
||||
fromT: NaN,
|
||||
toT: NaN,
|
||||
nextMotionT: Infinity,
|
||||
latestReportAgeMs: NaN,
|
||||
actualDelayMs: NaN,
|
||||
};
|
||||
}
|
||||
/** Sample an explicit report time into caller-owned storage. */
|
||||
export function sampleAt(track, displayTimeMs, out) {
|
||||
out.displayT = displayTimeMs;
|
||||
return sampleCurrent(track, out);
|
||||
}
|
||||
/** The frame path keeps its changing double in storage across this call. */
|
||||
function sampleCurrent(track, out) {
|
||||
const sampleTime = out.displayT;
|
||||
out.revision = track.revision;
|
||||
out.nextWakeMonoMs = Infinity;
|
||||
out.phase = track.phase;
|
||||
out.motion = 'unknown';
|
||||
out.fromSeq = -1;
|
||||
out.toSeq = -1;
|
||||
out.lat = NaN;
|
||||
out.lon = NaN;
|
||||
out.heightM = NaN;
|
||||
out.segmentCourseDeg = NaN;
|
||||
out.segmentSpeedMps = 0;
|
||||
if (!track.count) return out;
|
||||
let i = Math.min(track.bracket, track.count - 1);
|
||||
if (timeAt(track, i) > sampleTime) i = 0;
|
||||
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
|
||||
track.bracket = i;
|
||||
const a = readFix(track, i, track.from);
|
||||
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
|
||||
const b = readFix(track, j, track.to);
|
||||
const continuous =
|
||||
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
|
||||
out.fromSeq = a.seq;
|
||||
out.toSeq = continuous ? b.seq : a.seq;
|
||||
out.fraction = continuous
|
||||
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
|
||||
: 1;
|
||||
out.fromT = a.t;
|
||||
out.toT = continuous ? b.t : a.t;
|
||||
if (
|
||||
track.metricsRevision !== track.revision ||
|
||||
track.metricsIndex !== i ||
|
||||
track.metricsTo !== j
|
||||
) {
|
||||
const metres = continuous ? distance(a, b) : 0;
|
||||
const metrics = track.metrics;
|
||||
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
|
||||
metrics.course = NaN;
|
||||
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((b.lon - a.lon + 540) % 360) - 180) *
|
||||
Math.cos((a.lat * Math.PI) / 180),
|
||||
b.lat - a.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
} else {
|
||||
// Tiny displacement holds the last reliable course in this epoch.
|
||||
// With no reliable displacement yet, use the earliest known bearing.
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k >= 0; k--) {
|
||||
readFix(track, k, q);
|
||||
if (q.epoch !== a.epoch) break;
|
||||
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
|
||||
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
|
||||
readFix(track, k - 1, p);
|
||||
if (
|
||||
p.epoch === q.epoch &&
|
||||
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
|
||||
) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((q.lon - p.lon + 540) % 360) - 180) *
|
||||
Math.cos((p.lat * Math.PI) / 180),
|
||||
q.lat - p.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics.nextMotionT = Infinity;
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k + 1 < track.count; k++) {
|
||||
readFix(track, k, p);
|
||||
readFix(track, k + 1, q);
|
||||
if (
|
||||
p.lat !== q.lat ||
|
||||
p.lon !== q.lon ||
|
||||
p.epoch !== q.epoch ||
|
||||
q.flags & FIX_FLAGS.BREAK
|
||||
) {
|
||||
metrics.nextMotionT =
|
||||
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
track.metricsRevision = track.revision;
|
||||
track.metricsIndex = i;
|
||||
track.metricsTo = j;
|
||||
}
|
||||
const f = out.fraction;
|
||||
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
|
||||
out.lon = continuous
|
||||
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
|
||||
: a.lon;
|
||||
if (out.lon > 180) out.lon -= 360;
|
||||
if (out.lon < -180) out.lon += 360;
|
||||
out.heightM = continuous
|
||||
? a.heightM + (b.heightM - a.heightM) * f
|
||||
: a.heightM;
|
||||
out.segmentCourseDeg = track.metrics.course;
|
||||
out.segmentSpeedMps = track.metrics.speed;
|
||||
out.motion = continuous
|
||||
? track.metrics.speed > 0
|
||||
? 'moving'
|
||||
: 'stopped'
|
||||
: 'unknown';
|
||||
out.nextMotionT = track.metrics.nextMotionT;
|
||||
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor +
|
||||
track.lastMono -
|
||||
track.monoAnchor -
|
||||
timeAt(track, track.count - 1)
|
||||
: NaN;
|
||||
out.actualDelayMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
|
||||
: NaN;
|
||||
return out;
|
||||
}
|
||||
export function advance(track, clocks, out) {
|
||||
const wall = anchoredWall(track, clocks),
|
||||
previousMono = track.lastMono;
|
||||
const dt = Number.isFinite(previousMono)
|
||||
? Math.max(0, clocks.monoNowMs - previousMono)
|
||||
: 0;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
if (!track.count) return sampleAt(track, NaN, out);
|
||||
const newest = timeAt(track, track.count - 1),
|
||||
oldest = timeAt(track, 0);
|
||||
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
|
||||
const delayBlocked =
|
||||
track.offset === 0 &&
|
||||
track.phase !== 'playing' &&
|
||||
ceiling < track.displayT &&
|
||||
track.displayT < newest;
|
||||
if (!Number.isFinite(track.displayT))
|
||||
track.displayT = Math.min(
|
||||
newest,
|
||||
wall - track.targetDelayMs + track.offset,
|
||||
);
|
||||
else if (
|
||||
track.offset === 0 &&
|
||||
track.rate !== 0 &&
|
||||
(ceiling - track.displayT > 90_000 ||
|
||||
(track.truncated && track.displayT < oldest))
|
||||
) {
|
||||
track.resetReason =
|
||||
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
|
||||
track.displayT = ceiling;
|
||||
track.resets++;
|
||||
} else if (track.rate !== 0) {
|
||||
// While playing, finish the active segment at 1x even if lag grows.
|
||||
const activeCeiling =
|
||||
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
|
||||
let next = track.displayT + dt * track.rate;
|
||||
if (track.phase === 'held' && track.resumePending) {
|
||||
const sinceReceipt = Number.isFinite(track.acceptedMono)
|
||||
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
|
||||
: 0;
|
||||
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
|
||||
}
|
||||
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
|
||||
}
|
||||
track.phase =
|
||||
track.rate === 0
|
||||
? 'paused'
|
||||
: track.displayT < oldest || delayBlocked
|
||||
? 'buffering'
|
||||
: track.displayT >= newest
|
||||
? 'held'
|
||||
: 'playing';
|
||||
track.resumePending = false;
|
||||
out.displayT = track.displayT;
|
||||
sampleCurrent(track, out);
|
||||
if (out.motion !== 'moving' || track.phase !== 'playing') {
|
||||
const boundary = Math.max(oldest, out.nextMotionT);
|
||||
if (
|
||||
Number.isFinite(boundary) &&
|
||||
boundary > track.displayT &&
|
||||
track.rate > 0
|
||||
)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
|
||||
}
|
||||
if (delayBlocked && track.rate > 0)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
|
||||
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
|
||||
out.motion = 'stale';
|
||||
return out;
|
||||
}
|
||||
export function seek(track, displayTimeMs, clocks) {
|
||||
if (!Number.isFinite(displayTimeMs))
|
||||
throw new TypeError('A seek needs a finite time');
|
||||
anchoredWall(track, clocks);
|
||||
track.displayT = displayTimeMs;
|
||||
track.offset = -1;
|
||||
track.rate = 0;
|
||||
track.phase = 'paused';
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.bracket = 0;
|
||||
track.resets++;
|
||||
track.resetReason = 'seek';
|
||||
}
|
||||
export function setTimeOffsetMs(track, offset, clocks) {
|
||||
if (!Number.isFinite(offset) || offset > 0)
|
||||
throw new RangeError('Offset must be zero or negative');
|
||||
if (Number.isFinite(track.displayT)) {
|
||||
track.resets++;
|
||||
track.resetReason = 'time-offset';
|
||||
}
|
||||
track.offset = offset;
|
||||
track.rate = 1;
|
||||
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = 'buffering';
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function setRate(track, rate, clocks) {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
|
||||
throw new RangeError('Rate must be in [0, 16]');
|
||||
if (track.offset === 0 && rate !== 0 && rate !== 1)
|
||||
throw new RangeError('Live playback uses 1x or hold');
|
||||
track.rate = rate;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = rate === 0 ? 'paused' : 'buffering';
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createTrack,
|
||||
createLagReservoir,
|
||||
createHistoryBudget,
|
||||
insertFix,
|
||||
readFix,
|
||||
advance,
|
||||
sampleAt,
|
||||
seek,
|
||||
setRate,
|
||||
setTimeOffsetMs,
|
||||
mergeHistory,
|
||||
correctHeight,
|
||||
trimTrack,
|
||||
destroyTrack,
|
||||
FIX_BYTES,
|
||||
FIX_FLAGS,
|
||||
} from './contactPlayback.js';
|
||||
const fix = (t, metres = t / 100) => ({
|
||||
t,
|
||||
lat: 42 + metres / 111320,
|
||||
lon: -71,
|
||||
bearingDeg: 0,
|
||||
});
|
||||
const clocks = (t) => ({ wallNowMs: t, monoNowMs: t });
|
||||
const put = (track, t, metres, receipt = t) =>
|
||||
insertFix(track, fix(t, metres), { receivedAt: receipt, wallNowMs: receipt });
|
||||
|
||||
test('10 s reports / 15 s polls and a 6 m crawl move at report speed on ordinary frames', () => {
|
||||
for (const speed of [10, 0.6]) {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
let prior = null,
|
||||
moving = 0,
|
||||
pollJumps = 0;
|
||||
for (let now = 5000; now <= 180000; now += 16) {
|
||||
if (
|
||||
!prior ||
|
||||
Math.floor((now - 5000) / 15000) !== Math.floor((now - 5016) / 15000)
|
||||
) {
|
||||
const t = Math.floor((now - 5000) / 10000) * 10000;
|
||||
put(track, t, (t / 1000) * speed, now);
|
||||
}
|
||||
advance(track, clocks(now), out);
|
||||
if (prior && out.displayT >= 0 && prior.displayT >= 0) {
|
||||
const d = (out.lat - prior.lat) * 111320;
|
||||
const dt = out.displayT - prior.displayT;
|
||||
assert.ok(dt >= 0 && dt <= 16.0001);
|
||||
if (dt > 0 && d > 0) {
|
||||
assert.ok(Math.abs(d / (dt / 1000) - speed) < speed * 0.02);
|
||||
moving++;
|
||||
}
|
||||
if (d > speed * 0.0161) pollJumps++;
|
||||
}
|
||||
prior = { ...out };
|
||||
}
|
||||
assert.ok(
|
||||
moving > 5000,
|
||||
`non-vacuous ${speed} m/s trace: ${moving} moving frames`,
|
||||
);
|
||||
assert.equal(pollJumps, 0);
|
||||
}
|
||||
});
|
||||
test('stationary observations survive, schedule departure, and distinguish underflow', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 0);
|
||||
put(track, 30000, 6);
|
||||
setTimeOffsetMs(track, 0, clocks(35000));
|
||||
advance(track, clocks(35000), out);
|
||||
assert.equal(out.motion, 'stopped');
|
||||
assert.equal(out.nextWakeMonoMs, 40000);
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.motion, 'moving');
|
||||
advance(track, clocks(55000), out);
|
||||
assert.equal(out.phase, 'held');
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
});
|
||||
test('same-time conflicts and invalid coordinates never make zero-duration segments', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
assert.equal(put(track, 0, 0).reason, 'repeat');
|
||||
assert.equal(put(track, 0, 1).reason, 'conflict');
|
||||
for (const value of [
|
||||
{ t: 1, lat: 91, lon: 0 },
|
||||
{ t: 1, lat: 0, lon: NaN },
|
||||
{ t: -1, lat: 0, lon: 0 },
|
||||
])
|
||||
assert.equal(insertFix(track, value).accepted, false);
|
||||
assert.equal(track.count, 1);
|
||||
assert.equal(put(track, 15000, 0).accepted, true);
|
||||
});
|
||||
test('ring wrap retains 15 minutes and pins an active bracket under capacity pressure', () => {
|
||||
const track = createTrack({ capacity: 8 });
|
||||
for (let i = 0; i < 8; i++) put(track, i * 10000, i);
|
||||
seek(track, 5000, clocks(80000));
|
||||
for (let i = 8; i < 30; i++) put(track, i * 10000, i);
|
||||
const sample = sampleAt(track, 5000, {});
|
||||
assert.equal(sample.fromT, 0);
|
||||
assert.equal(sample.toT, 10000);
|
||||
assert.equal(track.count, 8);
|
||||
assert.equal(track.storage.byteLength, 8 * FIX_BYTES);
|
||||
assert.equal(track.truncated, true);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
seek(track, 250000, clocks(300000));
|
||||
trimTrack(track, 300000, 2);
|
||||
assert.ok(track.count <= 5);
|
||||
});
|
||||
test('backward seek and history merge leave clock and live health alone', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 30000, 30);
|
||||
put(track, 45000, 45);
|
||||
seek(track, 37500, clocks(80000));
|
||||
const health = track.acceptedAt;
|
||||
mergeHistory(track, [fix(0, 0), fix(15000, 15), fix(30000, 999)]);
|
||||
assert.equal(track.displayT, 37500);
|
||||
assert.equal(track.acceptedAt, health);
|
||||
assert.equal(readFix(track, 2).lat, fix(30000, 30).lat);
|
||||
seek(track, 7500, clocks(80000));
|
||||
advance(track, clocks(90000), out);
|
||||
assert.equal(out.displayT, 7500);
|
||||
assert.equal(out.phase, 'paused');
|
||||
setRate(track, 1, clocks(90000));
|
||||
advance(track, clocks(91000), out);
|
||||
assert.equal(out.displayT, 8500);
|
||||
});
|
||||
test('system-clock changes do not alter animation elapsed time', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 60000, 600);
|
||||
advance(track, clocks(60000), out);
|
||||
const before = out.displayT;
|
||||
advance(track, { wallNowMs: 999999999, monoNowMs: 60016 }, out);
|
||||
assert.equal(out.displayT - before, 16);
|
||||
setTimeOffsetMs(track, -10000, clocks(60016));
|
||||
assert.equal(track.resetReason, 'time-offset');
|
||||
assert.equal(track.resets, 1);
|
||||
});
|
||||
test('a 90 s outage holds, then resumes without a sprint; backlog reset adds no second delay', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let t = 25000; t <= 120000; t += 100) advance(track, clocks(t), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
put(track, 105000, 105, 120000);
|
||||
advance(track, clocks(120100), out);
|
||||
assert.ok(out.displayT - 15000 <= 100);
|
||||
track.targetDelayMs = 25000;
|
||||
put(track, 300000, 300, 300000);
|
||||
track.targetDelayMs = 25000;
|
||||
advance(track, clocks(300000), out);
|
||||
assert.equal(track.resets, 1);
|
||||
assert.equal(track.resetReason, 'backlog');
|
||||
assert.equal(out.displayT, 275000);
|
||||
});
|
||||
test('lag measures receipt of current minus report of previous and ignores cached deliveries', () => {
|
||||
const feedLag = createLagReservoir(),
|
||||
track = createTrack({ policy: { feedLag } });
|
||||
put(track, 0, 0, 5000);
|
||||
put(track, 10000, 100, 20000);
|
||||
assert.equal(track.lag.values[0], 20000);
|
||||
put(track, 10000, 100, 80000);
|
||||
assert.equal(track.lag.count, 1);
|
||||
put(track, 20000, 200, 50000);
|
||||
assert.equal(track.targetDelayMs, 45000);
|
||||
});
|
||||
test('displayed bearing never reads a future fix; height corrections revise typed storage', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 6), bearingDeg: 270 });
|
||||
const out = sampleAt(track, 7500, {});
|
||||
assert.equal(out.segmentCourseDeg, 90);
|
||||
assert.ok(Number.isNaN(out.heightM));
|
||||
correctHeight(track, out.fromSeq, 11.5);
|
||||
correctHeight(track, out.toSeq, 21.5);
|
||||
assert.equal(sampleAt(track, 7500, out).heightM, 16.5);
|
||||
});
|
||||
test('allocation budget is hard and destruction returns payload bytes', () => {
|
||||
const budget = createHistoryBudget(48 * 16),
|
||||
tracks = Array.from({ length: 3 }, () => createTrack({ budget }));
|
||||
for (const track of tracks)
|
||||
for (let t = 0; t < 100; t++) put(track, t * 1000, t);
|
||||
assert.ok(budget.allocatedBytes <= budget.maxBytes);
|
||||
for (const track of tracks) destroyTrack(track);
|
||||
assert.equal(budget.allocatedBytes, 0);
|
||||
});
|
||||
|
||||
test('tiny displacement holds a preceding reliable course instead of a changed bearing', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 100), bearingDeg: 270 });
|
||||
insertFix(track, { ...fix(30000, 106), bearingDeg: 180 });
|
||||
assert.equal(sampleAt(track, 20000, {}).segmentCourseDeg, 0);
|
||||
});
|
||||
test('an epoch gap wakes at its far boundary without drawing a connecting segment', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
insertFix(track, { ...fix(0, 0), epoch: 1 });
|
||||
insertFix(track, { ...fix(30000, 100), epoch: 2 });
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
assert.equal(out.nextWakeMonoMs, 65000);
|
||||
});
|
||||
|
||||
test('increased lag is adopted while rebuffering, with one monotonic wake and no slow playback', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let now = 25000; now <= 120000; now += 100)
|
||||
advance(track, clocks(now), out);
|
||||
insertFix(track, fix(105000, 105), {
|
||||
receivedAt: 120000,
|
||||
wallNowMs: 120000,
|
||||
monoNowMs: 120000,
|
||||
});
|
||||
advance(track, clocks(120100), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
assert.equal(out.phase, 'buffering');
|
||||
assert.equal(out.nextWakeMonoMs, 125000);
|
||||
advance(track, { wallNowMs: -9999999, monoNowMs: 125000 }, out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
advance(track, { wallNowMs: 9999999, monoNowMs: 125016 }, out);
|
||||
assert.equal(out.displayT, 15016);
|
||||
});
|
||||
|
||||
test('capacity rejection leaves both lag reservoirs and clock policy unchanged', () => {
|
||||
const feedLag = createLagReservoir();
|
||||
const track = createTrack({ capacity: 2, policy: { feedLag } });
|
||||
put(track, 0, 0);
|
||||
put(track, 10000, 10);
|
||||
seek(track, 5000, clocks(10000));
|
||||
const policyState = () =>
|
||||
structuredClone({
|
||||
lag: track.lag,
|
||||
feedLag,
|
||||
targetDelayMs: track.targetDelayMs,
|
||||
acceptedAt: track.acceptedAt,
|
||||
acceptedMono: track.acceptedMono,
|
||||
resumePending: track.resumePending,
|
||||
displayT: track.displayT,
|
||||
wallAnchor: track.wallAnchor,
|
||||
monoAnchor: track.monoAnchor,
|
||||
resetReason: track.resetReason,
|
||||
resets: track.resets,
|
||||
});
|
||||
const before = policyState();
|
||||
assert.deepEqual(put(track, 20000, 20, 300000), {
|
||||
accepted: false,
|
||||
reason: 'capacity',
|
||||
});
|
||||
assert.deepEqual(policyState(), before);
|
||||
});
|
||||
|
||||
test('sampleAt writes the supplied output without replacing nested scratch', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 150);
|
||||
const out = {},
|
||||
from = track.from,
|
||||
to = track.to,
|
||||
metrics = track.metrics;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
assert.equal(sampleAt(track, i, out), out);
|
||||
assert.equal(track.from, from);
|
||||
assert.equal(track.to, to);
|
||||
assert.equal(track.metrics, metrics);
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill pins the live bracket and quarantines outliers on either side', () => {
|
||||
const track = createTrack({
|
||||
policy: {
|
||||
accept: (a, b) =>
|
||||
Math.abs(b.lat - a.lat) * 111320 <= ((b.t - a.t) / 1000) * 36,
|
||||
},
|
||||
});
|
||||
put(track, 10000, 0);
|
||||
put(track, 25000, 100);
|
||||
seek(track, 17500, clocks(40000));
|
||||
const before = { ...sampleAt(track, 17500, {}) };
|
||||
mergeHistory(track, [fix(0, -111320), fix(17500, 25), fix(40000, 111320)]);
|
||||
const after = sampleAt(track, 17500, {});
|
||||
assert.equal(track.count, 2);
|
||||
assert.equal(after.fromT, before.fromT);
|
||||
assert.equal(after.toT, before.toT);
|
||||
assert.equal(after.lat, before.lat);
|
||||
mergeHistory(track, [fix(40000, 111320), fix(55000, 111420)]);
|
||||
assert.equal(
|
||||
track.count,
|
||||
4,
|
||||
'two coherent replacement fixes can be retained',
|
||||
);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
assert.equal(sampleAt(track, 30000, {}).lat, fix(25000, 100).lat);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
rgba,
|
||||
);
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
|
||||
import {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './contactTrailRenderer.js';
|
||||
|
||||
function fixture(t, ground = false) {
|
||||
for (const key of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
]) {
|
||||
const prior = globalThis[key];
|
||||
globalThis[key] = class {};
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete globalThis[key];
|
||||
else globalThis[key] = prior;
|
||||
});
|
||||
}
|
||||
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
|
||||
t.after(() => {
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = width;
|
||||
});
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => primitives.destroy());
|
||||
const groundPrimitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => groundPrimitives.destroy());
|
||||
return {
|
||||
primitives,
|
||||
groundPrimitives,
|
||||
frameState: { context: { depthTexture: ground } },
|
||||
globe: { show: false },
|
||||
};
|
||||
}
|
||||
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
|
||||
const scene = fixture(t),
|
||||
renderer = createContactTrailRenderer(scene);
|
||||
const positions = [0, 1, 2].map((i) =>
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
|
||||
);
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: [
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 0,
|
||||
toT: 10000,
|
||||
positions: positions.slice(0, 2),
|
||||
},
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 10000,
|
||||
toT: 20000,
|
||||
positions: positions.slice(1, 3),
|
||||
},
|
||||
],
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
assert.equal(body.allowPicking, false);
|
||||
// The constructor defers this check until shader preparation. Run the real
|
||||
// Cesium validator without WebGL, including a missing-attribute control.
|
||||
const source = body.depthFailAppearance.vertexShaderSource;
|
||||
body._batchTableAttributeIndices = { color: 0 };
|
||||
assert.throws(
|
||||
() => Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
/depthFailColor/,
|
||||
);
|
||||
for (const instance of body.geometryInstances) {
|
||||
assert.deepEqual(
|
||||
instance.attributes.depthFailColor.value,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
}
|
||||
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
|
||||
assert.doesNotThrow(() =>
|
||||
Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
);
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => [
|
||||
instance.id,
|
||||
{
|
||||
show: new Uint8Array([0]),
|
||||
color: new Uint8Array(4),
|
||||
depthFailColor: new Uint8Array(4),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Simulate Cesium's completed upload; exercise public attribute writes.
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => {
|
||||
const stored = attributes.get(id),
|
||||
accessor = {};
|
||||
for (const name of ['show', 'color', 'depthFailColor'])
|
||||
Object.defineProperty(accessor, name, {
|
||||
get: () => stored[name].slice(),
|
||||
set: (value) => {
|
||||
stored[name] = value.slice();
|
||||
},
|
||||
});
|
||||
return accessor;
|
||||
};
|
||||
const marker = Cesium.Cartesian3.lerp(
|
||||
positions[1],
|
||||
positions[2],
|
||||
0.5,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
|
||||
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
|
||||
assert.deepEqual(
|
||||
[...attributes.values()].map((a) => a.show[0]),
|
||||
[1, 1, 0, 0],
|
||||
);
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(
|
||||
a.depthFailColor,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, body);
|
||||
assert.equal(result.rebuilds, 1);
|
||||
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
|
||||
assert.equal(result.head.material.uniforms.fraction, 0.5);
|
||||
assert.equal(result.head.width, 3);
|
||||
assert.equal(result.backing.width, 5);
|
||||
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
|
||||
assert.equal(result.head.positions.length, 2);
|
||||
renderer.setDisplaySample(
|
||||
{ ...sample, displayT: 5000, fraction: 0.25 },
|
||||
positions[0],
|
||||
);
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'rewind hides future geometry again',
|
||||
);
|
||||
renderer.destroy();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
});
|
||||
test('trail age curve uses the specified four alpha anchors', () => {
|
||||
assert.equal(trailAlpha(0), 0.8);
|
||||
assert.equal(trailAlpha(120000), 0.45);
|
||||
assert.equal(trailAlpha(600000), 0.2);
|
||||
assert.equal(trailAlpha(900000), 0.08);
|
||||
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
|
||||
});
|
||||
|
||||
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
|
||||
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
import.meta
|
||||
.resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'),
|
||||
),
|
||||
),
|
||||
);
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
|
||||
];
|
||||
const start = performance.now();
|
||||
const instances = async (count) =>
|
||||
Promise.all(
|
||||
Array.from(
|
||||
{ length: count },
|
||||
async (_, i) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: i,
|
||||
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
|
||||
new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: i % 2 ? 3 : 5,
|
||||
granularity: 0,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const pipelineBytes = async (count) => {
|
||||
// Match GroundPolylinePrimitive's internal Primitive options and the
|
||||
// application's morph-capable scene, including batch IDs and encoding.
|
||||
const result = Cesium.PrimitivePipeline.combineGeometry({
|
||||
instances: await instances(count),
|
||||
projection: new Cesium.GeographicProjection(),
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
elementIndexUintSupported: true,
|
||||
scene3DOnly: false,
|
||||
vertexCacheOptimize: false,
|
||||
compressVertices: false,
|
||||
createPickOffsets: false,
|
||||
});
|
||||
return result.geometries.reduce(
|
||||
(sum, geometry) =>
|
||||
sum +
|
||||
Object.values(geometry.attributes).reduce(
|
||||
(n, a) => n + (a?.values?.byteLength || 0),
|
||||
0,
|
||||
) +
|
||||
geometry.indices.byteLength,
|
||||
0,
|
||||
);
|
||||
};
|
||||
const bytes = await pipelineBytes(1);
|
||||
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
|
||||
const bodyBytes = await pipelineBytes(bodyInstances);
|
||||
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
|
||||
assert.equal(bodyBytes, 2014128);
|
||||
const instanceReserve = 64 * bodyInstances;
|
||||
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
|
||||
assert.ok(
|
||||
bytes <= 2048,
|
||||
'subdivision crossings stay below 2 KiB of head geometry',
|
||||
);
|
||||
console.log(
|
||||
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
|
||||
const scene = fixture(t, true),
|
||||
renderer = createContactTrailRenderer(scene);
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
|
||||
];
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
|
||||
});
|
||||
const first = renderer.diagnostics();
|
||||
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
|
||||
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
|
||||
assert.ok(scene.groundPrimitives.contains(first.body));
|
||||
for (const instance of first.body.geometryInstances)
|
||||
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
|
||||
for (let i = 1; i <= 120; i++)
|
||||
renderer.setDisplaySample(
|
||||
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
|
||||
positions[1],
|
||||
);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, first.body);
|
||||
assert.equal(result.headRebuilds, 1);
|
||||
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
|
||||
assert.equal(result.head.material.uniforms.fraction, 0.6);
|
||||
assert.match(result.head.material.shaderSource, /discard/);
|
||||
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
|
||||
scene.globe.show = stack !== 'Google';
|
||||
renderer.setDisplaySample(
|
||||
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
|
||||
positions[1],
|
||||
);
|
||||
const retained = renderer.diagnostics();
|
||||
assert.equal(retained.body, result.body, `${stack}: retained history`);
|
||||
assert.equal(retained.headPrimitive, result.headPrimitive);
|
||||
for (const primitive of [retained.body, retained.headPrimitive]) {
|
||||
assert.equal(
|
||||
primitive.classificationType,
|
||||
Cesium.ClassificationType.BOTH,
|
||||
stack,
|
||||
);
|
||||
assert.equal(primitive.show, true, stack);
|
||||
}
|
||||
}
|
||||
renderer.setVisible(false);
|
||||
assert.equal(result.body.show, false);
|
||||
assert.equal(result.headPrimitive.show, false);
|
||||
renderer.destroy();
|
||||
assert.equal(scene.groundPrimitives.length, 0);
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
|
||||
const renderer = createContactTrailRenderer(fixture(t));
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.001),
|
||||
];
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: Array.from({ length: 100 }, (_, i) => ({
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: i * 500,
|
||||
toT: (i + 1) * 500,
|
||||
positions,
|
||||
})),
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
const counts = { show: 0, color: 0, depthFailColor: 0 };
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => {
|
||||
const result = {};
|
||||
for (const name of Object.keys(counts)) {
|
||||
let value = instance.attributes[name].value;
|
||||
Object.defineProperty(result, name, {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
value = next;
|
||||
counts[name]++;
|
||||
},
|
||||
});
|
||||
}
|
||||
return [instance.id, result];
|
||||
}),
|
||||
);
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
const display = (displayT) =>
|
||||
renderer.setDisplaySample(
|
||||
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
|
||||
positions[0],
|
||||
);
|
||||
const reset = () => {
|
||||
for (const name of Object.keys(counts)) counts[name] = 0;
|
||||
};
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(600);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
reset();
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'backward seek hides the completed range',
|
||||
);
|
||||
reset();
|
||||
display(1100);
|
||||
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
|
||||
renderer.setStyle('#FF4538');
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(
|
||||
a.depthFailColor,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
});
|
||||
+28
-13
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
const record = store.entities.get(contextId);
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
|
||||
if (record?.layerId === metadata.layerId && key !== id)
|
||||
store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
@@ -146,33 +149,44 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
|
||||
export function clearSelectedEntityContextForLayer(
|
||||
layerId,
|
||||
{ evicted = false } = {},
|
||||
) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove obsolete context records when a viewport-scoped layer refreshes. */
|
||||
export function removeEntityContextsForLayer(layerId) {
|
||||
/**
|
||||
* Remove obsolete context records when a viewport-scoped layer refreshes.
|
||||
* Optional retainIds keeps surviving records and their current selection intact.
|
||||
*/
|
||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId) store.entities.delete(id);
|
||||
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||
store.entities.delete(id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
|
||||
if (!record) return false;
|
||||
if (record.entity?.show === false) return false;
|
||||
if (record.dataSource && record.dataSource.show === false) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
cycloneProxy,
|
||||
parseCycloneStatus,
|
||||
attachCycloneGeometry,
|
||||
} from '../../server/providers/cyclones.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 8, 16, 3, 35);
|
||||
const TIME = '2026-09-16T03:00:00.000Z';
|
||||
function status(overrides = {}) {
|
||||
return {
|
||||
activeStorms: [
|
||||
{
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
intensity: '25',
|
||||
pressure: '1006',
|
||||
longitudeNumeric: -125.8,
|
||||
latitudeNumeric: 15.5,
|
||||
movementDir: 255,
|
||||
movementSpeed: 12,
|
||||
lastUpdate: TIME,
|
||||
forecastAdvisory: {
|
||||
advNum: '010',
|
||||
issuance: TIME,
|
||||
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function collections(adv = '10') {
|
||||
const geometry = [
|
||||
{ type: 'Point', coordinates: [-125.8, 15.5] },
|
||||
{
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-125.8, 15.5],
|
||||
[-128, 15],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-126, 15],
|
||||
[-128, 15],
|
||||
[-127, 17],
|
||||
[-126, 15],
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
return geometry.map((shape, index) => ({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
|
||||
advisnum: adv,
|
||||
tau: 0,
|
||||
maxwind: 25,
|
||||
gust: 9999,
|
||||
},
|
||||
geometry: shape,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
function install(options = {}, preview = false) {
|
||||
let handler;
|
||||
const plugin = cycloneProxy({ now: () => NOW, ...options });
|
||||
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
|
||||
middlewares: {
|
||||
use(path, callback) {
|
||||
assert.equal(path, '/api/cyclones');
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
const begin = (url = '/', method = 'GET') => {
|
||||
const res = new EventEmitter();
|
||||
res.writeHead = (code, headers) => {
|
||||
res.statusCode = code;
|
||||
res.headers = headers;
|
||||
};
|
||||
res.end = (body) => {
|
||||
res.body = body;
|
||||
};
|
||||
return { res, done: handler({ url, method }, res).then(() => res) };
|
||||
};
|
||||
return { begin, request: (url, method) => begin(url, method).done };
|
||||
}
|
||||
const body = (res) => JSON.parse(res.body);
|
||||
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function fetcher(calls = [], data = status(), parts = collections()) {
|
||||
return async (url, options) => {
|
||||
calls.push({ url: new URL(url), options });
|
||||
return Response.json(
|
||||
url.includes('CurrentStorms')
|
||||
? data
|
||||
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
test('status retains official clocks, knots, source links and finite current position', () => {
|
||||
const storm = parseCycloneStatus(status(), NOW)[0];
|
||||
assert.equal(storm.advisoryNumber, '10');
|
||||
assert.equal(storm.issuedAt, TIME);
|
||||
assert.equal(storm.positionAt, TIME);
|
||||
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
||||
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
|
||||
assert.equal(storm.windKt, 25);
|
||||
assert.equal(storm.pressureHpa, 1006);
|
||||
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
const missing = parseCycloneStatus(
|
||||
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
|
||||
NOW,
|
||||
)[0];
|
||||
assert.equal(missing.windKt, null);
|
||||
assert.equal(missing.pressureHpa, null);
|
||||
assert.equal(missing.movement.speedKt, null);
|
||||
assert.equal(
|
||||
parseCycloneStatus(
|
||||
status({
|
||||
forecastAdvisory: {
|
||||
advNum: '10',
|
||||
issuance: TIME,
|
||||
url: 'https://www.nhc.noaa.gov.evil/secret',
|
||||
},
|
||||
}),
|
||||
NOW,
|
||||
)[0].advisoryUrl,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
|
||||
for (const payload of [
|
||||
{},
|
||||
{ activeStorms: null },
|
||||
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
|
||||
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
|
||||
status({ id: 'wp012026' }),
|
||||
status({ name: 'a'.repeat(81) }),
|
||||
status({ name: '<script>' }),
|
||||
status({ longitudeNumeric: Infinity }),
|
||||
status({ latitudeNumeric: 91 }),
|
||||
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
|
||||
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
|
||||
status({
|
||||
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
|
||||
}),
|
||||
])
|
||||
assert.throws(() => parseCycloneStatus(payload, NOW));
|
||||
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
|
||||
});
|
||||
|
||||
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
|
||||
// NHC wrote the 03:00Z advisory at 02:34Z.
|
||||
const early = Date.UTC(2026, 8, 16, 2, 34);
|
||||
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
|
||||
/invalid_cyclone_data/,
|
||||
);
|
||||
});
|
||||
|
||||
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
|
||||
const MINUTE = 60_000;
|
||||
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
|
||||
const fields = {
|
||||
issuedAt: (value) =>
|
||||
status({
|
||||
forecastAdvisory: {
|
||||
advNum: '010',
|
||||
issuance: value,
|
||||
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
},
|
||||
}),
|
||||
positionAt: (value) => status({ lastUpdate: value }),
|
||||
};
|
||||
for (const [field, payload] of Object.entries(fields)) {
|
||||
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
|
||||
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
|
||||
assert.equal(storm[field], at(offset), `${field} ${offset}`);
|
||||
}
|
||||
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(payload(at(offset)), NOW),
|
||||
/invalid_cyclone_data/,
|
||||
`${field} ${offset}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
|
||||
const storms = parseCycloneStatus(status(), NOW);
|
||||
const parts = collections();
|
||||
parts[1].features[0].geometry.coordinates = [
|
||||
[179, 15],
|
||||
[-179, 16],
|
||||
];
|
||||
parts[2].features[0].geometry.coordinates.push([
|
||||
[-126.5, 15.5],
|
||||
[-127, 15.5],
|
||||
[-126.8, 16],
|
||||
[-126.5, 15.5],
|
||||
]);
|
||||
const good = attachCycloneGeometry(storms, parts)[0];
|
||||
assert.equal(good.geometryStatus, 'current');
|
||||
assert.equal(good.geometryAdvisoryNumber, '10');
|
||||
assert.deepEqual(good.track, parts[1].features[0].geometry);
|
||||
assert.equal(good.cone.coordinates.length, 2);
|
||||
assert.equal(good.forecastPoints[0].gustKt, null);
|
||||
assert.equal(good.forecastPoints[0].tauHours, 0);
|
||||
assert.equal(
|
||||
'validAt' in good.forecastPoints[0],
|
||||
false,
|
||||
'do not fabricate point time from advisory + tau',
|
||||
);
|
||||
const old = attachCycloneGeometry(storms, collections('9'))[0];
|
||||
assert.equal(old.geometryStatus, 'pending');
|
||||
assert.equal(old.track, null);
|
||||
assert.equal(old.cone, null);
|
||||
assert.deepEqual(old.forecastPoints, []);
|
||||
assert.deepEqual(old.position, storms[0].position);
|
||||
const mixed = collections();
|
||||
mixed[2] = collections('9')[2];
|
||||
const partial = attachCycloneGeometry(storms, mixed)[0];
|
||||
assert.equal(partial.geometryStatus, 'pending');
|
||||
assert.equal(
|
||||
partial.track,
|
||||
null,
|
||||
'a new track and old cone cannot form one advisory snapshot',
|
||||
);
|
||||
});
|
||||
|
||||
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
|
||||
const storms = parseCycloneStatus(status(), NOW);
|
||||
const mutations = [
|
||||
(p) => {
|
||||
p[0].exceededTransferLimit = true;
|
||||
},
|
||||
(p) => {
|
||||
p[0].features = Array(501).fill(p[0].features[0]);
|
||||
},
|
||||
(p) => {
|
||||
p[1].features.push(p[1].features[0]);
|
||||
},
|
||||
(p) => {
|
||||
p[0].features.push(p[0].features[0]);
|
||||
},
|
||||
(p) => {
|
||||
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
|
||||
},
|
||||
(p) => {
|
||||
p[1].features[0].geometry.coordinates[1] = [181, 1];
|
||||
},
|
||||
(p) => {
|
||||
p[2].features[0].geometry.coordinates[0].pop();
|
||||
},
|
||||
(p) => {
|
||||
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
|
||||
},
|
||||
(p) => {
|
||||
p[1].features[0].geometry = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
|
||||
};
|
||||
},
|
||||
(p) => {
|
||||
p[2].features[0].geometry = {
|
||||
type: 'GeometryCollection',
|
||||
geometries: [],
|
||||
};
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const parts = collections();
|
||||
mutate(parts);
|
||||
assert.throws(() => attachCycloneGeometry(storms, parts));
|
||||
}
|
||||
});
|
||||
|
||||
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
|
||||
for (const preview of [false, true]) {
|
||||
const calls = [];
|
||||
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
|
||||
const snapshot = body(await request());
|
||||
assert.equal(snapshot.unavailable, false);
|
||||
assert.equal(snapshot.stale, false);
|
||||
assert.match(snapshot.coverage, /not worldwide/);
|
||||
assert.equal(snapshot.storms[0].geometryStatus, 'current');
|
||||
assert.equal(snapshot.fetchedAt, NOW);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.equal(
|
||||
calls[0].url.href,
|
||||
'https://www.nhc.noaa.gov/CurrentStorms.json',
|
||||
);
|
||||
for (const call of calls) assert.equal(call.options.redirect, 'error');
|
||||
for (const call of calls.slice(1)) {
|
||||
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
|
||||
assert.equal(call.url.searchParams.get('outSR'), '4326');
|
||||
assert.equal(call.url.searchParams.get('f'), 'geojson');
|
||||
assert.notEqual(call.url.searchParams.get('outFields'), '*');
|
||||
}
|
||||
assert.equal(
|
||||
calls[2].url.searchParams.get('outFields'),
|
||||
'idp_source,advisnum',
|
||||
);
|
||||
assert.deepEqual(body(await request()), snapshot);
|
||||
assert.equal(calls.length, 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('query/method rejection cannot redirect or widen provider work', async () => {
|
||||
let calls = 0;
|
||||
const { request } = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
});
|
||||
for (const url of [
|
||||
'/?url=https://evil',
|
||||
'/?storm=ep152026',
|
||||
'/other',
|
||||
'/?bbox=0,0,1,1',
|
||||
])
|
||||
assert.equal((await request(url)).statusCode, 400);
|
||||
assert.equal((await request('/', 'POST')).statusCode, 405);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
|
||||
const calls = [];
|
||||
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
|
||||
assert.deepEqual(body(await empty.request()).storms, []);
|
||||
assert.equal(body(await empty.request()).unavailable, false);
|
||||
assert.equal(calls.length, 1);
|
||||
const broken = install({
|
||||
fetchImpl: async (url) =>
|
||||
url.includes('CurrentStorms')
|
||||
? Response.json(status())
|
||||
: new Response('secret detail', { status: 503 }),
|
||||
});
|
||||
const snapshot = body(await broken.request());
|
||||
assert.equal(snapshot.unavailable, false);
|
||||
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
|
||||
assert.equal(snapshot.storms[0].track, null);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
|
||||
});
|
||||
|
||||
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
|
||||
let clock = NOW;
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
const good = fetcher();
|
||||
const { request } = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async (...args) => {
|
||||
calls++;
|
||||
if (fail) throw new Error('private');
|
||||
return good(...args);
|
||||
},
|
||||
});
|
||||
await request();
|
||||
clock += 299_000;
|
||||
await request();
|
||||
assert.equal(calls, 4);
|
||||
fail = true;
|
||||
clock += 2000;
|
||||
const stale = body(await request());
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, NOW);
|
||||
assert.equal(stale.storms[0].issuedAt, TIME);
|
||||
await request();
|
||||
assert.equal(calls, 5);
|
||||
clock += 12 * 3600_000;
|
||||
const expired = body(await request());
|
||||
assert.equal(expired.unavailable, true);
|
||||
assert.deepEqual(expired.storms, []);
|
||||
});
|
||||
|
||||
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
|
||||
let release;
|
||||
let signal;
|
||||
const good = fetcher();
|
||||
const { begin } = install({
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.includes('CurrentStorms')) {
|
||||
signal = options.signal;
|
||||
await new Promise((resolve, reject) => {
|
||||
release = resolve;
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
return good(url, options);
|
||||
},
|
||||
});
|
||||
const a = begin();
|
||||
const b = begin();
|
||||
await nextTurn();
|
||||
a.res.emit('close');
|
||||
await a.done;
|
||||
assert.equal(signal.aborted, false);
|
||||
assert.equal(a.res.body, undefined);
|
||||
release();
|
||||
assert.equal(body(await b.done).unavailable, false);
|
||||
let calls = 0;
|
||||
const cancel = install({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls++;
|
||||
if (calls > 1) return good(url, options);
|
||||
signal = options.signal;
|
||||
return new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const c = cancel.begin();
|
||||
await nextTurn();
|
||||
c.res.emit('close');
|
||||
await c.done;
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(
|
||||
body(await cancel.request()).unavailable,
|
||||
false,
|
||||
'cancellation does not impose upstream failure cooldown',
|
||||
);
|
||||
});
|
||||
|
||||
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
|
||||
for (const stalled of [false, true]) {
|
||||
let cancelled = false;
|
||||
const { request } = install({
|
||||
timeoutMs: 15,
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
const snapshot = body(await request());
|
||||
assert.equal(snapshot.unavailable, true);
|
||||
assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
|
||||
let release;
|
||||
const good = fetcher();
|
||||
const { request } = install({
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.includes('CurrentStorms'))
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return good(url, options);
|
||||
},
|
||||
});
|
||||
const waiting = Array.from({ length: 32 }, () => request());
|
||||
await nextTurn();
|
||||
assert.equal((await request()).statusCode, 429);
|
||||
release();
|
||||
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
|
||||
const redirected = install({
|
||||
fetchImpl: async () =>
|
||||
new Response('private', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
});
|
||||
assert.equal(body(await redirected.request()).unavailable, true);
|
||||
});
|
||||
+420
-32
@@ -7,7 +7,7 @@ import * as Cesium from 'cesium';
|
||||
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
|
||||
* display carries its own license and required attribution — ODbL (OSM
|
||||
* datacenters/dams, adsb.lol, Overpass roads), CC BY-NC-SA (TeleGeography
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS operators, OpenSky.
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic (CC BY 4.0),
|
||||
* The MIT code license does NOT cover this data.
|
||||
*
|
||||
* These credits are registered ONCE at init as STATIC credits with
|
||||
@@ -25,6 +25,79 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
|
||||
for (const [key, visible] of [
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
state.owners.set(owner, openMapTiles);
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
|
||||
export function hideOsmCredit(viewer, owner) {
|
||||
const state = osmDisplays.get(viewer);
|
||||
if (!state?.owners.delete(owner)) return false;
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -38,10 +111,21 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'adsblol',
|
||||
html:
|
||||
'Military flights, aircraft traces & bounded regional flight fallback: ' +
|
||||
'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' +
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'adsbdb',
|
||||
html:
|
||||
'Aircraft type, registration & flight routes: ' +
|
||||
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
|
||||
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
|
||||
'Guillaume Michel · route data is the work of ' +
|
||||
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -70,34 +154,49 @@ export const DATA_CREDITS = [
|
||||
key: 'openseamap',
|
||||
html:
|
||||
'Sea marks overlay: ' +
|
||||
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a>',
|
||||
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
|
||||
'Data © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap contributors</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'opensnowmap',
|
||||
html:
|
||||
'Ski pistes overlay: Map data © OpenStreetMap contributors, SRTM | Map style © ' +
|
||||
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">OpenSnowMap.org</a> (CC BY-SA)',
|
||||
'Ski pistes overlay: ' +
|
||||
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
|
||||
'Data © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap contributors</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'overpass',
|
||||
key: 'nasa-gibs',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
'Recent imagery: We acknowledge the use of imagery provided by services from ' +
|
||||
"NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
|
||||
'Data and Information System (ESDIS). ' +
|
||||
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
|
||||
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
key: 'wfigs',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
'Wildfire perimeters: ' +
|
||||
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'eccc-aqhi',
|
||||
html:
|
||||
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
|
||||
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
|
||||
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'photon-geocoder',
|
||||
html: 'Keyless place search: <a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
@@ -126,6 +225,16 @@ export const DATA_CREDITS = [
|
||||
'CCTV cameras & frames: City of Austin, TX — ' +
|
||||
'<a href="https://data.austintexas.gov" target="_blank" rel="noopener">data.austintexas.gov</a>',
|
||||
},
|
||||
{
|
||||
key: 'txdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Texas): ' +
|
||||
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'deldot-cctv',
|
||||
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
|
||||
},
|
||||
{
|
||||
key: 'caltrans-cctv',
|
||||
html:
|
||||
@@ -139,9 +248,164 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
|
||||
'Contains OS data © Crown copyright and database rights.',
|
||||
},
|
||||
{
|
||||
key: 'ontario-511-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
|
||||
'· provided as is, without warranty; actual road conditions may differ',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Finland): Fintraffic / ' +
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'lithuania-cctv',
|
||||
html:
|
||||
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
|
||||
// among the services Via Lietuva's open-data statement lists (its
|
||||
// conditions: attribution, reuse on the same terms).
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'taiwan-cctv',
|
||||
html:
|
||||
'Freeway camera stills (Taiwan): ' +
|
||||
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'florida-cctv',
|
||||
html:
|
||||
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
|
||||
'Individual, personal use only under the ' +
|
||||
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
|
||||
"any other use needs FDOT's express written consent",
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Catalonia): ' +
|
||||
'<a href="https://transit.gencat.cat/" target="_blank" rel="noopener">Servei Català de Trànsit</a> ' +
|
||||
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
|
||||
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
|
||||
},
|
||||
{
|
||||
key: 'kingcounty-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (King County, WA): King County Road Services — ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/local-services/roads" target="_blank" rel="noopener">kingcounty.gov</a>, ' +
|
||||
'per the ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'hk-cctv',
|
||||
html:
|
||||
'Traffic snapshot cameras (Hong Kong): ' +
|
||||
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
|
||||
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen · camera ground heights © ' +
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'modified: heights over water set to 0 m and rounded to 0.1 m',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'toronto-cctv',
|
||||
html:
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'mapillary',
|
||||
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'google-street-view',
|
||||
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||
},
|
||||
{
|
||||
key: 'kartaview',
|
||||
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
|
||||
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
|
||||
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
|
||||
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
html:
|
||||
'City bikes (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
|
||||
},
|
||||
{
|
||||
key: 'entur-mobility-gbfs',
|
||||
html:
|
||||
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
|
||||
},
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
key: 'osrm-routing',
|
||||
// The service asks for its attribution to carry a "fix the map" link, so
|
||||
// a reader who spots a wrong turn can go and correct the data it came from.
|
||||
html:
|
||||
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
|
||||
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
@@ -157,20 +421,45 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
|
||||
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
|
||||
},
|
||||
{
|
||||
key: 'weather-noaa',
|
||||
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
|
||||
},
|
||||
{
|
||||
key: 'weather-cyclones',
|
||||
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
|
||||
},
|
||||
{
|
||||
key: 'weather-lightning',
|
||||
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
|
||||
},
|
||||
{
|
||||
key: 'wind-gfs',
|
||||
html:
|
||||
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
|
||||
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
|
||||
'(U.S. public domain; forecast, not observations)',
|
||||
},
|
||||
{
|
||||
key: 'ecmwf-ifs',
|
||||
html:
|
||||
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
|
||||
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
|
||||
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
|
||||
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
key: 'sky-background',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
|
||||
'(Hoffleit & Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html:
|
||||
'Dams: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0) + Open Infrastructure Map',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
},
|
||||
{
|
||||
key: 'firms',
|
||||
@@ -180,6 +469,56 @@ export const DATA_CREDITS = [
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 's2-ghostmaps',
|
||||
html:
|
||||
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
|
||||
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (British Columbia): ' +
|
||||
'<a href="https://www.drivebc.ca/" target="_blank" rel="noopener">DriveBC</a>. ' +
|
||||
'Contains information licensed under the ' +
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'wsdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Washington State): ' +
|
||||
'<a href="https://wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> ' +
|
||||
'(<a href="https://wsdot.com/travel/real-time/cameras" target="_blank" rel="noopener">Travel Center</a>), ' +
|
||||
'used per the ' +
|
||||
'<a href="https://wsdot.wa.gov/about/policies/travel-information-disclaimer" target="_blank" rel="noopener">WSDOT travel information disclaimer</a> ' +
|
||||
'(low-volume public feed; catalog cached, frames proxied on demand)',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Tallinn): City of Tallinn — ' +
|
||||
'<a href="https://ristmikud.tallinn.ee/" target="_blank" rel="noopener">ristmikud.tallinn.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'tarktee-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Estonia road weather): Transpordiamet / Tarktee — ' +
|
||||
'<a href="https://tarktee.transpordiamet.ee/" target="_blank" rel="noopener">tarktee.transpordiamet.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html: 'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New South Wales): ' +
|
||||
'<a href="https://www.livetraffic.com/" target="_blank" rel="noopener">Live Traffic NSW</a> — Transport for NSW ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
|
||||
},
|
||||
{
|
||||
key: 'telegeography',
|
||||
html:
|
||||
@@ -204,15 +543,64 @@ export const TOMTOM_CREDIT = {
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region outline resolves (public
|
||||
* domain — no attribution required; credited as a courtesy). */
|
||||
/** Registered when the first Natural Earth region or state/province outline
|
||||
* resolves (public domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
key: 'natural-earth',
|
||||
html:
|
||||
'Physical region boundaries from ' +
|
||||
'Country, state/province and physical region boundaries from ' +
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled US county outline resolves (public
|
||||
* domain — credited as a courtesy). */
|
||||
export const US_CENSUS_CREDIT = {
|
||||
key: 'us-census-counties',
|
||||
html:
|
||||
'US county boundaries from the ' +
|
||||
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Registered when the Bhote Koshi event reconstruction activates. */
|
||||
export const BHOTE_KOSHI_CREDIT = {
|
||||
key: 'bhote-koshi-2026',
|
||||
html:
|
||||
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
|
||||
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
|
||||
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
|
||||
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
|
||||
};
|
||||
|
||||
/** Registered when the scene-friendly Nepal incident locator activates. */
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
@@ -242,12 +630,12 @@ export function registerDynamicCredit(viewer, credit) {
|
||||
* present in the "Data attribution" popover.
|
||||
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
|
||||
*/
|
||||
export function registerDataCredits(viewer) {
|
||||
export function registerDataCredits(viewer, credits = DATA_CREDITS) {
|
||||
const creditDisplay = viewer?.creditDisplay;
|
||||
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
|
||||
return;
|
||||
}
|
||||
for (const { html } of DATA_CREDITS) {
|
||||
for (const { html } of credits) {
|
||||
// showOnScreen=false → lives in the expandable "Data attribution" popover,
|
||||
// not the on-globe credit line.
|
||||
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
assert.equal(
|
||||
new Set(keys).size,
|
||||
keys.length,
|
||||
'a duplicate key would silently shadow one provider’s credit',
|
||||
);
|
||||
for (const entry of DATA_CREDITS) {
|
||||
assert.ok(entry.key, 'a credit without a key cannot be registered');
|
||||
assert.ok(
|
||||
entry.html && entry.html.trim().length > 0,
|
||||
`credit ${entry.key} has nothing to show`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('adsbdb is credited and carries its published route-data restriction', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
|
||||
assert.ok(
|
||||
credit,
|
||||
'adsbdb supplies aircraft type and routes and must be credited',
|
||||
);
|
||||
// adsbdb publishes this restriction for its route data. Pin the provider's
|
||||
// credits and restriction here so a later edit cannot silently remove them.
|
||||
assert.match(credit.html, /David Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /Jim Mason, Glasgow/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/may not be\s+copied, published, or incorporated into other databases/,
|
||||
);
|
||||
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /PlaneBase/);
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
);
|
||||
assert.equal(osm.length, 1);
|
||||
assert.equal(osm[0].key, 'openstreetmap');
|
||||
assert.match(osm[0].html, /Map and place data/);
|
||||
assert.match(osm[0].html, /© OpenStreetMap contributors/);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
|
||||
/Vector tiles:/,
|
||||
);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
|
||||
/Overture Maps Foundation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
assert.match(
|
||||
[...visible][0].html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match([...visible][0].html, />© OpenStreetMap</);
|
||||
assert.ok([...visible][0].showOnScreen);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(visible.size, 1);
|
||||
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
|
||||
assert.equal(visible.size, 2);
|
||||
hideOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 2, 'traffic still owns both credits');
|
||||
showOsmCredit(viewer, 'datacenters');
|
||||
hideOsmCredit(viewer, 'traffic');
|
||||
assert.equal(
|
||||
visible.size,
|
||||
1,
|
||||
'bundled data does not retain the tile distributor',
|
||||
);
|
||||
hideOsmCredit(viewer, 'datacenters');
|
||||
assert.equal(visible.size, 0);
|
||||
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
|
||||
showOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
|
||||
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
for (const key of [
|
||||
'mapillary',
|
||||
'google-street-view',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
])
|
||||
assert.ok(
|
||||
DATA_CREDITS.some((entry) => entry.key === key),
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
+301
-73
@@ -1,3 +1,4 @@
|
||||
import { paintTransitBracket } from './detectionDraw.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import {
|
||||
acquireAlpha,
|
||||
appendCornerBracket,
|
||||
appendTransitBracket,
|
||||
resolveTier,
|
||||
measureTrackLabel,
|
||||
nearFarScale,
|
||||
@@ -41,11 +43,16 @@ import {
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
isCyberContactThemeActive as isCyberMapThemeActive,
|
||||
} from '../cyberSonar.js';
|
||||
|
||||
/**
|
||||
* @module detection
|
||||
@@ -204,6 +211,8 @@ let _lastRenderMs = 0;
|
||||
let _lastPaintMs = 0;
|
||||
let _lastSolveMs = 0;
|
||||
let _throttleSkipCount = 0;
|
||||
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
let _cyberSonarRenderTimer = null;
|
||||
/** @type {Function|null} External callback invoked when the detection mode changes */
|
||||
let _onModeChange = null;
|
||||
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
|
||||
@@ -217,6 +226,8 @@ let _hostLane = null;
|
||||
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
|
||||
*/
|
||||
let _calloutLane = null;
|
||||
let _transitBracketPaths = new Map();
|
||||
let _transitBracketAlpha = 1;
|
||||
/**
|
||||
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
||||
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||
@@ -275,7 +286,10 @@ let _cockpitModeListener = null;
|
||||
*/
|
||||
export function initDetection(viewer, layers, onModeChange) {
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_hostLane?.unregister?.();
|
||||
_calloutLane?.unregister?.();
|
||||
@@ -318,8 +332,12 @@ export function initDetection(viewer, layers, onModeChange) {
|
||||
|
||||
/** Release the host lane and all retained detection runtime state. */
|
||||
export function destroyDetection() {
|
||||
_clearCyberSonarRenderTimer();
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_cockpitModeListener = null;
|
||||
_cockpitActive = false;
|
||||
@@ -329,6 +347,7 @@ export function destroyDetection() {
|
||||
_hostLane = null;
|
||||
_calloutLane?.unregister?.();
|
||||
_calloutLane = null;
|
||||
_transitBracketPaths.clear();
|
||||
_calloutCount = 0;
|
||||
_viewer = null;
|
||||
_layers = [];
|
||||
@@ -351,6 +370,25 @@ export function destroyDetection() {
|
||||
_lastPaintMs = 0;
|
||||
_lastSolveMs = 0;
|
||||
_throttleSkipCount = 0;
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _clearCyberSonarRenderTimer() {
|
||||
if (_cyberSonarRenderTimer != null) {
|
||||
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
|
||||
_cyberSonarRenderTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleCyberSonarRender(nowMs) {
|
||||
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
|
||||
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
|
||||
_cyberSonarRenderTimer = null;
|
||||
if (isCyberSonarActive()) {
|
||||
governorRequestRender('cyber-sonar-cadence');
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,7 +430,8 @@ export function setMode(modeLabel) {
|
||||
_mode = MODE_OFF;
|
||||
} else {
|
||||
const requestedDensity = defaultDensityForProfile(target);
|
||||
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
|
||||
if (profileForDensity(_densityPct) !== target)
|
||||
_densityPct = requestedDensity;
|
||||
_mode = _modeForDensity();
|
||||
_lastNonOffMode = _mode;
|
||||
}
|
||||
@@ -457,7 +496,10 @@ export function isDetectionSuspended() {
|
||||
*/
|
||||
export function setDetectionTuning(options = {}) {
|
||||
let modeChanged = false;
|
||||
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
|
||||
if (
|
||||
typeof options.densityPct === 'number' &&
|
||||
Number.isFinite(options.densityPct)
|
||||
) {
|
||||
const nextDensity = canonicalizeDensity(options.densityPct);
|
||||
if (nextDensity !== _densityPct) {
|
||||
_densityPct = nextDensity;
|
||||
@@ -472,7 +514,10 @@ export function setDetectionTuning(options = {}) {
|
||||
}
|
||||
}
|
||||
if (options.allocationStrategy != null) {
|
||||
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
|
||||
const nextStrategy = normalizeAllocationStrategy(
|
||||
options.allocationStrategy,
|
||||
_allocationStrategy,
|
||||
);
|
||||
if (nextStrategy !== _allocationStrategy) {
|
||||
_allocationStrategy = nextStrategy;
|
||||
_labelSolveDirty = true;
|
||||
@@ -517,7 +562,14 @@ export function markDetectionSourcesChanged(reason = 'sources-changed') {
|
||||
|
||||
/** Read-only diagnostics for unit/browser QA. */
|
||||
export function getDetectionDiagnostics() {
|
||||
return _lastDiagnostics ? JSON.parse(JSON.stringify(_lastDiagnostics)) : null;
|
||||
if (!_lastDiagnostics) return null;
|
||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
function _publishDiagnostics() {
|
||||
@@ -525,19 +577,30 @@ function _publishDiagnostics() {
|
||||
const dataset = _hostCanvas.dataset;
|
||||
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
||||
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
||||
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.allocationStrategy =
|
||||
_lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
||||
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
|
||||
dataset.collectiveLabelBudget = String(
|
||||
_lastDiagnostics.collectiveLabelBudget ?? 0,
|
||||
);
|
||||
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(
|
||||
_lastDiagnostics.entitlementByLayer || {},
|
||||
);
|
||||
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
||||
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
|
||||
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
|
||||
dataset.labelAlphaByKey = JSON.stringify(
|
||||
_lastDiagnostics.labelAlphaByKey || {},
|
||||
);
|
||||
dataset.bracketOpacityCounts = JSON.stringify(
|
||||
_lastDiagnostics.bracketOpacityCounts || {},
|
||||
);
|
||||
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
||||
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
||||
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
||||
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
||||
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
|
||||
dataset.placementBuildCount = String(
|
||||
_lastDiagnostics.placementBuildCount ?? 0,
|
||||
);
|
||||
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
||||
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
||||
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
||||
@@ -622,7 +685,11 @@ function _applyModeState() {
|
||||
_labelArbiter.clear();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
|
||||
_lastSolveSnapshot = {
|
||||
demandByLayer: {},
|
||||
cohortByLayer: {},
|
||||
cohortCount: 0,
|
||||
};
|
||||
} else {
|
||||
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
||||
_syncSurfaceVisibility();
|
||||
@@ -642,7 +709,18 @@ function _applyModeState() {
|
||||
function _shouldPaintDetectionLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended) return false;
|
||||
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
_frameCount++;
|
||||
if (isCyberSonarActive()) {
|
||||
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
if (!layoutChanged && sonarDelay > 0) {
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
// The skip and the follow-up request come from ONE decision, so the valve
|
||||
// cannot drop a frame without handing its request forward — see
|
||||
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
|
||||
@@ -655,8 +733,10 @@ function _shouldPaintDetectionLane(frame) {
|
||||
});
|
||||
if (decision.skip) {
|
||||
_throttleSkipCount++;
|
||||
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
|
||||
if (_lastDiagnostics)
|
||||
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp)
|
||||
governorRequestRender('detection-paint-skipped');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -677,11 +757,24 @@ function _paintDetectionLane(frame) {
|
||||
_labelSolveDirty = true;
|
||||
}
|
||||
const start = performance.now();
|
||||
const result = _drawOverlay(frame)
|
||||
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
const result = _drawOverlay(frame) || {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
_lastRenderMs = performance.now() - start;
|
||||
_lastSolveMs = result.solveMs || 0;
|
||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
|
||||
_lastCyberSonarPaintAt = nowMs;
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
if (_lastDiagnostics) {
|
||||
_lastDiagnostics.frameTotalMs = _lastRenderMs;
|
||||
_lastDiagnostics.paintMs = _lastPaintMs;
|
||||
@@ -695,17 +788,19 @@ function _paintDetectionLane(frame) {
|
||||
// exactly what makes it safe where the old blanket hold was not. On a parked
|
||||
// scene with nothing animating it asks for nothing, and the governor stays
|
||||
// idle. See `detectionRenderDemand.js`.
|
||||
if (detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})) {
|
||||
if (
|
||||
detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs,
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})
|
||||
) {
|
||||
governorRequestRender('detection-animation');
|
||||
}
|
||||
}
|
||||
@@ -794,7 +889,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
|
||||
if (!_debugBanner) return;
|
||||
const mode = MODE_LABELS[_mode];
|
||||
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
||||
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const allocation =
|
||||
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
||||
_ctx.fillStyle = _theme.labelBg;
|
||||
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
||||
@@ -890,16 +986,27 @@ function _buildLabelPlacements(
|
||||
const placements = [];
|
||||
for (const placement of raw) {
|
||||
const { cardX, cardY } = placement;
|
||||
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
|
||||
if (
|
||||
cardX < margin ||
|
||||
cardY < margin ||
|
||||
cardX + card.w > width - margin ||
|
||||
cardY + card.h > height - margin
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
||||
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
||||
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
||||
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
|
||||
const leadToY = placement.leadToSide.startsWith('S')
|
||||
? cardY + card.h
|
||||
: cardY;
|
||||
const centerX = cardX + card.w * 0.5;
|
||||
const centerY = cardY + card.h * 0.5;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
centerX,
|
||||
centerY,
|
||||
keyhole,
|
||||
);
|
||||
if (radialAlpha <= 0) continue;
|
||||
placements.push({
|
||||
...placement,
|
||||
@@ -930,18 +1037,38 @@ function _buildLabelPlacements(
|
||||
*/
|
||||
function _stashCallout(entry, acquireFade, keyhole) {
|
||||
const { candidate, placement, temporalAlpha } = entry;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
|
||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
const alpha =
|
||||
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
|
||||
if (alpha <= 0.001) return;
|
||||
|
||||
let row = _calloutPool[_calloutCount];
|
||||
if (!row) {
|
||||
row = {
|
||||
x: 0, y: 0, w: 0, h: 0,
|
||||
primaryX: 0, microX: 0, baseline: 0,
|
||||
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
|
||||
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
|
||||
font: FONT, microFont: MICRO_FONT, alpha: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
primaryX: 0,
|
||||
microX: 0,
|
||||
baseline: 0,
|
||||
leadFromX: 0,
|
||||
leadFromY: 0,
|
||||
leadToX: 0,
|
||||
leadToY: 0,
|
||||
plate: '',
|
||||
plateScale: 1,
|
||||
accent: '',
|
||||
label: '',
|
||||
primary: '',
|
||||
micro: '',
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -981,9 +1108,20 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
* @param {Object} frame Host paint frame.
|
||||
*/
|
||||
function _paintCalloutLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended || _calloutCount === 0) return;
|
||||
if (_mode === MODE_OFF || _suspended) return;
|
||||
const ctx = frame.ctx;
|
||||
if (!ctx) return;
|
||||
for (const bands of _transitBracketPaths.values()) {
|
||||
for (const entry of bands) {
|
||||
if (entry)
|
||||
paintTransitBracket(
|
||||
ctx,
|
||||
entry.path,
|
||||
entry.color,
|
||||
entry.alpha * _transitBracketAlpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
@@ -1006,7 +1144,14 @@ function _plateScaleForBackdrop(skyFactor) {
|
||||
}
|
||||
|
||||
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
||||
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
|
||||
function _materializeCandidate(
|
||||
obj,
|
||||
width,
|
||||
height,
|
||||
keyhole,
|
||||
occlusionRects,
|
||||
cameraPosition,
|
||||
) {
|
||||
const primary = obj._candidatePrimary;
|
||||
const micro = obj._candidateMicro;
|
||||
const card = measureTrackLabel(primary, micro, _charWidth);
|
||||
@@ -1027,7 +1172,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
for (const placement of placements) {
|
||||
centerDistance = Math.min(
|
||||
centerDistance,
|
||||
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
|
||||
Math.hypot(
|
||||
placement.centerX - keyhole.centerX,
|
||||
placement.centerY - keyhole.centerY,
|
||||
),
|
||||
);
|
||||
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
||||
}
|
||||
@@ -1045,10 +1193,13 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
keyholeAlpha,
|
||||
placements,
|
||||
color: obj._candidateColor,
|
||||
sonarFactor: obj._candidateSonarFactor ?? 1,
|
||||
// Costed here rather than in the object sweep: the sweep walks every
|
||||
// observation in view, while this runs only for callouts that actually
|
||||
// placed — a budgeted handful per frame.
|
||||
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
|
||||
plateScale: _plateScaleForBackdrop(
|
||||
skyBackdropFactor(cameraPosition, obj.position),
|
||||
),
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
@@ -1061,22 +1212,36 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
* for a bounded cohort on solve ticks.
|
||||
*/
|
||||
function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
||||
// with every arbiter call in this function — so the frame that paints an
|
||||
// animation's final state is the same frame that ends its demand.
|
||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
const sonarActive = isCyberSonarActive();
|
||||
const cyberMapActive = isCyberMapThemeActive();
|
||||
const sonarSampler = createCyberSonarSampler(width, height, now);
|
||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const shouldSolve =
|
||||
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const calloutOcclusionRects = frame.uiRects;
|
||||
const selectedIdentities = shouldSolve
|
||||
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
||||
: null;
|
||||
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
|
||||
const renderIdentities = _labelArbiter.liveIdentities({
|
||||
includeFading: true,
|
||||
now,
|
||||
});
|
||||
const objects = _collectDetectableObjects();
|
||||
const sampledCount = objects.length;
|
||||
if (objects.length === 0) {
|
||||
@@ -1100,7 +1265,9 @@ function _drawOverlay(frame) {
|
||||
profile: MODE_LABELS[_mode],
|
||||
densityPct: _densityPct,
|
||||
allocationStrategy: _allocationStrategy,
|
||||
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
|
||||
viewScale: viewScaleForAltitude(
|
||||
_viewer?.camera?.positionCartographic?.height,
|
||||
),
|
||||
candidateCount: 0,
|
||||
observationCount: 0,
|
||||
visibleCount: 0,
|
||||
@@ -1128,7 +1295,13 @@ function _drawOverlay(frame) {
|
||||
// whole mechanism exists to remove.
|
||||
_lastLabelSolveAt = now;
|
||||
_labelSolveDirty = false;
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
||||
@@ -1163,7 +1336,10 @@ function _drawOverlay(frame) {
|
||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||
const bracketPaths = new Map();
|
||||
const pathFor = (map, color, alpha) => {
|
||||
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
|
||||
const band = Math.max(
|
||||
1,
|
||||
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
|
||||
);
|
||||
let bands = map.get(color);
|
||||
if (!bands) {
|
||||
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
||||
@@ -1201,8 +1377,10 @@ function _drawOverlay(frame) {
|
||||
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
||||
if (clipW <= 0) continue;
|
||||
const invW = 1 / clipW;
|
||||
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
const sx =
|
||||
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy =
|
||||
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
|
||||
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
||||
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
||||
@@ -1213,26 +1391,54 @@ function _drawOverlay(frame) {
|
||||
if (obj.type === 'AIR') {
|
||||
const bscale = nearFarScale(
|
||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
||||
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
|
||||
BILL_NEAR,
|
||||
BILL_NEAR_SCALE,
|
||||
BILL_FAR,
|
||||
BILL_FAR_SCALE,
|
||||
);
|
||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
|
||||
halfW = obj.bracketHalfWidth;
|
||||
halfH = obj.bracketHalfHeight;
|
||||
} else {
|
||||
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
|
||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||
}
|
||||
// Viewport bounds check with padding
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
|
||||
continue;
|
||||
|
||||
// Tier color drives the bracket, accent bar, and leader line for this object.
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
const admissionAlpha = detectionBracketAlpha(
|
||||
obj.type,
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity,
|
||||
cyberMapActive,
|
||||
);
|
||||
const bracketAlpha = admissionAlpha * sonarFactor;
|
||||
if (bracketAlpha > 0) {
|
||||
appendCornerBracket(pathFor(bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||
pathFor(
|
||||
transit ? _transitBracketPaths : bracketPaths,
|
||||
color,
|
||||
bracketAlpha,
|
||||
),
|
||||
sx,
|
||||
sy,
|
||||
halfW,
|
||||
halfH,
|
||||
);
|
||||
visibleCount++;
|
||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (obj.type === 'AIR')
|
||||
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
else bracketOpacityCounts.partial++;
|
||||
} else {
|
||||
@@ -1254,20 +1460,24 @@ function _drawOverlay(frame) {
|
||||
|
||||
obj._cohortSourceId = sourceId;
|
||||
obj._cohortPriority = _semanticPriority(obj);
|
||||
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
|
||||
obj._cohortBand =
|
||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||
obj._candidateScreenX = sx;
|
||||
obj._candidateScreenY = sy;
|
||||
obj._candidateHalfW = halfW;
|
||||
obj._candidateHalfH = halfH;
|
||||
obj._candidateColor = color;
|
||||
obj._candidateSonarFactor = sonarLabelFactor;
|
||||
obj._candidatePrimary = primary;
|
||||
obj._candidateMicro = micro;
|
||||
|
||||
if (shouldSolve && keyholeAlpha > 0) {
|
||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
if (!cohortBuilders.has(layerId))
|
||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent =
|
||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||
}
|
||||
|
||||
@@ -1281,13 +1491,17 @@ function _drawOverlay(frame) {
|
||||
calloutOcclusionRects,
|
||||
camPos,
|
||||
);
|
||||
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
|
||||
if (candidate && !candidateMap.has(candidate.key))
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
|
||||
const ambientBudget = Math.max(
|
||||
0,
|
||||
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
|
||||
);
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
@@ -1300,7 +1514,9 @@ function _drawOverlay(frame) {
|
||||
const solveCandidates = [];
|
||||
const cohortByLayer = {};
|
||||
for (const [layerId, builder] of cohortBuilders) {
|
||||
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
|
||||
const cohort = builder.values(
|
||||
cohortCapForQuota(quotas.get(layerId) || 0),
|
||||
);
|
||||
for (const obj of cohort) {
|
||||
const key = _detectionKey(layerId, obj._cohortSourceId);
|
||||
let candidate = candidateMap.get(key);
|
||||
@@ -1318,7 +1534,9 @@ function _drawOverlay(frame) {
|
||||
}
|
||||
if (candidate) solveCandidates.push(candidate);
|
||||
}
|
||||
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
|
||||
cohortByLayer[layerId] = solveCandidates.filter(
|
||||
(candidate) => candidate.layerId === layerId,
|
||||
).length;
|
||||
}
|
||||
const solveStarted = performance.now();
|
||||
_labelArbiter.solve(solveCandidates, {
|
||||
@@ -1346,6 +1564,7 @@ function _drawOverlay(frame) {
|
||||
|
||||
const fade = acquireAlpha(_enableTime, now, FADE_MS);
|
||||
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
|
||||
_transitBracketAlpha = fade * bracketPresentationOpacity;
|
||||
|
||||
// Brackets — batched by tier color and linear radial-opacity band.
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
@@ -1398,14 +1617,17 @@ function _drawOverlay(frame) {
|
||||
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
||||
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
||||
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
||||
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
])),
|
||||
labelAlphaByKey: Object.fromEntries(
|
||||
renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha *
|
||||
keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
]),
|
||||
),
|
||||
solveRevision: _labelArbiter.solveRevision,
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
@@ -1430,5 +1652,11 @@ function _drawOverlay(frame) {
|
||||
_drawModeBanner(visibleCount, sampledCount);
|
||||
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
||||
// run — the frame that carried the request cannot be the last one.
|
||||
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
|
||||
return {
|
||||
didSolve,
|
||||
solveMs,
|
||||
fadingCount,
|
||||
animatingCount,
|
||||
solvePending: _labelSolveDirty,
|
||||
};
|
||||
}
|
||||
+42
-13
@@ -18,12 +18,15 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
}
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
function compareCohortContenders(a, b) {
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
const bandDelta =
|
||||
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
if (bandDelta) return bandDelta;
|
||||
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
const hashDelta =
|
||||
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
if (hashDelta) return hashDelta;
|
||||
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
||||
}
|
||||
@@ -39,8 +42,14 @@ function isWorse(a, b) {
|
||||
*/
|
||||
export class BoundedCohort {
|
||||
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
||||
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
|
||||
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
|
||||
const ceiling = Math.max(
|
||||
1,
|
||||
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
|
||||
);
|
||||
this.maxSize = Math.max(
|
||||
1,
|
||||
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
|
||||
);
|
||||
this.incumbents = [];
|
||||
this.heap = [];
|
||||
}
|
||||
@@ -62,11 +71,20 @@ export class BoundedCohort {
|
||||
}
|
||||
|
||||
values(limit = this.maxSize) {
|
||||
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
|
||||
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
const incumbents = this.incumbents
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, cap);
|
||||
const remaining = Math.max(0, cap - incumbents.length);
|
||||
if (remaining === 0) return incumbents;
|
||||
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
|
||||
const contenders = this.heap
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, remaining);
|
||||
return incumbents.concat(contenders);
|
||||
}
|
||||
|
||||
@@ -75,7 +93,10 @@ export class BoundedCohort {
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2);
|
||||
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
||||
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
|
||||
[this.heap[current], this.heap[parent]] = [
|
||||
this.heap[parent],
|
||||
this.heap[current],
|
||||
];
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
@@ -86,10 +107,18 @@ export class BoundedCohort {
|
||||
const left = current * 2 + 1;
|
||||
const right = left + 1;
|
||||
let worst = current;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
|
||||
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
|
||||
worst = left;
|
||||
if (
|
||||
right < this.heap.length &&
|
||||
isWorse(this.heap[right], this.heap[worst])
|
||||
)
|
||||
worst = right;
|
||||
if (worst === current) return;
|
||||
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
|
||||
[this.heap[current], this.heap[worst]] = [
|
||||
this.heap[worst],
|
||||
this.heap[current],
|
||||
];
|
||||
current = worst;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,11 +213,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
|
||||
*/
|
||||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||||
if (!rect || !Array.isArray(obstacles)) return false;
|
||||
return obstacles.some((obstacle) => obstacle
|
||||
&& rect.x < obstacle.x + obstacle.w + padding
|
||||
&& rect.x + rect.w + padding > obstacle.x
|
||||
&& rect.y < obstacle.y + obstacle.h + padding
|
||||
&& rect.y + rect.h + padding > obstacle.y);
|
||||
return obstacles.some(
|
||||
(obstacle) =>
|
||||
obstacle &&
|
||||
rect.x < obstacle.x + obstacle.w + padding &&
|
||||
rect.x + rect.w + padding > obstacle.x &&
|
||||
rect.y < obstacle.y + obstacle.h + padding &&
|
||||
rect.y + rect.h + padding > obstacle.y,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,3 +242,28 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
const t = (distance - near) / (far - near);
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
|
||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
ctx.save?.();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = '#05080C';
|
||||
ctx.lineWidth = 3.25;
|
||||
ctx.stroke(path);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.stroke(path);
|
||||
ctx.restore?.();
|
||||
}
|
||||
|
||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||
appendCornerBracket(
|
||||
sink,
|
||||
Math.floor(sx) + 0.5,
|
||||
Math.floor(sy) + 0.5,
|
||||
Math.round(halfW),
|
||||
Math.round(halfH),
|
||||
);
|
||||
}
|
||||
@@ -161,3 +161,45 @@ test('nearFarScale interpolates by distance and clamps to the near/far values',
|
||||
const mid = nearFarScale((1000 + 8000000) / 2, 1000, 3.0, 8000000, 0.5);
|
||||
assert.ok(Math.abs(mid - 1.75) < 1e-6, `midpoint ~1.75, got ${mid}`);
|
||||
});
|
||||
|
||||
|
||||
test('transit brackets paint a 1.25 px mode stroke over 3.25 px dark backing', async () => {
|
||||
const { paintTransitBracket } = await import('./detectionDraw.js');
|
||||
const strokes = [];
|
||||
const ctx = { stroke(path) { strokes.push([path, this.lineWidth, this.strokeStyle, this.globalAlpha]); } };
|
||||
const path = {};
|
||||
paintTransitBracket(ctx, path, '#FF4538', 0.75);
|
||||
assert.deepEqual(strokes, [[path, 3.25, '#05080C', 0.75], [path, 1.25, '#FF4538', 0.75]]);
|
||||
});
|
||||
|
||||
test('transit bracket backing and colour use source-over under every inherited sensor theme', async () => {
|
||||
const {paintTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const theme of ['normal','thermal','surveillance','noir','retro']) {
|
||||
const strokes=[];
|
||||
const ctx={globalCompositeOperation:'screen',globalAlpha:0.3,stroke(p){strokes.push([this.globalCompositeOperation,this.lineWidth,this.strokeStyle]);},save(){this.saved=[this.globalCompositeOperation,this.globalAlpha];},restore(){[this.globalCompositeOperation,this.globalAlpha]=this.saved;}};
|
||||
paintTransitBracket(ctx,{},'#FF4538',1);
|
||||
assert.deepEqual(strokes,[['source-over',3.25,'#05080C'],['source-over',1.25,'#FF4538']],theme);
|
||||
assert.equal(ctx.globalCompositeOperation,'screen');
|
||||
}
|
||||
});
|
||||
|
||||
test('transit bracket stroke centres have full pixel coverage at the failing fractional positions', async () => {
|
||||
const {appendTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const sy of [305.076,490.54,279.56]) {
|
||||
const points=[],sink={moveTo:(x,y)=>points.push([x,y]),lineTo:(x,y)=>points.push([x,y])};
|
||||
appendTransitBracket(sink,759.016,sy,11,7);
|
||||
assert.equal(points.length,12);
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
@@ -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';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -96,7 +98,7 @@ function mockContext(target, trace) {
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
@@ -377,6 +379,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
env.postRender.raise();
|
||||
const first = getDetectionDiagnostics();
|
||||
assert.equal(first.profile, 'DENSE');
|
||||
assert.ok(Array.isArray(first.calloutRects));
|
||||
if (first.calloutRects.length) {
|
||||
const x=first.calloutRects[0].x;
|
||||
first.calloutRects[0].x=NaN;
|
||||
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
|
||||
}
|
||||
assert.equal(first.observationCount, 2);
|
||||
assert.equal(first.didSolve, true);
|
||||
assert.ok(first.solveRevision > 0);
|
||||
@@ -671,7 +679,8 @@ test('pathological detection paint holds alternate frames without freezing share
|
||||
|
||||
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const uiSource = readShellSource()
|
||||
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
|
||||
@@ -694,7 +703,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
|
||||
// three times" — same guarantee, and the copies can no longer drift.
|
||||
assert.match(
|
||||
uiSource,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/,
|
||||
'the tactical detection default is still Dense @ 75%',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -872,3 +881,82 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a transit vehicle is a contact: box, route, mode and operator on the shared canvas', () => {
|
||||
// The owner's finding was that buses were "green dots moving around
|
||||
// clumsily" — the layer rendered them and the detection overlay ignored
|
||||
// them, because transit published no detectable objects at all. This drives
|
||||
// the real host with the shape the layer now returns.
|
||||
const env = installEnvironment();
|
||||
const objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'mbta:y1276',
|
||||
tier: 'transit_bus',
|
||||
id: '111',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'BUS 34 KM/H',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 900, 6_356_752),
|
||||
sourceId: 'mbta:R-548BBB14',
|
||||
id: 'RED',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'METRO STOPPED',
|
||||
skipLabel: true,
|
||||
},
|
||||
];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'transit', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const painted = env.ctx.calls
|
||||
.filter(([name]) => name === 'fillText')
|
||||
.map(([, text]) => text);
|
||||
assert.ok(
|
||||
painted.includes('111'),
|
||||
`the route is the bright line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
// The host paints a single-line track label — bright id, one dim micro
|
||||
// field — so the mode and what the vehicle is doing share that field.
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('BUS')),
|
||||
`the mode rides along on the micro line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('34 KM/H')),
|
||||
'and so does what the vehicle is doing',
|
||||
);
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
|
||||
// The operator travels on the object for the two-tier card form, which is
|
||||
// where `klass` is read; the host's track label does not paint it.
|
||||
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
|
||||
// The selected vehicle carries its own fuller card, so detection must not
|
||||
// label it a second time.
|
||||
assert.equal(
|
||||
painted.includes('RED'),
|
||||
false,
|
||||
'skipLabel keeps the selected vehicle from being labelled twice',
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
+71
-22
@@ -5,10 +5,12 @@
|
||||
* module so density cannot drift into contradictory mode semantics again.
|
||||
*/
|
||||
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
ALLOCATION_ELASTIC,
|
||||
ALLOCATION_WEIGHTED,
|
||||
]);
|
||||
|
||||
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
||||
// made side aircraft brackets effectively disappear while the same contacts
|
||||
@@ -71,10 +73,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
||||
if (outside <= 0) return 0;
|
||||
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
|
||||
);
|
||||
}
|
||||
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
|
||||
const progress =
|
||||
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
|
||||
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,10 +91,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
|
||||
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
|
||||
* readability floor; the bracket follows the radial keyhole alpha exactly.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
export function detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
strictKeyholeFade = false,
|
||||
) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (strictKeyholeFade) return alpha;
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
}
|
||||
@@ -97,11 +113,11 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
const x = Number(screenX);
|
||||
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
||||
if (x < width / 3) return 'left';
|
||||
if (x > width * 2 / 3) return 'right';
|
||||
if (x > (width * 2) / 3) return 'right';
|
||||
return 'front';
|
||||
}
|
||||
|
||||
export const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
|
||||
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
|
||||
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
|
||||
@@ -140,19 +156,29 @@ export function defaultDensityForProfile(profile) {
|
||||
|
||||
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
||||
export function normalizeProfile(profile) {
|
||||
const raw = String(profile || '').trim().toUpperCase();
|
||||
const raw = String(profile || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (raw === 'OFF') return 'OFF';
|
||||
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
||||
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
|
||||
return 'DENSE';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize the user-selectable layer allocation strategy. */
|
||||
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
|
||||
const raw = String(strategy || '').trim().toUpperCase();
|
||||
export function normalizeAllocationStrategy(
|
||||
strategy,
|
||||
fallback = ALLOCATION_ELASTIC,
|
||||
) {
|
||||
const raw = String(strategy || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
||||
const normalizedFallback = String(fallback || '').trim().toUpperCase();
|
||||
const normalizedFallback = String(fallback || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
||||
? normalizedFallback
|
||||
: ALLOCATION_ELASTIC;
|
||||
@@ -160,7 +186,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
|
||||
|
||||
/** Classify camera altitude into the shared label-budget view scale. */
|
||||
export function viewScaleForAltitude(altitudeM) {
|
||||
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
|
||||
const altitude = Number.isFinite(Number(altitudeM))
|
||||
? Math.max(0, Number(altitudeM))
|
||||
: 1e9;
|
||||
if (altitude < 1200) return 'street';
|
||||
if (altitude < 4500) return 'city';
|
||||
if (altitude < 20000) return 'metro';
|
||||
@@ -181,25 +209,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
|
||||
* density was below 75; legacy Sparse remains inside the Sparse band.
|
||||
*/
|
||||
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
||||
const rawMode = String(mode || '').trim().toUpperCase();
|
||||
const rawMode = String(mode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const normalized = normalizeProfile(rawMode);
|
||||
if (normalized === 'OFF') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? densityPct
|
||||
: fallbackDensity;
|
||||
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
||||
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
|
||||
return {
|
||||
enabled: false,
|
||||
profile: profileForDensity(stop),
|
||||
densityPct: stop,
|
||||
};
|
||||
}
|
||||
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
|
||||
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.max(75, Number(densityPct))
|
||||
: 75;
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'DENSE',
|
||||
densityPct: canonicalizeDensity(density),
|
||||
};
|
||||
}
|
||||
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.min(25, Number(densityPct))
|
||||
: 25;
|
||||
const stop = canonicalizeDensity(density, 25);
|
||||
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
||||
}
|
||||
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
|
||||
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? canonicalizeDensity(densityPct)
|
||||
: defaultDensityForProfile(normalized);
|
||||
const stop =
|
||||
profileForDensity(supplied) === normalized
|
||||
? supplied
|
||||
: defaultDensityForProfile(normalized);
|
||||
return { enabled: true, profile: normalized, densityPct: stop };
|
||||
}
|
||||
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
} from './detectionPolicy.js';
|
||||
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
|
||||
|
||||
const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
|
||||
|
||||
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
|
||||
assert.equal(detectionBracketAlpha('AIR', 0), 0);
|
||||
@@ -27,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
|
||||
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
|
||||
});
|
||||
|
||||
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
|
||||
assert.equal(
|
||||
detectionBracketAlpha(
|
||||
'AIR',
|
||||
0.01,
|
||||
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
|
||||
true,
|
||||
),
|
||||
0.01,
|
||||
);
|
||||
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
|
||||
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
|
||||
});
|
||||
|
||||
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
|
||||
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
|
||||
// default ever moves, this fails rather than silently shifting the shipped
|
||||
|
||||
Loaded 100 of 2330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user