mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into pr/mapillary-layer
This commit is contained in:
201 files changed
+26665
-1973
No files matched your search
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* 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),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/countries.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/states_provinces.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/us_census_counties/counties.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
export 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, PACKS.admin1.importJson);
|
||||
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,
|
||||
PACKS.counties.importJson,
|
||||
);
|
||||
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,
|
||||
PACKS.countries.importJson,
|
||||
);
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
createOverpassAlprSource(),
|
||||
createAlprTileSource(),
|
||||
['fetch'],
|
||||
'ALPR source',
|
||||
{ destroy: () => {}, detailZoom: () => null },
|
||||
);
|
||||
export const configureAlprSource = slot.configure;
|
||||
export default createApplicationAlpr({
|
||||
|
||||
+437
-70
@@ -3,7 +3,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -262,9 +266,45 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
let response = async () => cameraResponse();
|
||||
const requests = [];
|
||||
globalThis.fetch = (...args) => {
|
||||
// Marker floors resolve through the shared terrain service; these tests
|
||||
// count camera requests only.
|
||||
if (String(args[0]).includes('/api/terrain/heights'))
|
||||
return Promise.resolve(Response.json({ results: [] }));
|
||||
requests.push(args);
|
||||
return response(...args);
|
||||
};
|
||||
// Rendering tests use normalized records; tile transport has its own fixture suite.
|
||||
if (layer === alprCamerasLayer)
|
||||
configureAlprSource({
|
||||
...createAlprTileSource(),
|
||||
async fetch(box, signal) {
|
||||
const response = await globalThis.fetch('/test/cameras', {
|
||||
signal,
|
||||
body: `data=${encodeURIComponent(buildOverpassQuery(box.south, box.west, box.north, box.east))}`,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Camera source temporarily unavailable');
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body.elements) || body.remark)
|
||||
throw new Error('Incomplete fixture snapshot');
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
records: [
|
||||
...new Map(
|
||||
body.elements
|
||||
.slice(0, QUERY_LIMIT)
|
||||
.map(normalizeAlprNode)
|
||||
.filter(Boolean)
|
||||
.map((r) => [r.id, r]),
|
||||
).values(),
|
||||
],
|
||||
stale: response.headers.get('x-overpass-cache') === 'STALE',
|
||||
saturated: body.elements.length >= QUERY_LIMIT,
|
||||
noCoverage: body.noCoverage,
|
||||
zoomIn: body.zoomIn,
|
||||
};
|
||||
},
|
||||
});
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
@@ -348,88 +388,28 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
}
|
||||
|
||||
test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => {
|
||||
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'do not use up the introduction while data is still loading',
|
||||
);
|
||||
assert.equal(h.credits.size, 0);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.match(credit.html, /class="gev-alpr-credit">ALPR:/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(credit.html, /© OpenStreetMap<\/a>/);
|
||||
t.mock.timers.tick(4999);
|
||||
assert.match(credit.html, />© OpenStreetMap</);
|
||||
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.credits.size, 0);
|
||||
const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm');
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/ALPR camera locations \(automatic license plate readers\)/,
|
||||
);
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(overflow.html, /ODbL 1\.0/);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'refresh must not keep reintroducing the credit',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(4000);
|
||||
alprCamerasLayer.enable();
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'duplicate enable must not restart the timer',
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
||||
/OpenStreetMap contributors.*ODbL/,
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(h.credits.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer');
|
||||
});
|
||||
|
||||
test('disabling midway through the introduction cancels its timer before a new enable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(3000);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
1,
|
||||
'old deadline must not hide the new presentation',
|
||||
);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(h.credits.size, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -765,7 +745,7 @@ test('empty mapped coverage, stale data and a saturated response remain distinct
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/coverage is incomplete/,
|
||||
/No ALPR data for this area/,
|
||||
);
|
||||
h.expire();
|
||||
h.setFetch(async () =>
|
||||
@@ -928,6 +908,122 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
}
|
||||
});
|
||||
|
||||
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
||||
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
||||
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
||||
const floors = new Map([['30.2672', 150]]);
|
||||
let resolveCalls = 0;
|
||||
let releaseResolve;
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
groundFloor: {
|
||||
cachedGroundFloor: (lat) => floors.get(lat.toFixed(4)) ?? null,
|
||||
resolveGroundFloorCells: async () => {
|
||||
resolveCalls++;
|
||||
await new Promise((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
floors.set('30.2682', 160);
|
||||
},
|
||||
},
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {
|
||||
return null;
|
||||
},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const layer = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({
|
||||
records: [
|
||||
{ id: 'directory:1', latitude: 30.2672, longitude: -97.7431 },
|
||||
{ id: 'directory:2', latitude: 30.2682, longitude: -97.7431 },
|
||||
],
|
||||
stale: false,
|
||||
saturated: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const known = h.source.entities.getById('directory:1');
|
||||
const unknown = h.source.entities.getById('directory:2');
|
||||
assert.equal(
|
||||
known.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
||||
151.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(resolveCalls, 1);
|
||||
releaseResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
||||
161.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
h.source.entities.getById('directory:2'),
|
||||
unknown,
|
||||
'placed in place, not rebuilt',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded cameras outside the view say so instead of looking unloaded', async () => {
|
||||
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
||||
// could hold cameras with none on screen and read as a failed load.
|
||||
const h = cameraHarness();
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
let inView = false;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
||||
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
||||
try {
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
2000,
|
||||
);
|
||||
h.viewer.scene.canvas.clientWidth = 800;
|
||||
h.viewer.scene.canvas.clientHeight = 600;
|
||||
await alprCamerasLayer.update();
|
||||
let stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 0);
|
||||
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
||||
assert.match(stats.loadingLabel, /None on screen/);
|
||||
inView = true;
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 1);
|
||||
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
@@ -943,6 +1039,10 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const position = entity.position,
|
||||
line = entity.polyline;
|
||||
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
||||
// bearings are painted by the overlay for the nearest cameras instead.
|
||||
assert.equal(line, undefined);
|
||||
assert.equal(entity.polygon, undefined);
|
||||
let selections = 0,
|
||||
clears = 0,
|
||||
collectionChanges = 0;
|
||||
@@ -1126,3 +1226,270 @@ test('nearby count and discovery control frame a real loaded camera without fetc
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-retryable capability error does not arm the ALPR retry timer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => {
|
||||
throw Object.assign(new Error('Unavailable'), {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
||||
const requests = h.requests.length;
|
||||
t.mock.timers.tick(300_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(h.requests.length, requests);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported ALPR coverage suppresses the nearby count and names the extract region', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => ({
|
||||
...cameraResponse([]),
|
||||
json: async () => ({ elements: [], noCoverage: true }),
|
||||
}));
|
||||
await alprCamerasLayer.update();
|
||||
const stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.noCoverage, true);
|
||||
assert.equal(stats.countLabel, '');
|
||||
assert.equal(
|
||||
stats.loadingLabel,
|
||||
'No ALPR data for this area — US and Canada only',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
|
||||
test('slow ALPR floors share real terrain requests across preparation, render and pans; disable aborts', async () => {
|
||||
const requests = [];
|
||||
const sourceServices = createApplicationRequestServices({
|
||||
fetchImpl: (url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
assert.match(url, /^\/api\/terrain\/heights\?/);
|
||||
const points = new URL(url, 'http://localhost').searchParams
|
||||
.get('points')
|
||||
.split(';');
|
||||
requests.push({
|
||||
signal,
|
||||
points,
|
||||
release: () =>
|
||||
resolve(
|
||||
Response.json({
|
||||
results: points.map(() => ({ ellipsoid: 160 })),
|
||||
}),
|
||||
),
|
||||
});
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
const terrain = createTerrainHeights({ source: sourceServices.terrain });
|
||||
const ground = createGroundFloor({ terrain });
|
||||
ground.FLOOR_RESOLVE_DEADLINE_MS = 1;
|
||||
const records = Array.from({ length: 130 }, (_, i) => ({
|
||||
id: `slow:${i}`,
|
||||
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
||||
longitude: -97.749 + (i % 15) * 0.001,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: ground,
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'render joins the outstanding preparation batch',
|
||||
);
|
||||
assert.equal(
|
||||
requests[0].points.length,
|
||||
64,
|
||||
'one bounded batch runs at a time',
|
||||
);
|
||||
h.expire();
|
||||
await layer.update();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'further preparation and render reuse cold cell promises',
|
||||
);
|
||||
requests[0].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2);
|
||||
const requested = requests.flatMap((request) => request.points);
|
||||
assert.equal(
|
||||
new Set(requested).size,
|
||||
requested.length,
|
||||
'no duplicate cells over HTTP',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
requests[1].signal.aborted,
|
||||
true,
|
||||
'disable cancels the active HTTP request',
|
||||
);
|
||||
requests[1].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
3,
|
||||
'a fresh consumer can resume unresolved cells',
|
||||
);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
requests[2].signal.aborted,
|
||||
true,
|
||||
'destroy also aborts its consumer',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
||||
let floor = 180;
|
||||
let listener;
|
||||
let removed = 0;
|
||||
const records = Array.from({ length: 70 }, (_, i) => ({
|
||||
id: `surface:${i}`,
|
||||
latitude: 30.2672 + i * 0.00001,
|
||||
longitude: -97.7431,
|
||||
directionDeg: i === 0 ? null : 90,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
subscribeMapStack(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
removed++;
|
||||
};
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
h.viewer.scene.globe.show = next !== 180;
|
||||
listener({ detail: { status: 'ready' } });
|
||||
for (const entity of entities) {
|
||||
assert.equal(h.source.entities.getById(entity.id), entity);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
||||
.height - (next == null ? 0 : next + 1.5),
|
||||
) < 0.01,
|
||||
`${entity.id} refreshes at ${next}`,
|
||||
);
|
||||
assert.equal(
|
||||
entity.billboard.heightReference.getValue(),
|
||||
next == null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(removed, 1, 'map listener is disposed');
|
||||
});
|
||||
|
||||
test('a repeated move inside an outstanding query keeps the displayed marker set until commit', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const previous = h.source.entities.getById('alpr:42');
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
let release;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
||||
const pending = alprCamerasLayer.update();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42'),
|
||||
previous,
|
||||
'do not show an intermediate filtered snapshot while the new query prepares',
|
||||
);
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision);
|
||||
assert.equal(h.requests.length, 2, 'both moves share one new query');
|
||||
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
@@ -1035,6 +1037,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
renderPresentation();
|
||||
viewer.dataSources.add(dataSource);
|
||||
registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
requestRender('bhote-koshi-locator-callout');
|
||||
|
||||
try {
|
||||
@@ -1070,6 +1073,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_enabled = false;
|
||||
_boundaryReady = false;
|
||||
_boundaryResolved = false;
|
||||
|
||||
@@ -27,7 +27,7 @@ function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson) {
|
||||
export async function loadBundledJson(url, importJson, { signal } = {}) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
const response = await fetch(url);
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
+86
-54
@@ -25,6 +25,72 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export 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>)',
|
||||
};
|
||||
export 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;
|
||||
}
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -94,42 +160,15 @@ export const DATA_CREDITS = [
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(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) over ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'alpr-osm',
|
||||
html:
|
||||
'ALPR camera locations (automatic license plate readers): ' +
|
||||
'<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 1.0</a>); ' +
|
||||
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context and last-resort place search: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
html: 'Keyless place search: <a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
@@ -212,7 +251,6 @@ export const DATA_CREDITS = [
|
||||
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/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
@@ -262,19 +300,9 @@ export const DATA_CREDITS = [
|
||||
'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',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -307,9 +335,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
html: 'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
@@ -342,15 +368,24 @@ 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).
|
||||
@@ -386,10 +421,7 @@ export const BHOTE_KOSHI_CREDIT = {
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'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)',
|
||||
};
|
||||
|
||||
@@ -37,3 +37,86 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
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);
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
* the layer's pure helpers for the tests and the share-link code.
|
||||
*/
|
||||
|
||||
import * as credits from './dataCredits.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
@@ -23,6 +24,7 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
|
||||
@@ -20,3 +20,24 @@
|
||||
time is interpolated between the frames whose decoded position equals a
|
||||
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
|
||||
category, speed-check and track-accuracy tests; never loaded at runtime.
|
||||
- `ofm-austin-{14-3743-6745,12-935-1686}.pbf` and
|
||||
`ofm-camp-mabry-12-935-1685.pbf` — OpenFreeMap / OpenMapTiles tiles from
|
||||
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
|
||||
retrieved 2026-09-23. Trimmed to one transportation feature per class/direction
|
||||
and one military landuse feature, preserving original geometry and dictionaries.
|
||||
OpenFreeMap © OpenMapTiles Data from OpenStreetMap; © OpenStreetMap
|
||||
contributors, ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
These modified data fixtures retain ODbL attribution and database share-alike;
|
||||
commercial use and redistribution are permitted under that license. Test-only.
|
||||
- `osm-alpr-austin-11-467-843.pbf` — one OSM camera feature (original geometry
|
||||
and attribute dictionary) from the community-hosted hourly US extract,
|
||||
`https://tiles.dontgetflocked.com/cameras-us-hourly/11/467/843.mvt`,
|
||||
retrieved 2026-09-23. © OpenStreetMap contributors, ODbL 1.0; modified by
|
||||
trimming to one feature, test-only. Same attribution/share-alike terms above.
|
||||
|
||||
- `traffic-road-access.json` — representative transportation properties and two
|
||||
original vertices per feature near the Texas Capitol, Camp Mabry greenbelt,
|
||||
and Austin commercial car parks, from OpenFreeMap z14 tiles, retrieved
|
||||
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
|
||||
Modified, test-only OpenStreetMap data; same ODbL attribution and database
|
||||
share-alike terms as the OpenFreeMap fixtures above.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
+125
-82
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
* @file Flow→road matching: assign TomTom congestion levels to OpenStreetMap roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* In OpenStreetMap road mode the layer renders OpenFreeMap polylines, while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
@@ -11,8 +11,10 @@
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* bearing agrees with the road travel direction within 30°. Two-way
|
||||
* roads are represented as separate travel directions by the layer. A
|
||||
* flow line whose `coverage` is `full` describes both directions of its
|
||||
* road, so either travel bearing may match it.
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
@@ -29,7 +31,7 @@ const M_PER_DEG_LAT = 111320;
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
/** @const {number} Max travel-bearing disagreement (degrees). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
@@ -56,15 +58,14 @@ function bearingDeg(dx, dy) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* Travel-bearing disagreement; opposing flow never matches.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
* @returns {number} Angular difference in [0, 180].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
@@ -80,61 +81,45 @@ function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
* Index flow polylines for nearest-segment queries in local meters.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
* Shared by congestion matching and the Hybrid road-source dedupe, so both
|
||||
* use one distance, bearing and direction rule.
|
||||
*
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, closure:boolean, coverage?:string}>} flowSegments
|
||||
* @returns {null|{
|
||||
* project: (lon:number, lat:number) => [number, number],
|
||||
* nearest: (x:number, y:number, bearing:number, radiusFor?:(segment:object) => number) => {best:object|null, ambiguous:boolean, candidate:boolean},
|
||||
* }} Null when no usable segment exists.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createFlowSegmentIndex(flowSegments) {
|
||||
if (!Array.isArray(flowSegments)) return null;
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
if (!anchor) return null;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
const project = (lon, lat) => [
|
||||
(lon - refLon) * mPerDegLon,
|
||||
(lat - refLat) * M_PER_DEG_LAT,
|
||||
];
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
// Spatial hash of flow segments (midpoint-keyed 100 m cells).
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
const cellKey = (cx, cy) => (cx + 0x8000) * 0x10000 + (cy + 0x8000);
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
const bothDirections = flow.coverage === 'full';
|
||||
const roadType = flow.roadType;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const [ax, ay] = project(coords[i][0], coords[i][1]);
|
||||
const [bx, by] = project(coords[i + 1][0], coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
@@ -150,10 +135,15 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
bothDirections,
|
||||
roadType,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
const key = cellKey(
|
||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||
Math.floor((seg.ay + seg.by) / 2 / CELL_SIZE_M),
|
||||
);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
@@ -163,13 +153,87 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
if (grid.size === 0) return null;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
/**
|
||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
||||
*/
|
||||
function nearest(px, py, bearing, radiusFor = null) {
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
let ambiguous = false;
|
||||
let candidate = false;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(cellKey(gx, gy));
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
candidate = true; // within radius, bearing not yet checked
|
||||
if (radiusFor) {
|
||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||
if (d2 > radius * radius) continue;
|
||||
}
|
||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||
if (
|
||||
diff >= BEARING_TOLERANCE_DEG &&
|
||||
!(seg.bothDirections && 180 - diff < BEARING_TOLERANCE_DEG)
|
||||
)
|
||||
continue;
|
||||
if (d2 < bestDist2 - 1) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
ambiguous = false;
|
||||
} else if (Math.abs(d2 - bestDist2) <= 1) {
|
||||
if (
|
||||
best &&
|
||||
(best.level !== seg.level || best.closure !== seg.closure)
|
||||
)
|
||||
ambiguous = true;
|
||||
else if (!best) best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { best, ambiguous, candidate };
|
||||
}
|
||||
return { project, nearest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string, oneway?:number}>} roads
|
||||
* Parsed OpenStreetMap road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} flowSegments
|
||||
* Decoded flow polylines from `flowDecode.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing before bearing rejection.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0) return empty;
|
||||
const index = createFlowSegmentIndex(flowSegments);
|
||||
if (!index) return empty;
|
||||
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
// Sample each road and vote.
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
@@ -180,8 +244,7 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
[xs[i], ys[i]] = index.project(coords[i][0], coords[i][1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
@@ -202,37 +265,17 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
const sampleBearing =
|
||||
(roads[r].oneway === -1 ? 180 : 0) +
|
||||
bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const { best, ambiguous, candidate } = index.nearest(
|
||||
px,
|
||||
py,
|
||||
sampleBearing,
|
||||
);
|
||||
if (candidate) hadCandidate = true;
|
||||
if (best && !ambiguous) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ test('coincident parallel road matches with the flow level', () => {
|
||||
assert.equal(matches[0].closure, false);
|
||||
});
|
||||
|
||||
test('opposite-direction flow still matches (two-way bearing fold)', () => {
|
||||
test('opposite-direction flow matches only the reverse travel direction)', () => {
|
||||
// Same line drawn south->north vs road north->south equivalence.
|
||||
const reversed = line({ lengthM: 500, bearingDeg: 0, eastM: 4 }).reverse();
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [flow(reversed, 0.3)]);
|
||||
const { matches } = matchFlowToRoads([{...ROAD_NS, oneway:-1}], [flow(reversed, 0.3)]);
|
||||
assert.ok(matches[0], 'reversed flow should still match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.3) < 1e-9);
|
||||
});
|
||||
@@ -159,3 +159,28 @@ test('median: single value and empty', () => {
|
||||
assert.equal(median([0.7]), 0.7);
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('one-way rejects opposite closure and two travel directions retain separate congestion', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const forward = {coords, oneway:1};
|
||||
const reverse = {coords, oneway:-1};
|
||||
const east = {coords, trafficLevel:1, closure:false};
|
||||
const west = {coords:[...coords].reverse(), trafficLevel:0, closure:true};
|
||||
assert.equal(matchFlowToRoads([forward],[west]).matches[0], null);
|
||||
for (const flows of [[east,west],[west,east]]) {
|
||||
assert.deepEqual(matchFlowToRoads([forward,reverse],flows).matches,
|
||||
[{level:1,closure:false},{level:0,closure:true}]);
|
||||
}
|
||||
assert.equal(matchFlowToRoads([forward],[east,{...east,trafficLevel:0,closure:true}]).matches[0], null);
|
||||
});
|
||||
|
||||
test('a full-coverage flow line matches both travel directions; one-side does not', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const directions = [{coords, oneway:1}, {coords, oneway:-1}];
|
||||
const full = {coords, trafficLevel:0.4, closure:false, coverage:'full'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [full]).matches,
|
||||
[{level:0.4,closure:false},{level:0.4,closure:false}]);
|
||||
const oneSide = {...full, coverage:'one_side'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [oneSide]).matches,
|
||||
[{level:0.4,closure:false}, null]);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
@@ -37,6 +38,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||
|
||||
function services() {
|
||||
const records = new Map();
|
||||
const credited = [];
|
||||
let selection;
|
||||
return {
|
||||
records,
|
||||
credited,
|
||||
showOsmCredit: (viewer) => credited.push(viewer),
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
@@ -121,6 +124,7 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
osmDerived: true,
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
@@ -150,6 +154,10 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.deepEqual(
|
||||
hosts.map((host) => host.credited),
|
||||
instances.map(({ viewer }) => [viewer]),
|
||||
);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
instances[0].click();
|
||||
assert.ok(hosts[0].selection());
|
||||
@@ -184,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
|
||||
/** Shared ambient infrastructure card contract; the host owns projection and persistence. */
|
||||
export function createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source,
|
||||
position,
|
||||
title,
|
||||
details = [],
|
||||
accent,
|
||||
priority,
|
||||
}) {
|
||||
return {
|
||||
id: String(id),
|
||||
source: source,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: title,
|
||||
details: details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
@@ -18,5 +20,16 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
stats.coverage?.kind === 'subject' &&
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
}
|
||||
@@ -19,3 +19,16 @@ test('first fetch, retry, cached data and success have distinct copy', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready' }), 'Mapped sites loaded');
|
||||
assert.equal(installationFeedback({ stale: true }), 'Showing cached mapped sites');
|
||||
});
|
||||
test('a counted result names what was found and where', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready', count: 3 }), '3 mapped sites in view');
|
||||
assert.equal(installationFeedback({ status: 'empty', count: 0 }), 'No mapped sites in view');
|
||||
const coverage = { kind: 'subject', radiusM: 100_000 };
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'ready', count: 1, coverage }),
|
||||
'1 mapped site within 100 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'unavailable', failureReason: 'tiles_unavailable' }),
|
||||
'Map tiles temporarily unavailable',
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ const FADE_IN_MS = 150;
|
||||
const FADE_OUT_MS = 300;
|
||||
const MIN_LIFETIME_MS = 2500;
|
||||
const COOLDOWN_MS = 1200;
|
||||
function zeroCount(_value, key, map) {
|
||||
map.set(key, 0);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
return Math.max(min, Math.min(max, Math.floor(Number(value) || 0)));
|
||||
@@ -757,6 +760,8 @@ export class LabelArbiter {
|
||||
this._layerBuckets = new Map();
|
||||
this._demand = new Map();
|
||||
this._quotas = new Map();
|
||||
this._acceptedByLayer = new Map();
|
||||
this._paintedByLayer = new Map();
|
||||
this._weightedQuotaScratch = [];
|
||||
this._quotaPriorityScratch = [];
|
||||
this._quotaRemainderScratch = [];
|
||||
@@ -770,6 +775,12 @@ export class LabelArbiter {
|
||||
this._queue = new SpatialCandidateQueue();
|
||||
}
|
||||
|
||||
/** Release an identity when its source changes between ambient and protected paint. */
|
||||
remove(key) {
|
||||
if (this.states.delete(key)) this._stateListDirty = true;
|
||||
this.selectedKeys.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.states.clear();
|
||||
this.selectedKeys.clear();
|
||||
@@ -781,6 +792,8 @@ export class LabelArbiter {
|
||||
this._candidates.length = 0;
|
||||
this._layerBuckets.clear();
|
||||
this._quotas.clear();
|
||||
this._acceptedByLayer.clear();
|
||||
this._paintedByLayer.clear();
|
||||
this._selectedCandidates.length = 0;
|
||||
this._selectedPlacements.length = 0;
|
||||
this._droppedKeys.length = 0;
|
||||
@@ -889,6 +902,12 @@ export class LabelArbiter {
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const limits = options.limitsByLayer;
|
||||
demand.forEach((count, layerId) => {
|
||||
demand.set(layerId, Math.min(count, limits?.get(layerId) ?? Infinity));
|
||||
});
|
||||
const acceptedByLayer = this._acceptedByLayer;
|
||||
acceptedByLayer.forEach(zeroCount);
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
let activeCount = 0;
|
||||
let totalDemand = 0;
|
||||
@@ -937,6 +956,9 @@ export class LabelArbiter {
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const layerCount = acceptedByLayer.get(candidate.layerId) || 0;
|
||||
if (layerCount >= (limits?.get(candidate.layerId) ?? Infinity))
|
||||
return false;
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
// Stateless candidates ignore the re-entry cooldown (they return on the
|
||||
@@ -955,6 +977,7 @@ export class LabelArbiter {
|
||||
selectedCandidates[selectedCount] = candidate;
|
||||
selectedPlacements[selectedCount] = placement;
|
||||
selectedCount++;
|
||||
acceptedByLayer.set(candidate.layerId, layerCount + 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -968,7 +991,8 @@ export class LabelArbiter {
|
||||
if (preserveIncumbents) {
|
||||
for (let i = 0; i < layerCount && accepted < target; i++) {
|
||||
const candidate = layerList[i];
|
||||
if (!this.states.get(candidate.key)?.selected) continue;
|
||||
if (candidate.stateless || !this.states.get(candidate.key)?.selected)
|
||||
continue;
|
||||
if (attempt(candidate, true)) accepted++;
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1163,7 @@ export class LabelArbiter {
|
||||
* Reproject accepted/fading identities from the current frame candidate map.
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
renderEntries(currentCandidates, now = Date.now(), out = [], limits = null) {
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
@@ -1151,6 +1175,20 @@ export class LabelArbiter {
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
const painted = this._paintedByLayer;
|
||||
painted.forEach(zeroCount);
|
||||
// Reserve the live winners before spending any remaining slots on exit fades.
|
||||
if (limits)
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
if (!state.selected) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (candidate)
|
||||
painted.set(
|
||||
candidate.layerId,
|
||||
(painted.get(candidate.layerId) || 0) + 1,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
let temporalAlpha;
|
||||
@@ -1171,9 +1209,14 @@ export class LabelArbiter {
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
if (limits && !state.selected) {
|
||||
const count = painted.get(candidate.layerId) || 0;
|
||||
if (count >= (limits.get(candidate.layerId) ?? Infinity)) continue;
|
||||
painted.set(candidate.layerId, count + 1);
|
||||
}
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
|
||||
@@ -345,6 +345,9 @@ test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||||
'it took the free corner while the preferred one was blocked');
|
||||
const paint = arbiter.renderEntries(new Map([[blocker.key, blocker], [mover.key, mover]]), 1000, []);
|
||||
assert.equal(paint.find((entry) => entry.candidate.key === mover.key).placement.corner, 'below',
|
||||
'painting honors this solve’s collision-free corner even without sticky placement');
|
||||
|
||||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||||
// card returns to its preferred placement immediately.
|
||||
@@ -504,3 +507,44 @@ test('live identities expose selected and temporal fading membership by layer/so
|
||||
]));
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('stateless labels yield occupied slots and re-rank by area on each solve', () => {
|
||||
const make = (key, x, priority) => ({ key, layerId: 'installations', sourceId: key,
|
||||
priority, keyholeAlpha: 1, stateless: true, screenX: x, screenY: 100,
|
||||
placements: [{ corner: 'above', rect: { x, y: 100, w: 100, h: 20 } }],
|
||||
});
|
||||
const large = make('large', 100, 100), small = make('small', 300, 10);
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.selectedKeys.size, 2);
|
||||
small.placements[0].rect.x = 100;
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1016 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['large'], 'incumbency never forces a stateless overlap');
|
||||
const larger = make('larger', 100, 1000);
|
||||
arbiter.solve([large, small, larger], { capacity: 2, now: 1032 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['larger'], 'area priority outranks a smaller incumbent');
|
||||
});
|
||||
|
||||
|
||||
test('a source ceiling bounds shared-domain winners and exit fades without evicting incumbents', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const sites = Array.from({ length: 40 }, (_, i) => candidate('installations', i));
|
||||
const centers = Array.from({ length: 30 }, (_, i) => candidate('flights', i));
|
||||
const limits = new Map([['installations', 24]]);
|
||||
const solve = (list, now) => arbiter.solve(list, { capacity: 54, limitsByLayer: limits, now });
|
||||
solve([...sites, ...centers], 0);
|
||||
const first = new Set(arbiter.selectedKeys);
|
||||
assert.equal([...first].filter(key => key.startsWith('installations:')).length, 24);
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (const site of sites) site.placements[0].rect.x += 0.25;
|
||||
solve([...sites, ...centers], i * 16);
|
||||
assert.deepEqual(arbiter.selectedKeys, first);
|
||||
}
|
||||
const replacements = sites.map((site, i) => ({ ...site, key: `installations:new${i}` }));
|
||||
const current = [...replacements, ...centers];
|
||||
solve(current, 2000);
|
||||
const rendered = arbiter.renderEntries(new Map(current.map(c => [c.key, c])), 2100, [], limits);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'installations').length, 24);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'flights').length, 30);
|
||||
});
|
||||
+13
-1
@@ -298,6 +298,13 @@ function imageryPinOption(key, token) {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
osm: 'o',
|
||||
hybrid: 'h',
|
||||
}),
|
||||
]),
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
@@ -654,7 +661,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'u',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'traffic',
|
||||
token: 't',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'traffic',
|
||||
}),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'weather-cyclones',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -1074,6 +1076,7 @@ export class LayerLifecycle {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
|
||||
const desiredState = Boolean(shouldEnable);
|
||||
if (!desiredState) cancelCameraArrival(this.viewer);
|
||||
if (entry.destroying) {
|
||||
return {
|
||||
intentEpoch: null,
|
||||
@@ -2095,6 +2098,7 @@ export class LayerLifecycle {
|
||||
async destroyLayer(layerId) {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry || entry.destroying) return false;
|
||||
cancelCameraArrival(this.viewer);
|
||||
entry.destroying = true;
|
||||
this._invalidateRefresh(layerId, entry, 'layer-destroyed');
|
||||
// Teardown becomes authoritative before the first await. Advancing the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
@@ -14,10 +15,6 @@ const VISIBILITY_UPDATE_MS = 450;
|
||||
export const LOCAL_OVERLAY_COHORT_LIMIT = 160;
|
||||
const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
// Stems are anchored at ellipsoid height 0, but high-elevation features
|
||||
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
|
||||
// burying the short close-in stem inside the photoreal mesh. Once the
|
||||
@@ -182,33 +179,15 @@ export function createLocalInfrastructureOverlayEntry({
|
||||
accent,
|
||||
}) {
|
||||
const copy = localInfrastructureOverlayCopy(properties, layerId);
|
||||
return {
|
||||
id: String(id),
|
||||
return createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source: layerId,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,6 +373,7 @@ export function createLocalGeoJsonLayer(
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
osmDerived = false,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
@@ -409,6 +389,8 @@ export function createLocalGeoJsonLayer(
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
},
|
||||
) {
|
||||
let _dataSource = null;
|
||||
@@ -538,6 +520,7 @@ export function createLocalGeoJsonLayer(
|
||||
};
|
||||
|
||||
const disableLayer = (viewer) => {
|
||||
hideOsmCredit?.(viewer, id);
|
||||
_enabled = false;
|
||||
clearGroundRetryRender();
|
||||
_activeLodIds = new Set();
|
||||
@@ -942,6 +925,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id);
|
||||
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe
|
||||
if (_enabled && !_preRenderRemover) {
|
||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Natural Earth physical regions pack
|
||||
# Natural Earth packs
|
||||
|
||||
Offline named-region polygons for the voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
|
||||
@@ -8,6 +8,8 @@ range geometry with no network dependency.
|
||||
|------|----------------|----------|
|
||||
| `regions.json` | `ne_10m_geography_regions_polys` (ranges, deserts, plateaus, peninsulas, islands, …) | 1,046 named |
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
@@ -34,3 +36,70 @@ See DATA_SOURCES.md.
|
||||
|
||||
Duplicate names exist upstream (two "Cordillera Oriental", a sliver + real
|
||||
"Canadian Shield", …); the lookup module resolves ties by largest area.
|
||||
|
||||
## States and provinces (`states_provinces.json`)
|
||||
|
||||
Worldwide first-level administrative units for area annotations ("outline
|
||||
Texas", "outline Bavaria"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
**Source:** `geojson/ne_10m_admin_1_states_provinces.geojson` from
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
at commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
|
||||
`22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5`), fetched
|
||||
2026-09-25. `geojson/ne_10m_populated_places_simple.geojson` from the same
|
||||
commit (SHA-256
|
||||
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`) is read
|
||||
only to mark ambiguous names; none of it is bundled.
|
||||
|
||||
**License:** public domain, as above.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only admin1` downloads
|
||||
the pinned files (to the system temp directory, or `--cache <dir>`), checks
|
||||
their SHA-256 and rewrites the pack byte for byte. Parameters are in the
|
||||
script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URLs, hashes, license, curation) and `features[]`
|
||||
with `name`, `nameEn` (when it differs), `alt[]` (Latin-script `name_alt`
|
||||
variants and abbreviations), `postal`, `type` (`type_en`), `rank`
|
||||
(`min_label`), `country` (`admin`), `iso2`, `iso` (ISO 3166-2), `amb[]`,
|
||||
`ambAbroad[]`, `cityState`, `label` ([lon, lat] label point) and
|
||||
`polygons`. `polygons` is a list of parts, largest first, each
|
||||
`[outer, ...holes]`; every ring is open and stored as integers in units of
|
||||
10^-3 degrees (10^-`d` when a feature carries `d`), the first vertex absolute
|
||||
and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- The 7 unnamed features and 2 degenerate Nauru districts are dropped
|
||||
(4,596 → 4,587).
|
||||
- Douglas-Peucker per ring at 0.004 × √(unit area in km²) km, clamped to
|
||||
0.005°–0.02° (Texas ≈ 0.02°, Bavaria ≈ 0.01°); coordinates rounded to
|
||||
3 decimals. The 75 units too small to survive (Maldives atolls, Malta's
|
||||
councils) keep 4 decimals at 0.001° (`d: 4`); 3 smaller still (Vatican
|
||||
City) keep their source rings at 5 decimals (`d: 5`).
|
||||
- Parts under 20 km² or under 0.03% of the unit's largest part are dropped;
|
||||
holes under 20 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- `amb` lists names that are also a country (`admin`/`geonunit` of any
|
||||
unit), a populated place of 200,000+ (`pop_max`) inside the unit's box
|
||||
when the unit is larger than 1,000 km², or a populated place of 50,000+
|
||||
elsewhere in the same country; `ambAbroad` lists names shared only with a
|
||||
populated place of 50,000+ in another country. `cityState` marks a unit of
|
||||
at most 1,000 km² that contains its namesake city (Berlin, Vienna, Paris).
|
||||
548 units carry `amb`, 108 `ambAbroad`, 138 `cityState`.
|
||||
- Result: 3,648,916 bytes (1,260,545 with gzip -9); 6,492 parts, 80 holes,
|
||||
407,339 vertices.
|
||||
|
||||
## Countries (`countries.json`)
|
||||
|
||||
Natural Earth 10m admin-0 countries, public domain under the terms above.
|
||||
Source: `geojson/ne_10m_admin_0_countries.geojson` at the same pinned commit;
|
||||
SHA-256 `239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255`.
|
||||
Regenerate with `node scripts/build-admin-packs.mjs --only countries`.
|
||||
Uses the state/province encoding and simplification, retaining parts of at least
|
||||
20 km² and 0.003% of the largest part (including Alaska and Hawaii); the largest
|
||||
part always stays. Holes under 20 km² are dropped.
|
||||
Includes England, Scotland, Wales and Northern Ireland from
|
||||
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-0 countries","mapUnits":{"url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_map_units.geojson","sha256":"57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862"},"source":"ne_10m_admin_0_countries","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson","sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.00003,"minHoleKm2":20},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Afghanistan","alt":["Islamic State of Afghanistan"],"country":"Afghanistan","iso":"AFG","iso2":"AF","type":"Country","polygons":[[[74383,37127,-15,40,89,10,43,55,123,-1,98,67,73,-84,98,17,-103,100,-129,63,-139,-18,-205,51,-12,-27,-80,3,-36,-65,-479,-121,-112,45,142,76,14,90,-268,53,-189,-16,-164,-81,-64,-69,-277,-95,-133,-191,-398,-62,-423,-268,-184,-12,-100,83,-122,297,63,241,36,453,60,55,8,82,-61,46,-196,-51,-83,33,101,258,-1,67,-407,222,-15,-40,-159,13,-112,-41,20,-18,-85,-40,-17,-72,-36,-12,-77,-142,-216,-148,8,-34,-90,7,-7,-56,82,-71,36,-114,-35,-82,-90,-82,-150,8,-89,68,-117,-35,-293,-3,-131,-142,32,-191,32,-22,-130,-107,-66,-13,-122,65,-139,151,-97,18,28,-59,-80,50,-26,-17,10,-61,-151,27,-42,-72,-223,-54,-12,-47,-104,-2,-10,-93,-92,8,-166,-92,-142,138,-105,46,8,79,-34,40,-114,25,-72,-34,-58,62,-244,-97,-136,97,-26,59,-91,43,-712,-53,-151,54,-63,56,-225,67,-94,70,-22,-49,-80,-19,-38,-80,3,-85,-87,-88,-474,-24,-284,-115,-23,-64,23,-116,-174,-277,-11,-237,-116,-152,-174,-60,-38,-60,-107,8,-102,-55,-12,-106,-155,31,-124,-53,-184,-18,-152,-88,-326,-15,-20,-30,28,-56,102,-71,-137,-74,24,-100,-21,-88,-162,-49,-224,-140,-159,-21,-103,54,-146,-140,-52,153,-227,145,-402,-8,-212,123,-41,74,-82,-10,20,-70,-95,-158,-8,-90,-85,-43,-6,-64,51,-91,-72,-169,-10,-118,-105,-92,-12,-85,-248,-122,190,-197,-246,-12,-151,-168,35,-298,-41,-130,25,-73,144,-78,190,-4,75,-42,-89,-30,-3,-68,-261,-265,-5,-93,267,-809,-15,-161,-40,-61,47,-532,865,-122,55,-52,1,-81,83,-225,-24,-168,-958,-989,1634,-450,1091,89,517,-110,87,97,305,86,558,-30,1160,295,145,122,-122,101,86,187,17,192,-54,164,98,322,184,54,113,106,34,113,246,119,74,-6,18,-73,311,-28,419,126,5,61,-159,16,-42,119,127,-9,147,102,123,15,81,50,92,137,116,-59,243,-41,52,29,-131,29,108,40,149,-48,29,-74,99,-98,236,70,259,265,-48,193,17,170,-35,162,193,192,-50,117,101,85,11,171,60,47,224,40,109,-26,114,38,53,67,77,5,169,120,-8,64,-131,124,19,125,-56,85,-160,36,-111,174,26,90,130,27,325,-87,194,-18,340,26,22,42,56,-6,123,53,45,111,14,192,-130,86,-35,89,109,26,15,115,122,75,86,127,181,75,41,59,-2,63,129,118,-108,111,91,109,-39,83,11,56,-100,61,22,91,-159,232,-185,94,52,98,335,203,-11,44,63,86,173,-61,-10,83,102,19,164,81,20,47,113,27,-19,48,61,24,240,32,176,75,238,-3,324,47,581,15,262,-76,59,15,36,67,106,4,158,92,63,10,80,-42,5,60]]]},{"name":"Akrotiri Sovereign Base Area","alt":["Akrotiri"],"country":"Akrotiri Sovereign Base Area","iso":"WSB","iso2":"-99","type":"Country","polygons":[[[32970,34632,-7,-7,-9,18,12,9,24,-6,-2,27,-33,4,-19,-19,-19,9,-3,-7,-48,19,-6,22,-28,0,-13,-13,-13,-2,-7,-15,-32,5,-7,-7,1,-16,73,18,80,-14,2,-18,29,-45,-4,-25,89,0,-21,28,7,37]]]},{"name":"Aland","alt":["Åland Islands"],"country":"Aland","iso":"ALD","iso2":"AX","type":"Country","polygons":[[[20236,60232,42,47,-76,14,-21,-7,11,26,-14,11,-52,11,-33,21,-21,-5,-48,11,13,-27,-41,22,-68,0,-22,46,-24,4,-44,-6,-61,-45,42,-8,39,-34,31,32,35,-31,10,-32,-28,-10,-13,35,-13,-48,-21,-14,-7,-28,-34,15,5,-24,-9,-17,-22,9,-27,49,32,51,-48,7,22,-21,-62,-17,-21,-31,3,19,-8,7,-36,2,24,-49,11,-5,13,-42,68,-96,26,22,18,4,17,-6,-1,-10,9,-5,13,0,23,7,5,10,14,5,23,0,14,-9,17,-32,-3,-10,10,-2,10,6,-9,30,57,10,39,-7,5,11,-19,17,13,61,-34,-20,-23,17,-43,13,-27,22,-2,10,40,7,-7,-21,68,-20,-1,18,16,15,-37,24,-18,38,27,-12,22,9,9,18,-10,19,37,-3,-4,-17,-33,-38,8,-19,29,-21,-3,-24,7,0,17,32,29,16,77,6,-16,-12,-49,-2,-21,-10,-13,-25,-2,-14,8,-6,42,0,25,-9,13,3,26,29,29,5,-21,21]],[[20025,60042,79,-14,17,-11,-16,-21,18,-7,48,10,13,-6,-9,-22,50,34,-32,61,-30,22,-62,7,-33,-22,-34,-9]],[[19634,60240,-26,17,-40,-2,-31,-23,0,-31,-24,-17,14,-35,38,-19,20,5,18,29,-11,33,55,-41,24,2,0,33,-32,30]],[[20284,59958,23,-4,15,9,3,14,74,4,48,14,-10,10,-98,15,-37,-21,-21,-22]],[[20256,60145,8,-27,-6,-10,-36,42,-24,-46,4,-16,64,-28,12,6,-9,20,29,36,-20,20]],[[20411,60260,-31,10,-21,-4,-12,-13,-2,-15,8,-9,30,12,5,-19,-7,-23,12,-2,48,48,-2,13,-11,8]],[[20583,60059,-38,-3,-44,-43,26,-8,23,14,37,-11,19,15,2,13,-7,3,17,3,-8,3]]]},{"name":"Albania","alt":["Republic of Albania"],"country":"Albania","iso":"ALB","iso2":"AL","type":"Country","polygons":[[[2Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-1 states and provinces","source":"ne_10m_admin_1_states_provinces","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_1_states_provinces.geojson","sha256":"22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5","ambiguitySource":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","ambiguitySha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.0003,"minHoleKm2":20,"ambiguity":{"insideMinPopulation":200000,"elsewhereMinPopulation":50000,"cityStateMaxKm2":1000}},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Badakhshan","alt":["Badahšan"],"postal":"BD","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDS","label":[70.804,36.781],"polygons":[[[74892,37231,-29,14,-74,86,-129,63,-29,-13,-110,-5,-66,18,-77,0,-34,27,-23,-7,-5,13,-12,-27,-80,3,-16,-13,-2,-34,-18,-18,-352,-81,-38,-28,-52,2,-37,-14,-29,25,-62,-9,-16,8,-5,21,34,34,59,9,49,33,14,90,-78,3,-190,50,-44,-1,-63,-27,-82,12,-36,-15,-49,-42,-41,0,-38,-24,-64,-69,-72,-6,-94,-55,-111,-34,-133,-191,-149,-18,-34,-14,-69,11,-146,-41,-133,-94,-290,-174,-88,-20,-96,8,-42,18,-58,65,-25,86,-65,92,-3,63,-29,56,19,150,37,50,7,41,-7,101,24,77,-14,81,5,43,20,28,21,82,-13,41,8,18,52,37,8,82,-61,46,-36,2,-160,-53,-83,33,15,72,29,44,57,142,-1,67,-24,30,-116,45,-101,73,-67,10,-52,57,-47,7,-15,-40,-65,20,-28,-13,-66,6,-35,-27,-77,-14,20,-18,-44,-33,-41,-7,-17,-72,-36,-12,-77,-142,-55,-26,-131,-113,-30,-9,-4,-17,12,-17,-47,-10,-43,17,-11,-25,4,-31,31,-50,51,-21,28,-45,8,-69,-35,-82,-32,-6,-17,-38,-41,-38,-29,-9,-125,19,-5,-20,6,-123,37,-21,-24,-28,-14,-83,30,-88,-1,-53,91,-92,-28,-47,-40,-16,-19,-39,-30,-19,-3,-307,33,-100,1,-53,102,-57,84,-78,137,-23,80,0,31,-20,3,-134,-46,-112,33,-67,-161,-91,-28,-30,-19,-45,-23,-135,63,-9,90,37,95,-31,20,-45,56,-55,-19,-69,2,-39,6,-13,33,-4,58,24,16,45,36,26,43,-40,28,-8,106,-5,19,32,-17,84,12,26,82,16,54,57,-45,41,4,35,47,24,42,56,23,87,85,89,-4,15,42,42,15,37,79,38,17,38,84,30,76,70,79,27,-16,28,5,16,33,30,30,56,39,-5,87,-57,47,1,11,11,-19,40,-2,32,102,19,102,64,62,17,16,13,-5,26,9,8,113,27,-22,35,20,22,132,32,152,15,63,43,113,32,238,-3,78,22,45,-10,51,22,150,13,75,-10,65,15,177,-3,131,18,133,-5,92,-19,82,-42,88,-15,59,15,21,59,15,8,106,4,158,92,63,10,64,-46,16,4,10,54,-28,14,-42,53,-94,44,-17,21,2,19,89,10,43,55,90,11,33,-12,28,29,25,4,45,34,16,-2,57,-82]]]},{"name":"Badghis","alt":["Badghes","Badghisat","Badgis"],"postal":"BG","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDG","label":[63.85,35.27],"polygons":[[[63125,35860,-21,-4,-15,-13,-5,-17,28,-56,72,-58,25,-8,5,-12,-34,-26,-77,-20,-26,-21,0,-66,24,-34,-21,-88,-13,-11,-82,-11,-67,-27,-95,-60,-39,-101,-84,-115,-28,-70,-20,-148,16,-51,49,-17,34,-1,69,-50,46,-14,47,-29,82,-6,31,-35,8,-45,77,-11,62,-24,71,34,116,-10,73,-22,99,-52,76,-13,42,10,77,-19,58,-30,227,53,67,45,45,10,26,39,52,24,107,0,57,-15,110,6,20,29,33,11,123,-4,57,-12,78,30,179,40,48,29,21,56,10,100,-46,76,-74,50,-57,58,-22,9,-32,-1,-49,25,-35,46,-116,-14,-102,12,-101,29,-5,17,6,79,13,53,-1,22,-8,7,-118,-20,-47,21,11,27,-15,22,-220,19,-24,15,-2,107,-12,37,-43,46,-51,18,-8,11,28,54,3,44,26,86,3,83,-11,30,-30,14,-16,-2,-124,-53,-184,-18,-71,-57,-81,-31,-87,-15]]]},{"name":"Baghlan","alt":["Baglan","Bughlan"],"postal":"BL","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BGL","amb":["baghlan"],"label":[68.983,35.859],"polygons":[[[69956,35887,-32,41,-3,19,-20,20,-32,67,-81,65,-10,115,-12,19,-54,8,-11,61,-20,48,-10,3,-56,-32,-92,-26,-79,-48,-99,-17,-23,9,-21,-11,-33,47,-35,8,-35,41,-117,31,-82,62,-84,16,-53,-20,-20,7,-43,28,-64,64,-12,22,-88,-13,-96,20,-217,7,-82,23,-38,-14,-19,-14,-10,-28,152,-201,46,-50,120,-67,31,-26,9,-18,-64,-80,-129,-85,-14,-26,-12,-108,-156,-177,-4,-35,42,-29,-5,-30,-36,-42,-10,-34,-54,-46,-3,-25,20,-60,-12,-32,7,-36,-40,-99,-31,-16,-20,-28,-5,Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
# Military area names
|
||||
|
||||
This database is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code.
|
||||
© [OpenStreetMap contributors](https://www.openstreetmap.org/copyright).
|
||||
Distribution: [Overture Maps Foundation](https://docs.overturemaps.org/attribution/), base/land_use release **2026-09-23.1**.
|
||||
OSM snapshots: **2026-09-06**, with two named records from **2026-09-12**.
|
||||
|
||||
Selected named military areas; geometry simplified to interior label points,
|
||||
bounds and area, coordinates rounded and records re-encoded. Names are unchanged.
|
||||
Rows: `[typedOsmId, name, lon, lat, west, south, east, north, classIndex, areaM2]`.
|
||||
Labels use five decimal places; bounds use four. Area is measured on the WGS84
|
||||
ellipsoid. Class indices refer to `classes`. Output SHA-256: [`names.sha256`](names.sha256).
|
||||
|
||||
Rebuild (maintainer tool; requires Python and `duckdb==1.5.5`):
|
||||
|
||||
```sh
|
||||
python scripts/build-military-names.py --cache /tmp/military-names
|
||||
python scripts/build-military-names.py --cache /tmp/military-names --verify
|
||||
```
|
||||
|
||||
The pinned parquet file list and query are in `scripts/military-names-files.json`
|
||||
and `scripts/military-names.sql`. The first build downloads DuckDB extensions and
|
||||
scans source columns; subsequent builds reuse the cached selected rows.
|
||||
@@ -0,0 +1 @@
|
||||
{"release":"2026-09-23.1","snapshots":["2026-09-06","2026-09-12"],"classes":["military_land","airfield","naval_base","range","barracks","base","training_area"],"records":[["r122949","Scott Air Force Base",-89.85277,38.54384,-89.8799,38.5292,-89.8041,38.5588,1,12413875],["r130448","Fort Campbell",-87.61569,36.63134,-87.8047,36.5355,-87.4231,36.7268,0,419957267],["r133445","Fort George G Meade",-76.74626,39.10227,-76.7766,39.0732,-76.7092,39.131,0,18817854],["r133514","Joint Base Andrews",-76.87745,38.80571,-76.9034,38.7823,-76.8491,38.829,5,17812387],["r139794","Whiteman AFB",-93.56134,38.72992,-93.5959,38.7024,-93.5308,38.7575,1,19580209],["r139848","Fort Leonard Wood",-92.16933,37.70114,-92.2496,37.6052,-92.0411,37.798,0,248751778],["r147157","Camp du Valdahon",6.32589,47.18508,6.2731,47.151,6.375,47.2189,0,34786523],["r169792","Offutt Air Force Base",-95.90817,41.1172,-95.9664,41.0986,-95.8781,41.1364,1,11200098],["r170440","McGuire Air Force Base",-74.57822,40.03444,-74.6026,40.0267,-74.5593,40.0586,5,5388371],["r171293","Cannon AFB",-103.31633,34.38639,-103.3371,34.3653,-103.2955,34.4054,1,13976465],["r171528","White Sands",-106.49662,32.38378,-106.5139,32.3657,-106.4793,32.4031,0,8047394],["r175474","West Point",-74.00783,41.35584,-74.1043,41.3163,-73.9467,41.413,0,64474104],["r176921","Pope Field",-79.01404,35.17337,-79.0319,35.1587,-78.9888,35.1886,1,7327036],["r176940","Fort Bragg",-79.15415,35.14031,-79.3814,35.0306,-78.9447,35.2512,5,554150015],["r206824","Fort Belvoir",-77.14369,38.7108,-77.1739,38.6729,-77.1208,38.7486,0,23156083],["r237502","Joint Base Lewis-McChord",-122.50741,47.03083,-122.802,46.9015,-122.3698,47.1595,0,358289411],["r252043","Francis E. Warren Air Force Base",-104.86328,41.16587,-104.8859,41.1317,-104.8394,41.2012,1,23635348],["r253786","Naval Base Point Loma",-117.24939,32.6941,-117.2562,32.6648,-117.2341,32.7131,5,4741248],["r253814","Marine Corps Air Station Miramar",-117.08249,32.87638,-117.1986,32.8331,-116.9871,32.9208,0,87161025],["r308744","Schietveld",5.44866,51.04934,5.4361,51.0439,5.4672,51.0548,0,1200107],["r309689","Ādažu poligons",24.46976,57.15377,24.3461,57.0837,24.563,57.2226,6,133266211],["r309926","Roberts Army Heliport",-120.74307,35.81448,-120.7467,35.8091,-120.7404,35.8197,1,331999],["r317709","Naval Reservation San Clemente Island",-118.51541,32.92142,-118.6206,32.8004,-118.3487,33.0422,0,163093405],["r317710","Naval Air Warfare Center China Lake",-117.62946,35.75329,-117.8897,35.272,-116.9246,36.2318,0,4495435805],["r317712","Marine Corps Base Camp Pendleton",-117.42402,33.35453,-117.5963,33.2056,-117.2554,33.5036,0,507618719],["r317716","Camp Roberts",-120.77817,35.79079,-120.8594,35.6884,-120.6887,35.8934,6,173959948],["r317718","Camp San Luis Obispo",-120.69818,35.33796,-120.7635,35.3048,-120.6642,35.3716,0,23703563],["r379564","US Coast Guard - Base Portsmouth",-76.35878,36.88311,-76.3678,36.8801,-76.3518,36.8859,0,636553],["r383103","Patuxent River NAS (Trapnell Field)",-76.40977,38.28455,-76.4423,38.2641,-76.3817,38.3042,1,9064162],["r389913","Helmut-Schmidt-Universität – Universität der Bundeswehr Hamburg",10.11122,53.56877,10.1059,53.5663,10.1165,53.5709,4,255487],["r392208","Joint Expeditionary Base Little Creek - Fort Story East East",-76.01769,36.92062,-76.0464,36.9092,-75.9918,36.9323,0,6066899],["r392665","Naval Station Norfolk",-76.29734,36.94167,-76.3307,36.9193,-76.2646,36.964,0,13143152],["r416869","Naval Base Kitsap-Bremerton",-122.64202,47.5574,-122.6622,47.5511,-122.6257,47.5649,5,2487880],["r417668","Camp Murray",-122.56814,47.11741,-122.5809,47.111,-122.5577,47.1229,0,1105917],["r420625","Yakima Training Center",-120.18138,46.75205,-120.4667,46.5473,-119.9159,46.9563,0,1322423534],["r437075","Vojenský újezd Libavá",17.54728,49.67197,17.4068,49.5838,17.6984,49.7602,6,235507050],["r438525","Vojenský újezd Boletice",14.07264,48.8478,13.947,48.7872,14.2431,48.9085,6,165484736],["r439469","Vojenský újezd Hradiště",13.13,50.26224,13.0045,50.1629,13.2555,50.3615,6,280845474],["r440731","Vojenský újezd Březina",16.96557,49.40648,16.8378,49.313,17.0541,49.5,6,149643828],["r451539","Naval Magazine Indian Island",-122.72867,48.05303,-122.7481,48.0187,-122.7014,48.0872,0,10831143],["r451698","Naval Radio Station Jim Creek",-121.92018,48.2028,-121.9584,48.167,-121.8873,48.2249,0,19804554],["r454382","National Security Investigative Service",31.31037,30.05187,31.31,30.0508,31.3117,30.0528,0,14562],["r533411","Naval Outlying Landing Field Coupeville",-122.63203,48.18948,-122.6479,48.171,-122.6152,48.2067,1,2626295],["r533644","Eglin Air Force Base",-86.51396,30.5777,-86.9829,30.3814,-86.1073,30.7498,5,1873152593],["r534267","Saufley Field",-87.34183,30.46763,-87.3512,30.4582,-87.3321,30.4773,0,2666728],["r541791","Whidbey Island Naval Air Station Seaplane Base",-122.56089,48.28799,-122.6453,48.2676,-122.5559,48.3083,0,9647535],["r543980","U.S. Coast Guard - Base Seattle",-122.34035,47.58933,-122.343,47.5879,-122.3375,47.5908,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
74cc07f12c496a622f2f6c5a81922c293bca5eb44e0901c967bbf94e15ee514f names.json
|
||||
@@ -0,0 +1,45 @@
|
||||
# US Census Bureau counties pack
|
||||
|
||||
County and county-equivalent outlines for area annotations ("outline Travis
|
||||
County", "outline Orleans Parish"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
| `counties.json` | `cb_2025_us_county_5m` (cartographic boundary file, 1:5,000,000) | 3,235 |
|
||||
|
||||
**Source:** U.S. Census Bureau cartographic boundary files,
|
||||
<https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip>
|
||||
(SHA-256 `faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74`),
|
||||
fetched 2026-09-25. Covers the 50 states, the District of Columbia, Puerto
|
||||
Rico and the Island Areas (American Samoa, Guam, the Northern Mariana Islands,
|
||||
the US Virgin Islands).
|
||||
|
||||
**License:** public domain — a work of the U.S. Government (17 U.S.C. § 105).
|
||||
No attribution is required; the app credits the U.S. Census Bureau. See
|
||||
DATA_SOURCES.md.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only counties`
|
||||
downloads the pinned zip (to the system temp directory, or `--cache <dir>`),
|
||||
checks its SHA-256, reads the shapefile and rewrites the pack byte for byte.
|
||||
Parameters are in the script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URL, hash, license, curation) and `features[]`
|
||||
with `name` (`NAME`), `full` (`NAMELSAD`, e.g. "Travis County"), `lsad` (the
|
||||
county word: County, Parish, Borough, Census Area, Municipality, city, …),
|
||||
`state` (`STATE_NAME`), `st` (`STUSPS`), `geoid` (`GEOID`) and `polygons`.
|
||||
`polygons` is a list of parts, largest first, each `[outer, ...holes]`; every
|
||||
ring is open and stored as integers in units of 10^-4 degrees, the first
|
||||
vertex absolute and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Shapefile rings are grouped into parts (clockwise outers) and holes
|
||||
(counter-clockwise rings, assigned to the outer that contains them), so an
|
||||
independent city inside a county (Fairfax city in Fairfax County) is a hole.
|
||||
- Douglas-Peucker per ring at 0.003 × √(county area in km²) km, clamped to
|
||||
0.0005°–0.01° (Travis County ≈ 0.0014°); coordinates rounded to 4 decimals.
|
||||
- Parts under 1 km² or under 0.1% of the county's largest part are dropped;
|
||||
holes under 1 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- Result: 1,541,464 bytes (533,326 with gzip -9); 3,524 parts, 18 holes,
|
||||
139,904 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"US Census Bureau cartographic boundary counties","source":"cb_2025_us_county_5m","url":"https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip","sha256":"faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74","license":"Public domain (U.S. Government work, 17 U.S.C. § 105)","schema":"features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":4,"curation":{"toleranceFactor":0.003,"minToleranceDeg":0.0005,"maxToleranceDeg":0.01,"decimals":4,"minPartKm2":1,"minPartShare":0.001,"minHoleKm2":1},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Autauga","full":"Autauga County","lsad":"County","state":"Alabama","st":"AL","geoid":"01001","polygons":[[[-869212,326575,103,-284,72,-457,50,-60,-44,-368,241,-533,189,-364,-66,-131,114,7,302,-128,-203,-103,16,-150,284,-296,80,-164,-135,-212,48,-232,178,-14,251,321,-90,296,8,222,314,-34,228,154,93,-17,-11,-307,77,-113,278,-74,279,227,22,211,354,85,45,-71,-13,-243,195,-130,134,137,340,-58,122,-288,156,-1,233,41,43,191,158,9,142,137,62,277,116,-60,335,101,-19,2975,-3011,-17,8,-440,-1032,-16,-1010,41]]]},{"name":"Baldwin","full":"Baldwin County","lsad":"County","state":"Alabama","st":"AL","geoid":"01003","polygons":[[[-880146,302224,523,71,1238,-23,379,22,651,113,786,90,988,247,398,60,-1,35,90,83,425,101,28,95,-216,-29,-72,29,-33,62,56,52,276,108,107,101,9,231,127,136,-20,107,111,150,69,36,192,1,78,74,245,129,46,61,-42,103,-291,41,-80,54,-68,9,-104,83,-45,50,-10,60,-50,39,50,106,-6,47,-85,108,-80,38,27,45,-16,122,112,220,75,50,19,60,73,17,20,45,-17,37,57,32,40,105,21,162,73,57,43,97,14,156,-26,195,-97,162,-11,85,-125,49,-109,86,-94,19,-116,93,-56,-23,-93,33,-81,58,-68,99,-211,51,-94,119,-209,100,-39,179,-97,106,11,66,-269,219,-99,119,-186,82,-53,107,-101,34,-14,56,-100,76,11,92,-87,88,54,142,87,74,-14,100,62,39,82,231,157,270,29,129,-96,330,-166,0,-5,2471,-163,-16,-442,191,-379,401,-509,-48,-64,-36,-240,90,-79,163,-55,-124,232,-237,3,-71,-160,-60,-167,88,-101,-33,-54,-162,-156,9,122,-127,-230,-119,-35,-93,151,-177,-114,-38,-99,112,-94,-178,-104,63,-166,-51,-74,-118,-330,55,53,-298,-316,-4,66,-144,262,-11,-65,-255,-296,-322,124,-212,151,-4,75,-197,-162,-188,69,-73,-143,-139,82,-184,-86,-220,216,-60,193,-264,-280,-105,-65,-209,-247,-204,-29,-117,54,-100,265,-125,96,-124,-18,-134,-404,-122,-35,-172,-261,-131,-55,-89,-41,-188,253,-551,-74,-133,272,-99,252,-162,193,-15,174,-213,68,-203,-25,-299,108,-199,25,-151,-94,-251,-223,-384,15,-63,119,-105,137,-612,413,-259,199,-61,79,-81,73,-155,206,-211,365,-210,173,-205,2,-139,-113,-149,-211,-82,-300,1,-424,-140,-192,-17,-212,29,-355,200,-130,-49,-178,-210,-572,-52,-53,-77]]]},{"name":"Barbour","full":"Barbour County","lsad":"County","state":"Alabama","st":"AL","geoid":"01005","polygons":[[[-857483,316180,1629,-4,1184,30,506,-11,4,872,1829,-36,236,74,35,313,406,182,111,139,256,-100,-78,251,53,263,-106,240,120,442,99,98,378,514,283,688,45,489,-1358,-7,-69,31,-5,112,-187,183,-179,84,-51,169,-256,189,21,98,-1525,-16,-172,-73,-24,-934,13,-310,-340,-6,-86,79,5,-81,-420,-1,2,-144,-749,-23,-55,-35,-120,-330,-155,-154,-285,-339,-90,-231,1,-82,-204,-243,98,-215,-58,-264,116,-235,-87,-237,-301,-199,-77,-293,33,-29,-122,-108,18,-375,-253,-409]]]},{"name":"Bibb","full":"Bibb County","lsad":"County","state":"Alabama","st":"AL","geoid":"01007","polygons":[[[-874212,328745,1017,6,3,-436,859,7,208,30,1933,18,1431,-7,-8,2136,-216,-1,-178,98,-177,45,0,147,-177,-1,8,199,-184,-2,-3,597,-560,77,0,133,43,123,-55,551,-498,3,-275,-253,-174,2,-4,141,-336,-60,-437,-332,-1,-659,-826,24,-297,-392,-69,-877,-1034,-28]]]},{"name":"Blount","full":"Blount County","lsad":"County","state":"Alabama","st":"AL","geoid":"01009","polygons":[[[-869634,338582,91,-133,6,-296,172,0,526,279,1248,-26,1138,-676,-25,-71,700,-6,3,367,557,-1,93,89,637,132,186,-45,898,712,-75,205,256,210,-33,79,-446,-3,309,317,66,146,3,604,97,257,192,269,-102,127,-953,932,-361,445,10,96,-418,11,-138,-112,-152,54,-85,-271,-160,-13,-126,-200,67,-199,-287,-207,-207,-300,-64,-164,-230,32,-293,-150,-34,-156,-298,-120,-18,130,-158,-111,200,-260,-27,-35,-91,96,-135,-139,78,-121,-189,-36,-23,-123,-263,-221,-109,-17,-39,-217,-277,-106,-3,-112,-281,51,-12,-160,-149,91,-319,-179,-181,3,2,-156,-393,-93,16,-359,-392,-46]]]},{"name":"Bullock","full":"Bullock County","lsad":"County","state":"Alabama","st":"AL","geoid":"01011","polygons":[[[-859969,320510,466,36,-43,72,83,-72,345,2,181,-74,10,-583,84,-218,943,0,-10,-869,1333,-1,-1,82,90,231,285,339,155,154,120,330,55,35,749,23,-2,144,420,1,-5,81,86,-79,340,6,-13,310,24,934,172,73,-172,-1,-61,880,-4215,-26,-211,143,-18,452,-185,137,-23,-302,-879,-27,1,-217,-127,-1]]]},{"name":"Butler","full":"Butler County","lsad":"County","state":"Alabama","st":"AL","geoid":"01013","polygonLine truncated
|
||||
@@ -1631,6 +1631,20 @@ test('installation summaries disclose viewport-scoped coverage', () => {
|
||||
assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s');
|
||||
});
|
||||
|
||||
test('installation summaries name a subject window instead of the viewport', () => {
|
||||
const stats = { coverage: { kind: 'subject', radiusM: 100_000 } };
|
||||
const empty = summarizeInstallationViewport([], { available: true, stale: false, stats });
|
||||
assert.equal(empty.count, 0);
|
||||
assert.match(empty.reason, /none mapped within 100 km/);
|
||||
assert.match(empty.reason, /not a complete 250 km survey/);
|
||||
const found = summarizeInstallationViewport(
|
||||
[{ id: 'ofm:installation:1', label: 'Military area', distanceM: 1200 }],
|
||||
{ available: true, stale: false, stats },
|
||||
);
|
||||
assert.equal(found.count, 1);
|
||||
assert.equal(found.reason, 'mapped matches within 100 km of the subject');
|
||||
});
|
||||
|
||||
test('compact Context snapshots retain installation coverage', () => {
|
||||
const snapshot = buildAwarenessContextSnapshot({
|
||||
subject: { layerId: 'flights', id: 'abc' },
|
||||
@@ -3060,3 +3074,27 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
for (const layer of [aisLiveVesselsLayer, flightsLayer, militaryFlightsLayer, militaryInstallationsLayer])
|
||||
replaceMethod(layer, 'getNearby', () => [], restores);
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
runtime.dispatch('gev:entity-selected', {
|
||||
layerId: 'military-installations', id: 'osm:military:w1', label: 'Parent base',
|
||||
latitude: 31.13, longitude: -97.78,
|
||||
properties: { memberNames: ['Area one', 'Area two', 'Area three', '<Area four>'] },
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of ['Area one', 'Area two', 'Area three', '<Area four>']) assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const sourceSlot = createSourceSlot(
|
||||
createInstallationSource(),
|
||||
['getMappedSites', 'searchNearby'],
|
||||
'Installation source',
|
||||
{ destroy: () => {} },
|
||||
);
|
||||
export const configureInstallationSource = sourceSlot.configure;
|
||||
const layer = createApplicationInstallations({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
installRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext } from './contextStore.js';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
|
||||
test('clicking a selected installation again or empty map clears it through refresh', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
@@ -42,6 +42,23 @@ test('clicking a selected installation again or empty map clears it through refr
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('background installation paints neither reopen Contacts nor resurrect a cleared selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type:'node',id:42,lat:30.2,lon:-97.7,tags:{military:'base',name:'Site'} }] });
|
||||
try {
|
||||
const selected=()=>run.contextLabels().length;
|
||||
run.click('osm:node:42');
|
||||
assert.equal(selected(),1);
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id,'osm:node:42');
|
||||
assert.equal(selected(),1,'data and floor refreshes must not publish a fresh selection');
|
||||
clearSelectedEntityContextForLayer('military-installations');
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(),null);
|
||||
assert.equal(selected(),1,'an external clear must survive the next paint');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(),9);
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
@@ -798,7 +815,7 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
||||
|
||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
assert.match(installationsSource,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*if \(!isUnavailableCapability\(error\)\)\s*parts\.viewport\.scheduleUnavailableRetry\(error\?\.retryAfterMs\);/,
|
||||
'a failed load schedules the retry immediately after reporting unavailable');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||
@@ -815,3 +832,60 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||
'the fired retry re-checks enablement and never races an in-flight load');
|
||||
});
|
||||
|
||||
|
||||
test('a merged installation stays visible when only its second fragment intersects', () => {
|
||||
const far = [[-96.6,30.4],[-96.4,30.4],[-96.4,30.6],[-96.6,30.6]];
|
||||
const near = [[-97.5,30.4],[-97.4,30.4],[-97.4,30.6],[-97.5,30.6]];
|
||||
assert.equal(installationWithinViewport({latitude:30.5,longitude:-96.5,footprint:far,footprints:[[far],[near]]},VIEWPORT), true);
|
||||
});
|
||||
|
||||
test('a Contacts anchor replaces the viewport and ignores small subject moves', async () => {
|
||||
// Field report: a follow/Cockpit camera never fires moveEnd and often looks at
|
||||
// the horizon, so the Context row read "?" for the whole track.
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 7,
|
||||
lat: 30.3125, lon: -97.765, tags: { military: 'base', name: 'Camp Mabry' } }] });
|
||||
try {
|
||||
const box = (href) => {
|
||||
const url = new URL(href, 'https://example.test');
|
||||
return Object.fromEntries(['south', 'west', 'north', 'east']
|
||||
.map((key) => [key, Number(url.searchParams.get(key))]));
|
||||
};
|
||||
const centre = (href) => (box(href).south + box(href).north) / 2;
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.3125, longitude: -97.765 }), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const anchored = box(run.requests.at(-1));
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.3125) < 0.01);
|
||||
assert.ok(anchored.north - anchored.south > 1.5, 'window reaches ~100 km each way');
|
||||
assert.equal(run.stats().coverageLabel, 'WITHIN 100 KM');
|
||||
assert.equal(run.stats().coverage.kind, 'subject');
|
||||
const count = run.requests.length;
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.35, longitude: -97.765 }), false,
|
||||
'a 4 km move keeps the window');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.6, longitude: -97.765 }), true,
|
||||
'a 32 km move shifts the window');
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(run.requests.length, count + 1);
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.6) < 0.01);
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor(null), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.deepEqual(box(run.requests.at(-1)), { south: 30, west: -98, north: 31, east: -97 },
|
||||
'clearing the subject returns to the viewport');
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('unchanged installations keep their entities across reloads', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
try {
|
||||
const before = run.entities().slice();
|
||||
assert.ok(before.length > 0);
|
||||
await militaryInstallationsLayer.update();
|
||||
const after = run.entities();
|
||||
assert.equal(after.length, before.length);
|
||||
assert.ok(after.every((entity, index) => entity === before[index]),
|
||||
'a reload with the same geometry must not recreate entities');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
export { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
import { requestWithDeadline } from '../sources/requestDeadline.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
loadPack = (signal) =>
|
||||
loadBundledJson(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
() =>
|
||||
import('./local_data/osm_military_names/names.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
|
||||
...retryOptions
|
||||
} = {}) {
|
||||
return createRetryableLoader(async () => {
|
||||
const pack = await requestWithDeadline(loadPack, { timeoutMs });
|
||||
const records = pack.records.map(
|
||||
([
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
kind,
|
||||
areaM2,
|
||||
]) => ({
|
||||
id: `osm:military:${osmKey}`,
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
bbox: [west, south, east, north],
|
||||
class: pack.classes[kind],
|
||||
areaM2,
|
||||
kind: 'installation',
|
||||
namedArea: true,
|
||||
pointOnly: true,
|
||||
validation: 'unreviewed',
|
||||
sources: [
|
||||
{ name: 'OpenStreetMap', id: osmKey },
|
||||
{ name: 'Overture Maps Foundation', id: osmKey },
|
||||
],
|
||||
}),
|
||||
);
|
||||
records.sort(
|
||||
(a, b) => b.areaM2 - a.areaM2 || a.osmKey.localeCompare(b.osmKey),
|
||||
);
|
||||
return { records, byId: new Map(records.map((r) => [r.osmKey, r])) };
|
||||
}, retryOptions);
|
||||
}
|
||||
|
||||
/** Load and index the separate data asset only when installation context needs it. */
|
||||
export const loadMilitaryNames = createMilitaryNamesLoader();
|
||||
|
||||
/** Join exact source identity before parcel merging; reject geographically inconsistent ids. */
|
||||
export function nameMilitaryFragment(fragment, names) {
|
||||
const osmKey = militaryOsmKey(fragment.featureKey);
|
||||
const named = names?.byId.get(osmKey);
|
||||
if (!named || !fragment.footprint?.length) return fragment;
|
||||
const [west, south, east, north] = named.bbox;
|
||||
const epsilon = (fragment.tileEpsilon || 0) * 2 + 0.0001;
|
||||
const xs = fragment.footprint.map((p) => p[0]);
|
||||
const ys = fragment.footprint.map((p) => p[1]);
|
||||
if (
|
||||
Math.max(...xs) < west - epsilon ||
|
||||
Math.min(...xs) > east + epsilon ||
|
||||
Math.max(...ys) < south - epsilon ||
|
||||
Math.min(...ys) > north + epsilon
|
||||
)
|
||||
return fragment;
|
||||
return {
|
||||
...fragment,
|
||||
name: named.name,
|
||||
class: named.class,
|
||||
osmKey,
|
||||
namedArea: true,
|
||||
areaM2: named.areaM2,
|
||||
nameRecord: named,
|
||||
};
|
||||
}
|
||||
|
||||
/** Area-ranked spatial thinning of visible label points, including dateline views. */
|
||||
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
|
||||
const height = box.north - box.south;
|
||||
const cells = new Set(),
|
||||
records = [];
|
||||
let count = 0;
|
||||
for (const record of names?.records || []) {
|
||||
const x = (record.longitude - box.west + 360) % 360;
|
||||
if (x > width || record.latitude < box.south || record.latitude > box.north)
|
||||
continue;
|
||||
count++;
|
||||
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
|
||||
if (cells.has(cell) || records.length >= cap) continue;
|
||||
cells.add(cell);
|
||||
records.push(record);
|
||||
}
|
||||
return { records, count };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import {
|
||||
loadMilitaryNames,
|
||||
militaryOsmKey,
|
||||
nameMilitaryFragment,
|
||||
militaryNamesInView,
|
||||
MILITARY_POINT_CAP,
|
||||
} from './militaryNames.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../sources/openFreeMap.js';
|
||||
import { createControls } from '../layers/installations/controls.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
} from '../sources/militaryTileGeometry.js';
|
||||
|
||||
const sites = [
|
||||
['w27894251', 'Camp Mabry'],
|
||||
['r317712', 'Marine Corps Base Camp Pendleton'],
|
||||
['r253814', 'Marine Corps Air Station Miramar'],
|
||||
['w60546523', 'Ramstein Air Base'],
|
||||
['w102826331', 'RAF Lakenheath'],
|
||||
['r13529728', 'Fort Hood'],
|
||||
];
|
||||
|
||||
test('wide-view counts report visible points rather than retained off-screen candidates', () => {
|
||||
const state = {
|
||||
wide: true,
|
||||
records: Array.from({ length: 8 }, () => ({ kind: 'installation' })),
|
||||
lastUpdate: 1,
|
||||
status: 'ready',
|
||||
};
|
||||
const { methods } = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: {},
|
||||
parts: {
|
||||
namedMarkers: {
|
||||
stats: () => ({
|
||||
namedMarkers: 8,
|
||||
pointsOnScreen: 3,
|
||||
labelsOnScreen: 2,
|
||||
labelCap: 24,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 3);
|
||||
assert.equal(stats.namedMarkers, 8);
|
||||
assert.equal(stats.statusMessage, '3 mapped sites in view');
|
||||
});
|
||||
|
||||
test('the separately licensed pack has unique typed identities, areas, names and a pinned lean output', async () => {
|
||||
const raw = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
);
|
||||
const hash = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.sha256', import.meta.url),
|
||||
'utf8',
|
||||
).split(' ')[0];
|
||||
assert.equal(createHash('sha256').update(raw).digest('hex'), hash);
|
||||
assert.ok(gzipSync(raw, { level: 9 }).length <= 2_000_000);
|
||||
const names = await loadMilitaryNames();
|
||||
assert.equal(names.records.length, 36466);
|
||||
assert.equal(names.byId.size, names.records.length);
|
||||
assert.ok(
|
||||
names.records.every(
|
||||
(r) => r.areaM2 > 0 && Number.isFinite(r.latitude + r.longitude),
|
||||
),
|
||||
);
|
||||
for (const [id, name] of sites) assert.equal(names.byId.get(id).name, name);
|
||||
});
|
||||
|
||||
test('checked-in OpenFreeMap fixtures retain typed OSM feature ids and geographical identity', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const tile = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
|
||||
const mabry = tile.military.find((r) => r.featureKey === '278942512');
|
||||
assert.ok(mabry);
|
||||
assert.equal(militaryOsmKey(mabry.featureKey), 'w27894251');
|
||||
assert.equal(nameMilitaryFragment(mabry, names).name, 'Camp Mabry');
|
||||
assert.equal(militaryOsmKey('3177123'), 'r317712');
|
||||
for (const value of [
|
||||
'tile:12/935/1685:0',
|
||||
'278942510',
|
||||
'3177127',
|
||||
'bad',
|
||||
1e20,
|
||||
])
|
||||
assert.equal(militaryOsmKey(value), null);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '3177123' }, names).name,
|
||||
'Military area',
|
||||
'a valid but distant id fails soft',
|
||||
);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '278942510' }, names).name,
|
||||
'Military area',
|
||||
);
|
||||
});
|
||||
|
||||
test('a merged title uses the largest named member and exposes all other member names', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const fragment = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685)
|
||||
.military[0];
|
||||
const parent = nameMilitaryFragment(fragment, names);
|
||||
const child = {
|
||||
...parent,
|
||||
featureKey: '12',
|
||||
osmKey: 'w1',
|
||||
nameRecord: {
|
||||
...parent.nameRecord,
|
||||
id: 'osm:military:w1',
|
||||
osmKey: 'w1',
|
||||
name: 'Small barracks',
|
||||
areaM2: 1,
|
||||
},
|
||||
};
|
||||
for (const records of [
|
||||
[child, parent],
|
||||
[parent, child],
|
||||
]) {
|
||||
const merged = mergeMilitaryFragments(records)[0];
|
||||
assert.equal(merged.name, 'Camp Mabry');
|
||||
assert.equal(merged.id, 'osm:military:w27894251');
|
||||
assert.deepEqual(
|
||||
[merged.longitude, merged.latitude],
|
||||
[parent.nameRecord.longitude, parent.nameRecord.latitude],
|
||||
);
|
||||
assert.deepEqual(merged.memberNames, ['Small barracks']);
|
||||
}
|
||||
});
|
||||
|
||||
test('wide views rank and thin names within a bounded viewport, including the dateline', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
for (const box of [
|
||||
{ west: -130, south: 20, east: -60, north: 60 },
|
||||
{ west: -15, south: 35, east: 35, north: 65 },
|
||||
{ west: 170, south: -60, east: -150, north: 70 },
|
||||
]) {
|
||||
const view = militaryNamesInView(names, box);
|
||||
assert.ok(view.records.length > 0);
|
||||
assert.ok(view.records.length <= MILITARY_POINT_CAP);
|
||||
assert.ok(view.count >= view.records.length);
|
||||
assert.equal(
|
||||
new Set(view.records.map((r) => r.id)).size,
|
||||
view.records.length,
|
||||
);
|
||||
for (const r of view.records) {
|
||||
assert.ok(r.latitude >= box.south && r.latitude <= box.north);
|
||||
assert.ok(
|
||||
box.east < box.west
|
||||
? r.longitude >= box.west || r.longitude <= box.east
|
||||
: r.longitude >= box.west && r.longitude <= box.east,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a close view keeps the parent name inside its polygon when the usual label is off-screen', () => {
|
||||
const record = {
|
||||
id: 'osm:military:w1',
|
||||
namedArea: true,
|
||||
name: 'Parent base',
|
||||
longitude: 1,
|
||||
latitude: 1,
|
||||
footprints: [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
const visible = militaryNameInView(record, {
|
||||
west: 5,
|
||||
south: 5,
|
||||
east: 6,
|
||||
north: 6,
|
||||
});
|
||||
assert.equal(visible.id, record.id);
|
||||
assert.deepEqual([visible.longitude, visible.latitude], [5.5, 5.5]);
|
||||
assert.equal(
|
||||
militaryNameInView(record, { west: 0, south: 0, east: 2, north: 2 }),
|
||||
record,
|
||||
);
|
||||
const hole = {
|
||||
...record,
|
||||
footprints: [
|
||||
[
|
||||
record.footprints[0][0],
|
||||
[
|
||||
[4, 4],
|
||||
[7, 4],
|
||||
[7, 7],
|
||||
[4, 7],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
militaryNameInView(hole, { west: 5, south: 5, east: 6, north: 6 }),
|
||||
hole,
|
||||
);
|
||||
});
|
||||
@@ -325,3 +325,39 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the smallest containing bundled physical region without a network geocoder. */
|
||||
export async function naturalRegionAtPoint(latitude, longitude) {
|
||||
if (
|
||||
![latitude, longitude].every(Number.isFinite) ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
await loadIndex();
|
||||
let best = null;
|
||||
for (const entry of _entries) {
|
||||
const [west, south, east, north] = entry.bbox;
|
||||
if (
|
||||
longitude < west ||
|
||||
longitude > east ||
|
||||
latitude < south ||
|
||||
latitude > north
|
||||
)
|
||||
continue;
|
||||
if (best && entry.areaKm2 >= best.areaKm2) continue;
|
||||
if (entry.polygons.some((ring) => pointInRing(ring, latitude, longitude)))
|
||||
best = entry;
|
||||
}
|
||||
return best
|
||||
? {
|
||||
label: best.name,
|
||||
locality: null,
|
||||
region: best.name,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
source: 'Natural Earth',
|
||||
kind: best.kind,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
|
||||
import { createInfrastructureLayers } from './infrastructure.js';
|
||||
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||
import * as input from './inputOwnership.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||
|
||||
const noop = () => {};
|
||||
const overlayHost = { setEntries: noop, setVisible: noop, clearSource: noop };
|
||||
function viewer(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
const credits = [];
|
||||
const v = { credits, creditDisplay: { addStaticCredit: (c) => credits.push(c), removeStaticCredit: c => { const i = credits.indexOf(c); if (i >= 0) credits.splice(i, 1); } },
|
||||
entities: new C.EntityCollection(), dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
return v;
|
||||
}
|
||||
function assertCredit(v) {
|
||||
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
|
||||
}
|
||||
for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce the shared OSM credit when data displays`, async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ type: 'Feature', id: 'test', properties: { name: 'Site' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] } }) }));
|
||||
const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, governorRequestRender: noop, showOsmCredit, hideOsmCredit })[index];
|
||||
await layer.enable(v);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assertCredit(v);
|
||||
layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Nepal locator introduces the shared OSM credit', async (t) => {
|
||||
const v = viewer(t);
|
||||
const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop });
|
||||
await layer.init(v); await layer.enable(v, { origin: 'scene' });
|
||||
assertCredit(v); await layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('voice route geometry introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), renderer = createWorldAnnotationRenderer(v);
|
||||
renderer.add({ id: 'route', type: 'route', color: 'primary', anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] });
|
||||
assertCredit(v); renderer.destroy();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
||||
const layer = createDirectionsLayer({ services: {
|
||||
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
ground: { warmGroundFloor: noop },
|
||||
} });
|
||||
layer.init(v); layer.enable(v);
|
||||
layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 });
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assertCredit(v); await layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Warendorf webcam display introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
|
||||
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
|
||||
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
|
||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
|
||||
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('optional Overpass ALPR adapter uses the shared contributor wording', () => {
|
||||
assert.equal(createOverpassAlprSource().attribution.text, '© OpenStreetMap contributors');
|
||||
});
|
||||
@@ -219,11 +219,7 @@ export function getActiveTrackedReadoutId() {
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
]);
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
|
||||
@@ -191,7 +191,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache'
|
||||
}
|
||||
});
|
||||
|
||||
test('selection lifecycle ignores vessels, accepts installations, and clears without stale cards', () => {
|
||||
test('selection lifecycle leaves installations to their own entry and retains ALPR cards', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const fakeWindow = new EventTarget();
|
||||
const changed = makeCesiumEvent();
|
||||
@@ -215,8 +215,8 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'military-installations', entity: installation },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore, 'installation selection cannot create a second overlay');
|
||||
|
||||
// ALPR cameras are static context too: a click publishes the same card.
|
||||
const camera = {
|
||||
|
||||
@@ -76,11 +76,11 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
assert.deepEqual(idle, {
|
||||
mode: 'live',
|
||||
error: null,
|
||||
loadingLabel: 'LIVE · TomTom flow · 87% cov',
|
||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||
'syncing LIVE traffic flow',
|
||||
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,3 +159,11 @@ test('traffic can be destroyed before its first enable and destroyed repeatedly'
|
||||
assert.doesNotThrow(() => traffic.destroy(viewer));
|
||||
assert.equal(traffic.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('zero matched dots never claims live coverage', () => {
|
||||
const feed = trafficFeedPresentation({ liveMode: true, coveragePct: 0 });
|
||||
assert.equal(feed.mode, 'live');
|
||||
assert.match(feed.loadingLabel, /^SIMULATED/);
|
||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||
});
|
||||
@@ -366,7 +366,7 @@ test('the timed parser stays source-equivalent to the production parser', () =>
|
||||
|
||||
assert.doesNotMatch(SOURCE, /function fetchRoadsTimed\s*\(/);
|
||||
assert.doesNotMatch(SOURCE, /function renderRoadsForAltitudeTimed\s*\(/);
|
||||
const fetchBody = canonicalSemanticBody(functionBody('fetchRoads'));
|
||||
const fetchBody = canonicalSemanticBody(functionBody('readRoads'));
|
||||
assert.equal(fetchBody.includes('response.text()'), false);
|
||||
assert.equal(fetchBody.includes('JSON.parse('), false);
|
||||
const okCheck = fetchBody.indexOf('if(!response.ok)');
|
||||
|
||||
Reference in new issue
Block a user