Merge current main into shared sliders

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-21 14:10:40 +02:00
commit bf0e1d7c79
52 files changed
+3750 -258

No files matched your search

+87
View File
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
+10 -10
View File
@@ -30,6 +30,7 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
+106
View File
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+40 -8
View File
@@ -444,6 +444,7 @@ export function createAnnotationResolver({
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -469,6 +470,7 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
const neCentroid = ringCentroid(ne.ring);
@@ -2004,8 +2006,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2020,7 +2022,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2037,16 +2039,24 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
if (ne?.polygons?.length) {
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
if (admin) return { name: admin.name, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2067,8 +2077,26 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2077,12 +2105,16 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
if (admin) return { name: q, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
return null;
}
+14 -2
View File
@@ -881,6 +881,7 @@ export function createAnalystEngine(providers) {
followUp && !spec.scope ? lastResult.distanceOf || null : null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
@@ -890,8 +891,16 @@ export function createAnalystEngine(providers) {
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
const region = await providers.resolveRegionRing(scope.name, signal);
if (!isCurrent()) return cancelled();
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
if (signal?.aborted || !isCurrent()) return cancelled();
throw error;
}
if (signal?.aborted || !isCurrent()) return cancelled();
if (region?.error === 'region-timeout') {
return {
ok: false,
@@ -910,6 +919,7 @@ export function createAnalystEngine(providers) {
}
resolvedScope = region;
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
} else if (scope.kind === 'radius') {
@@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
@@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
};
+303
View File
@@ -1,6 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
// Stub world: a square "Texland" region, flights + ships + fires around it.
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
assert.ok(r.coverage.scope.includes('Texland'));
});
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async () => ({ ...TEXLAND, source }),
});
const result = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.coverage.scopeSource, source);
}
});
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
const lookups = [];
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
lookups.push(name);
return { ...TEXLAND, source: 'natural-earth' };
},
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const airborne = await engine.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: false }],
});
const military = await engine.query({
followUp: true,
filters: [{ field: 'military', op: 'eq', value: true }],
});
assert.equal(first.count, 3);
assert.equal(airborne.count, 2);
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
for (const result of [first, airborne, military]) {
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.scopeLabel, 'over Texland');
assert.equal(result.coverage.scopeSource, 'natural-earth');
}
assert.deepEqual(lookups, ['Texland']);
});
test('analyst: an explicit replacement region replaces retained provenance', async () => {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: {
name,
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
source: 'remote-boundary',
},
});
await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({
followUp: true,
scope: { kind: 'region', name: 'Northern Texland' },
});
const followUp = await engine.query({ followUp: true });
for (const result of [replacement, followUp]) {
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
assert.equal(result.coverage.scope, 'region:Northern Texland');
assert.equal(result.coverage.scopeSource, 'remote-boundary');
}
});
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
for (const scope of [
{ kind: 'anywhere' },
{ kind: 'view' },
{ kind: 'radius', km: 150 },
{ kind: 'region', name: 'Uncredited Texland' },
]) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({
...TEXLAND,
name,
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({ followUp: true, scope });
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.scopeSource, 'natural-earth');
for (const result of [replacement, followUp]) {
assert.equal(result.ok, true, scope.kind);
assert.equal(result.count, 3, scope.kind);
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
}
}
});
for (const [label, committedScope, scopeSource] of [
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
['window without a boundary source', 'window:presented', null],
]) {
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
const committedSnapshot = {
id: 'flights', enabled: true, feedState: 'stale',
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
};
let reads = 0;
let lookups = 0;
const engine = createAnalystEngine({
getRecords: () => { reads++; return FLIGHTS; },
getLayerSnapshot: () => ({
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
}),
resolveRegionRing: async () => {
lookups++;
return { ...TEXLAND, source: 'natural-earth' };
},
});
await engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{
beforeCommit: async (result) => {
assert.equal(result.count, 3);
assert.equal(result.coverage.scopeSource, 'natural-earth');
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
};
},
},
);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.count, 1);
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
assert.equal(followUp.scopeLabel, 'the presented cohort');
assert.equal(followUp.display.scope, 'the presented cohort window');
assert.equal(followUp.coverage.scope, committedScope);
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
});
}
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
assert.equal(result.cancelled, true);
});
for (const [label, reason, trackAuthority] of [
['default abort reason', undefined, false],
['custom error reason', new Error('operator stopped'), false],
['primitive reason and revoked authority', 'operator stopped', true],
]) {
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
const started = Promise.withResolvers();
const geocode = Promise.withResolvers();
const controller = new AbortController();
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
method,
async () => { throw new Error('offline region query reached feature source'); },
])),
});
const placeSearch = {
geocode(name, { signal }) {
lookups.push(name);
assert.equal(signal, controller.signal);
started.resolve();
return geocode.promise;
},
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
const first = await engine.query({
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
});
assert.equal(first.ok, true);
assert.equal(first.coverage.scopeSource, 'bundled-admin');
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
let commits = 0;
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
{
signal: controller.signal,
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
beforeCommit: async () => { commits++; },
},
);
await started.promise;
controller.abort(reason);
// Let an actual late geocode result cross the resolver's post-await check.
geocode.resolve({ place: {
lat: 5, lng: -73, label: 'Cordillera Oriental',
name: 'Cordillera Oriental', types: ['natural_feature'],
} });
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.equal(commits, 0);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
assert.equal(followUp.scopeLabel, first.scopeLabel);
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
});
}
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
const lookup = Promise.withResolvers();
let current = true;
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: lookup.promise,
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ isCurrent: () => current },
);
current = false;
lookup.reject(new Error('late lookup failed'));
assert.equal((await pending).code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
assert.equal(followUp.coverage.scope, first.coverage.scope);
});
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
const controller = new AbortController();
const lookup = Promise.withResolvers();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: () => lookup.promise,
});
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{ signal: controller.signal },
);
controller.abort();
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
assert.equal((await pending).code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
const controller = new AbortController();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
throw error;
},
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
await assert.rejects(
engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ signal: controller.signal },
),
(thrown) => thrown === error,
);
assert.equal(controller.signal.aborted, false);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
});
}
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
+464
View File
@@ -0,0 +1,464 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
hkCameraName,
hkCameraToSource,
hkImageUrlFromKey,
loadHkSourcesFromOpenData,
normalizeHkImageUrl,
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_IMAGE_ORIGIN,
HK_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
prioritizeSources,
} from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<image-list>'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Transport Department `<image>` block as a parsed row. */
const row = (overrides = {}) => ({
key: 'BC101F',
region: 'New Territories',
district: 'North',
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
latitude: '22.508',
longitude: '114.078',
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
...overrides,
});
/** Fixture XML matching the published English locations schema. */
const fixtureXml = (...records) => {
const images = records
.map(
(record) => `
<image>
<key>${record.key}</key>
<region>${record.region}</region>
<district>${record.district}</district>
<description>${record.description}</description>
<latitude>${record.latitude}</latitude>
<longitude>${record.longitude}</longitude>
<url>${record.url}</url>
</image>`,
)
.join('');
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
};
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
const source = hkCameraToSource(row());
assert.equal(source.id, 'hk-bc101f');
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
assert.equal(source.cityId, 'hk');
assert.equal(source.provider, 'Transport Department');
assert.equal(source.lat, 22.508);
assert.equal(source.lon, 114.078);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'hk-td-open-data');
assert.equal(
source.license,
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
);
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
});
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
assert.equal(
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
null,
);
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
assert.equal(hkCameraToSource(row({ key: '' })), null);
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
assert.equal(
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
null,
);
assert.equal(
hkCameraToSource(
row({
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
}),
),
null,
);
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
assert.equal(hkCameraToSource(null), null);
});
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
assert.equal(
hkImageUrlFromKey('bc101f'),
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
);
assert.equal(hkImageUrlFromKey(''), null);
assert.equal(hkImageUrlFromKey('bad/key'), null);
assert.equal(
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(normalizeHkImageUrl(''), null);
assert.equal(normalizeHkImageUrl('not a url'), null);
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
hkCameraName(
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
'hk-h429f',
),
'Aberdeen Praya Road near Fish Market [H429F]',
);
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
});
test('the XML parser reads each image block', () => {
const rows = parseHkCameraLocationsXml(
fixtureXml(
row(),
row({
key: 'H429F',
region: 'Hong Kong Island',
district: 'Southern',
description: 'Aberdeen Praya Road near Fish Market [H429F]',
latitude: '22.24845',
longitude: '114.1505',
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
}),
),
);
assert.equal(rows.length, 2);
assert.equal(rows[0].key, 'BC101F');
assert.equal(rows[1].key, 'H429F');
assert.equal(rows[1].latitude, '22.24845');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return new Response(
fixtureXml(
row(),
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
row({ url: 'https://evil.example/x.jpg' }),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
const cameras = await loadHkSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest Central/TST first: Wan Chai before the northern New Territories.
['hk-h210f', 'hk-bc101f'],
);
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('the unselected label is the description, trimmed to the code width', () => {
assert.equal(
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
'SAN TIN HIGHWAY [BC101F]',
);
const long = hkCameraToSource(
row({
description:
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.xml' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = fixtureXml(row());
return new Response(body, {
headers: {
'Content-Type': 'application/xml',
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[
lane('austin', 250),
lane('calgary', 215),
lane('hk', DEFAULT_HK_MAX_SOURCES),
],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['calgary', 10],
['hk', 10],
],
);
assert.deepEqual(
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
);
});
/**
* Serve the HK catalog to the HK endpoint and an empty payload to every other
* pack, so one catalog refresh exercises the registration without reaching the
* network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.includes('Traffic_Camera_Locations_En.xml')) {
return new Response(
fixtureXml(
row(),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_HK_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_HK_CAMERAS_URL),
'the catalog refresh invokes the HK loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
['hk-h210f', 'hk-bc101f'],
'HK cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HK_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_HK_CAMERAS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
assert.ok(
DEFAULT_HK_MAX_SOURCES >= 1013,
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
);
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
});
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
const cameras = [
hkCameraToSource(
row({
key: 'H001F',
region: 'Hong Kong Island',
district: 'Central & Western',
description: 'Central harbour [H001F]',
latitude: '22.2819',
longitude: '114.158',
}),
),
hkCameraToSource(
row({
key: 'ST101F',
region: 'New Territories',
district: 'Sha Tin',
description: 'Sha Tin Road [ST101F]',
latitude: '22.3827',
longitude: '114.19',
}),
),
hkCameraToSource(
row({
key: 'K999F',
region: 'Kowloon',
district: 'Yau Tsim Mong',
description: 'Far Kowloon filler [K999F]',
latitude: '22.32',
longitude: '114.18',
}),
),
].filter(Boolean);
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
'hk-h001f',
'hk-st101f',
]);
});
+342
View File
@@ -0,0 +1,342 @@
import { afterEach, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTaiwanSources,
parseTaiwanCctvXml,
resetTaiwanSourcesCache,
taiwanCameraToSource,
taiwanFrame,
TAIWAN_FREEWAY_CCTV_URL,
TAIWAN_MAX_CATALOG_BYTES,
TAIWAN_RETRY_AFTER_FAILURE_MS,
} from '../../server/providers/cctv/taiwan.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
// Settings that would steer the loader or the catalog away from the mocks.
const ISOLATED_ENV = [
'CCTV_SOURCES_FILE',
'CCTV_SOURCES_JSON',
'CCTV_PREFER_AUSTIN',
'CCTV_FORCE_AUSTIN',
'CCTV_TAIWAN_ENABLED',
'CCTV_TAIWAN_URL',
'CCTV_TAIWAN_MAX_SOURCES',
];
let savedEnv = {};
beforeEach(() => {
resetTaiwanSourcesCache();
savedEnv = Object.fromEntries(
ISOLATED_ENV.map((key) => [key, process.env[key]]),
);
for (const key of ISOLATED_ENV) delete process.env[key];
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function cctvXml(...blocks) {
return `<?xml version='1.0' encoding='utf-8'?>
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
<AuthorityCode>NFB</AuthorityCode>
<CCTVs>${blocks.join('')}</CCTVs>
</CCTVList>`;
}
function block({
id = 'CCTV-N1-S-0.000-M',
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
lat = '25.1229931',
lon = '121.735695',
} = {}) {
return `<CCTV>
<CCTVID>${id}</CCTVID>
<VideoStreamURL>${stream}</VideoStreamURL>
<PositionLon>${lon}</PositionLon>
<PositionLat>${lat}</PositionLat>
<RoadName>國道1號</RoadName>
<RoadDirection>S</RoadDirection>
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
<LocationMile>0K+000</LocationMile>
</CCTV>`;
}
test('parses the MOTC 2.0 table and registers the same camera still', () => {
const [row] = parseTaiwanCctvXml(cctvXml(block()));
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(camera.lat, 25.1229931);
assert.equal(camera.lon, 121.735695);
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
assert.equal(camera.cityId, 'taiwan');
assert.equal(camera.feedType, 'image');
assert.equal(camera.headingConfidence, 'low');
assert.equal(
camera.url,
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
);
assert.equal(camera.snapshotUrl, camera.url);
assert.match(camera.license, /Open Government Data License, Taiwan/);
});
test('each camera server maps its stream path to its own still path only', () => {
assert.equal(
taiwanFrame(
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
).frameUrl,
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
);
assert.equal(
taiwanFrame(
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
).frameUrl,
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.equal(
taiwanFrame(
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
).frameUrl,
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
);
});
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
for (const stream of [
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
'http://127.0.0.1/private',
'',
]) {
assert.equal(taiwanFrame(stream), null, stream);
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
assert.equal(taiwanCameraToSource(row), null, stream);
}
});
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
for (const [lat, lon] of [
['121.735695', '25.1229931'],
['35.68', '139.76'],
['', '121.7'],
['NaN', '121.7'],
]) {
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
}
assert.equal(taiwanCameraToSource(null), null);
});
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
const [row] = parseTaiwanCctvXml(
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&amp;(北)' })),
);
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
});
function mockCatalog(t, xml) {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
requested.push(String(url));
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
assert.equal(options.redirect, 'manual');
return new Response(xml, {
headers: { 'content-type': 'application/xml' },
});
}
return Response.json([]);
});
return requested;
}
test('loader deduplicates cameras that share a camera number', async (t) => {
mockCatalog(
t,
cctvXml(
block(),
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
block({
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
lat: '24.1',
lon: '120.6',
}),
),
);
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
'tw-freeway-cctvc-495',
'tw-freeway-cctvn-10000',
]);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(cctvXml(block()), {
status,
headers: status === 302 ? { location: 'https://evil.test/' } : {},
}),
);
resetTaiwanSourcesCache();
assert.deepEqual(await loadTaiwanSources(), []);
}
});
async function runCatalogWithTaiwanFlag(t, flag) {
const requested = mockCatalog(t, cctvXml(block()));
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
}
test('the Taiwan pack is in the catalog by default', async (t) => {
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
assert.equal(
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
'taiwan',
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block()) + padding),
);
assert.deepEqual(await loadTaiwanSources(), []);
resetTaiwanSourcesCache();
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('fetch failed');
});
assert.deepEqual(await loadTaiwanSources(), []);
});
test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(console, 'warn', () => {});
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
throw new TypeError('fetch failed');
});
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block())),
);
const first = await loadTaiwanSources();
assert.deepEqual(
first.map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
fetchMock.mock.mockImplementation(
async () => new Response('', { status: 503 }),
);
assert.deepEqual(
(await loadTaiwanSources()).map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
});
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
t.mock.method(console, 'log', () => {});
const rows = Array.from({ length: 3 }, (_, i) =>
block({
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
}),
);
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(...rows)),
);
for (const [value, expected] of [
['0', 1],
['2', 2],
['5000', 3],
['abc', 3],
]) {
resetTaiwanSourcesCache();
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
const sources = await loadTaiwanSources();
assert.equal(sources.length, expected, value);
assert.equal(sources.available, 3, value);
}
});
test('names fall back to the published ID when the row has no road details', () => {
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
.replace('<LocationMile>0K+000</LocationMile>', '')
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
assert.equal(camera.name, 'CCTV-N3-N-100-M');
});
test('numeric character references in the table are decoded', () => {
const xml = cctvXml(block()).replace(
'<RoadName>國道1號</RoadName>',
'<RoadName>&#22283;&#x9053;1號</RoadName>',
);
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
});
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
const camera = taiwanCameraToSource(
parseTaiwanCctvXml(
cctvXml(
block({
stream:
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
}),
),
)[0],
);
assert.equal(
camera.id,
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
});
+17 -1
View File
@@ -239,7 +239,8 @@ export const DATA_CREDITS = [
html:
'CCTV cameras &amp; frames (Ontario): ' +
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
'· provided as is, without warranty; actual road conditions may differ',
},
{
key: 'fintraffic-cctv',
@@ -257,6 +258,14 @@ export const DATA_CREDITS = [
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
},
{
key: 'taiwan-cctv',
html:
'Freeway camera stills (Taiwan): ' +
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
},
{
key: 'calgary-cctv',
html:
@@ -288,6 +297,13 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'hk-cctv',
html:
'Traffic snapshot cameras (Hong Kong): ' +
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
},
{
key: 'vegvesen-cctv',
html:
+28 -21
View File
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
return index;
});
function toResult(entry) {
function toResult(entry, candidates = 1) {
return {
name: entry.name,
featurecla: entry.featurecla,
@@ -197,6 +197,7 @@ function toResult(entry) {
polygons: entry.polygons,
bboxDiagonalKm: entry.bboxDiagonalKm,
areaKm2: entry.areaKm2,
candidates,
};
}
@@ -209,7 +210,9 @@ function toResult(entry) {
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
* areaKm2:number, candidates:number}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. Uniqueness in
* this pack alone does not establish the user's intended place.
*/
export async function findNaturalRegion(query) {
const norm = normalizeName(query);
@@ -223,7 +226,7 @@ export async function findNaturalRegion(query) {
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
if (list && list.length) return toResult(list[0]);
if (list && list.length) return toResult(list[0], list.length);
}
return null;
}
@@ -270,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
/**
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
* matched name (duplicate names included — the US and Spanish "Sierra
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
* geocoded anchor. Containment is simultaneously the disambiguator and the
* matched name (duplicate names included), trying exact names, aliases and
* broader suffix variants in that order. Returns a ring only when exactly one
* entry under the matched name CONTAINS the anchor. Containment is the
* disambiguator and the
* wrong-place guard: when no ring contains the anchor this returns null and
* the resolver's normal ladder continues unchanged.
*
@@ -291,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
ALIASES[norm],
...suffixVariants(ALIASES[norm] || norm),
];
const seen = new Set();
for (const key of candidates) {
if (!key || seen.has(key)) continue;
seen.add(key);
for (const key of new Set(candidates.filter(Boolean))) {
const containing = [];
for (const entry of index.get(key) || []) {
for (const ring of entry.polygons) {
if (pointInRing(ring, lat, lon)) {
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
}
const ring = entry.polygons.find((candidate) =>
pointInRing(candidate, lat, lon),
);
if (ring) containing.push({ entry, ring });
}
// Specific names win over aliases and broader rewrites. Ambiguity belongs
// to this name, not the union of every lower-priority candidate name.
if (containing.length > 1) return null;
if (containing.length === 0) continue;
const [{ entry, ring }] = containing;
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
return null;
}
+41 -2
View File
@@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
});
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
const alps = await findNaturalRegion('the Alps');
assert.equal(alps?.name, 'Alps');
assert.equal(alps?.candidates, 1);
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
@@ -41,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
});
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
});
test('major deserts/ranges resolve with sane areas', async () => {
const sahara = await findNaturalRegion('Sahara');
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
@@ -122,24 +130,55 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
});
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
test('outline lookup: Alps ring via containment anchor', async () => {
test('outline lookup: containing exact names win over broader suffix variants', async () => {
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
assert.ok(r, 'Alps must resolve with an inside anchor');
assert.equal(r.name, 'Alps');
assert.ok(r.ring.length >= 8, 'range-scale ring');
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
assert.equal(range?.name, 'Alaska Range');
assert.ok(pointInRing(range.ring, 63.14, -148.71));
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
});
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
assert.ok(us, 'US anchor must match a Sierra Nevada');
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
assert.ok(pointInRing(colombia.ring, 5, -73));
});
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
});
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
});
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
for (const [name, lat, lon] of [
['Gold Coast', -28, 153.4],
['Santa Cruz', 37, -122],
['Isabela', 17, 122],
['India', 28, 95], // Northeast India lies outside the physical peninsula.
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
]) {
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
}
});
test('outline lookup: aliases retain containment checks', async () => {
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
assert.equal(alps?.name, 'Alps');
assert.ok(pointInRing(alps.ring, 46.5, 10));
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
});
test('outline lookup: non-region names never match', async () => {
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
+118
View File
@@ -152,6 +152,124 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () =>
assert.equal(plan.headingDeg, 90);
});
// Compatibility guard for inherited geocode authority, not a newly fixed bug.
test('search retains main geocode authority for Gold Coast, Australia', async () => {
const viewer = stubViewer();
const result = await runSearch(viewer, {}, {
query: 'Gold Coast',
result: {
formatted_address: 'Gold Coast, Queensland, Australia',
types: ['locality', 'political'],
geometry: {
location: { lat: -28, lng: 153.4 },
viewport: boxOf(-28.2, 153.2, -27.8, 153.6),
},
},
});
assert.equal(result.label, 'Gold Coast, Queensland, Australia');
assert.equal(result.navigationMode, 'city-overview');
assert.equal(viewer.flights.length, 1);
const flown = flownRectangleDegrees(viewer);
assert.ok(Math.abs((flown.south + flown.north) / 2 + 28) < 0.000001);
assert.ok(Math.abs((flown.west + flown.east) / 2 - 153.4) < 0.000001);
});
function alpsViewer() {
const viewer = stubViewer();
Object.assign(viewer.camera.positionCartographic, {
latitude: Cesium.Math.toRadians(46.5),
longitude: Cesium.Math.toRadians(10),
height: 248_000,
});
return viewer;
}
test('a successful nearby recovery resolves after a geocoder miss', async () => {
const viewer = alpsViewer();
const result = await searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { return { place: null, answered: true }; } },
recoverNearView: async () => ({
lat: 46.6,
lon: 10.1,
label: 'The Alps local landmark',
types: ['locality', 'political'],
viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } },
}),
});
assert.equal(result.label, 'The Alps local landmark');
assert.equal(result.navigationMode, 'city-overview');
assert.equal(viewer.flights.length, 1);
});
test('a geocoder miss returns null even over a namesake region', async () => {
for (const answered of [true, false]) {
const viewer = alpsViewer();
const result = await searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { return { place: null, answered }; } },
recoverNearView: async () => null,
});
assert.equal(result, null);
assert.equal(viewer.flights.length, 0);
}
});
test('a geocoder rejection propagates without nearby recovery or a flight', async () => {
const viewer = alpsViewer();
const failure = new Error('geocoder failed');
let recoveries = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { throw failure; } },
recoverNearView: async () => { recoveries += 1; return null; },
}), (error) => error === failure);
assert.equal(recoveries, 0);
assert.equal(viewer.flights.length, 0);
});
test('search cancellation before or during geocoding cannot issue a flight', async () => {
for (const preAborted of [true, false]) {
const viewer = alpsViewer();
const controller = new AbortController();
if (preAborted) controller.abort();
let geocodes = 0;
let recoveries = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
signal: controller.signal,
placeSearch: {
async geocode(_query, { signal }) {
geocodes += 1;
assert.equal(signal, controller.signal);
controller.abort();
return { place: null, answered: true };
},
},
recoverNearView: async () => { recoveries += 1; return null; },
}), { name: 'AbortError' });
assert.equal(geocodes, preAborted ? 0 : 1);
assert.equal(recoveries, 0);
assert.equal(viewer.flights.length, 0);
}
});
test('cancellation during nearby recovery cannot issue a flight', async () => {
const viewer = alpsViewer();
const controller = new AbortController();
let authorityChecks = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
signal: controller.signal,
placeSearch: { async geocode() { return { place: null, answered: true }; } },
recoverNearView: async () => {
controller.abort();
return {
lat: 46.6, lon: 10.1, label: 'Alpine landmark',
types: ['locality'],
};
},
beforeFly: () => { authorityChecks += 1; return true; },
}), { name: 'AbortError' });
assert.equal(authorityChecks, 0);
assert.equal(viewer.flights.length, 0);
});
test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => {
// Zilker Park (~3 km).
assert.equal(regionFramingPlan({
+8 -12
View File
@@ -1,3 +1,5 @@
import { syncSegmentedControl } from './segmentedControl.js';
/** Own the shared aircraft display preference and its controls. */
export class AircraftDisplay {
constructor({ elements, readDataManager, layout }) {
@@ -28,12 +30,9 @@ export class AircraftDisplay {
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState();
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
for (const button of this._models3dModeBtns || []) {
if (!button) continue;
const active = button.dataset.mode === this._models3dMode;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
valueOf: (button) => button.dataset.mode,
});
this._layoutRightPanels();
}
@@ -59,12 +58,9 @@ export class AircraftDisplay {
const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized });
for (const button of this._models3dModeBtns) {
if (!button) continue;
const active = button.dataset.mode === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._models3dModeBtns, normalized, {
valueOf: (button) => button.dataset.mode,
});
this._syncModels3dButtonState();
}
+21 -6
View File
@@ -1,3 +1,5 @@
import { createSegmentedControl } from './segmentedControl.js';
/**
* Bind Display controls to application actions. Inputs remain native DOM controls;
* the caller owns settings and effect state. Destroy before replacing the view.
@@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const el of elements.allocationButtons || [])
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
for (const el of elements.modelModeButtons || [])
listen(el, 'click', 'setModelsMode', (el) =>
el.dataset.mode === 'all' ? 'all' : 'proximity',
);
for (const [buttons, valueOf, action] of [
[
elements.allocationButtons || [],
(el) => el.dataset.allocation,
'setAllocation',
],
[
elements.modelModeButtons || [],
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
'setModelsMode',
],
]) {
const control = createSegmentedControl({
buttons,
valueOf,
onChange: (value) => actions[action](value),
});
removers.push(() => control.destroy());
}
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
return {
destroy() {
+13 -28
View File
@@ -13,6 +13,7 @@ import { createRailCardBlocks } from './railCardBlocks.js';
import { createRailCards } from './railCards.js';
import { createImagerySplit } from './imagerySplit.js';
import { createSlider } from './slider.js';
import { createSegmentedControl } from './segmentedControl.js';
import {
PRODUCTS,
hhmm,
@@ -287,13 +288,13 @@ export function createRecentImageryPanel({
const selection = el('section', 'ri-block ri-selection', root);
selection.id = 'ri-selection';
selection.setAttribute('aria-label', t('On the map'));
const modeGroup = el('div', 'ri-mode', selection);
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
modeGroup.id = 'ri-mode';
modeGroup.setAttribute('role', 'radiogroup');
modeGroup.setAttribute('aria-label', t('Mode'));
const modeButtons = new Map(
MODE_LABELS.map(([mode, label]) => {
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
node.type = 'button';
node.textContent = t(label);
node.dataset.mode = mode;
@@ -301,6 +302,15 @@ export function createRecentImageryPanel({
return [mode, node];
}),
);
const modeControl = createSegmentedControl({
buttons: modeButtons.values(),
valueOf: (button) => button.dataset.mode,
onChange: (mode) => layer.setMode(mode),
keyboard: 'arrows',
keyTarget: modeGroup,
readValue: () => snapshot.mode,
listen,
});
const slotRows = {};
for (const slotId of ['a', 'b']) {
const row = el('div', 'ri-slot', selection);
@@ -670,12 +680,7 @@ export function createRecentImageryPanel({
function renderSelection() {
const { mode } = snapshot;
set(selection.dataset, 'mode', mode);
for (const [id, node] of modeButtons) {
const on = id === mode;
attribute(node, 'aria-checked', String(on));
toggle(node, 'active', on);
set(node, 'tabIndex', on ? 0 : -1);
}
modeControl.update(mode, { rovingFocus: true });
const ab = mode === 'ab';
for (const slotId of ['a', 'b']) {
const { row, tag, value, unpin } = slotRows[slotId];
@@ -1067,11 +1072,6 @@ export function createRecentImageryPanel({
strip.focus({ preventScroll: true });
return;
}
const mode = target?.closest?.('.ri-mode-btn');
if (mode) {
layer.setMode(mode.dataset.mode);
return;
}
if (target?.closest?.('.ri-swap') && !swap.disabled) {
layer.swapSides?.();
return;
@@ -1088,21 +1088,6 @@ export function createRecentImageryPanel({
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
});
listen(modeGroup, 'keydown', (event) => {
const order = MODE_LABELS.map(([mode]) => mode);
const step =
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
0;
if (!step) return;
event.preventDefault();
const next =
order[
(order.indexOf(snapshot.mode) + step + order.length) % order.length
];
layer.setMode(next);
modeButtons.get(next)?.focus?.();
});
listen(strip, 'keydown', (event) => {
const total = snapshot.candidates.length;
const index = snapshot.focusIndex || 0;
+3 -3
View File
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
assert.match(
rule('.recent-imagery-readout .ri-selection'),
/grid-template-rows: 20px 20px 20px/,
/grid-template-rows: 26px 20px 20px/,
);
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
/**
* Every block that must keep its height: the node carrying the class, that
* class, and the rule it gets its height from. The mode row is the first
* 20 px track of the selection grid. The details header has no height of
* 26 px track of the selection grid. The details header has no height of
* its own: it is the last block, so it moves nothing below it, and its
* title is held to one line.
*/
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
mode.parentNode.children[0] === mode ? mode.parentNode : null,
'ri-selection',
readout('.ri-selection'),
/grid-template-rows: 20px 20px 20px/,
/grid-template-rows: 26px 20px 20px/,
],
'ri-slot-a': [
f.byId('ri-slot-a'),
+109
View File
@@ -0,0 +1,109 @@
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
/** Pure value navigation, also usable by a future template-based view. */
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
if (!values.length) return undefined;
if (homeEnd && key === 'Home') return values[0];
if (homeEnd && key === 'End') return values.at(-1);
const step = STEPS[key];
if (!step) return undefined;
return values[(values.indexOf(value) + step + values.length) % values.length];
}
/** Paint controlled selection without emitting changes or moving focus. */
export function syncSegmentedControl(
buttons,
value,
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
) {
for (const button of buttons || []) {
if (!button) continue;
const selected = valueOf(button) === value;
if (button.classList.contains(activeClass) !== selected)
button.classList.toggle(activeClass, selected);
const set = (name, content) => {
if (button.getAttribute?.(name) !== content)
button.setAttribute(name, content);
};
set('aria-checked', String(selected));
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
}
}
/**
* Controlled segmented-selector DOM adapter. Feature owners supply values and
* receive changes; rendering a restored value never triggers the callback.
* `keyboard` retains each existing group's policy: none, arrows, or radio
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
* An injected listener owner keeps the feature's existing teardown boundary.
* @param {object} options
* @param {Iterable<HTMLButtonElement>} options.buttons
* @param {Function} options.valueOf Maps a button to the feature's value.
* @param {Function} options.onChange Receives value and originating button.
* @param {'none'|'arrows'|'radio'} [options.keyboard]
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
* @param {Function} [options.readValue] Current controlled value for delegation.
* @param {Function} [options.listen] Feature-owned event registration.
* @returns {{ update: Function, destroy: Function }}
*/
export function createSegmentedControl({
buttons,
valueOf,
onChange,
keyboard = 'none',
keyTarget,
readValue,
listen,
}) {
const options = [...buttons].filter(Boolean);
const removers = [];
let destroyed = false;
const bind = (target, type, handler) => {
const owned = (event) => {
if (!destroyed) handler(event);
};
if (listen) listen(target, type, owned);
else {
target.addEventListener(type, owned);
removers.push(() => target.removeEventListener(type, owned));
}
};
for (const button of options)
bind(button, 'click', () => {
if (!button.disabled) onChange(valueOf(button), button);
});
const navigate = (event, from) => {
const enabled = options.filter((button) => !button.disabled);
const values = enabled.map(valueOf);
const next = nextSegmentValue(values, from, event.key, {
homeEnd: keyboard === 'radio',
});
if (next === undefined) return;
const button = enabled.find((option) => valueOf(option) === next);
event.preventDefault();
if (keyTarget) {
button.click();
button.focus();
} else {
button.focus();
if (next !== from) button.click();
}
};
if (keyboard !== 'none') {
if (keyTarget)
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
else
for (const button of options)
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
}
return {
update(value, presentation) {
if (!destroyed)
syncSegmentedControl(options, value, { valueOf, ...presentation });
},
destroy() {
destroyed = true;
for (const remove of removers.splice(0)) remove();
},
};
}
+174
View File
@@ -0,0 +1,174 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { railFixture } from './railTestFixture.mjs';
import {
createSegmentedControl,
nextSegmentValue,
} from './segmentedControl.js';
function fixture(options = {}) {
let writes = 0;
const f = railFixture(() => writes++);
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (button) => button.dataset.value,
onChange: (value) => calls.push(value),
...options,
});
const key = (target, value) => {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value });
target.dispatchEvent(event);
return event.defaultPrevented;
};
return { ...f, buttons, calls, control, key, writes: () => writes };
}
test('controlled restoration updates selection without emitting or stealing focus', () => {
const f = fixture();
f.buttons[0].focus();
f.control.update('b', { rovingFocus: true });
assert.deepEqual(f.calls, []);
assert.equal(f.document.activeElement, f.buttons[0]);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('aria-checked')),
['false', 'true', 'false'],
);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('tabindex')),
['-1', '0', '-1'],
);
const before = f.writes();
f.control.update('b', { rovingFocus: true });
assert.equal(
f.writes(),
before,
'unchanged renders must not retrigger rail observers',
);
f.control.destroy();
});
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
const f = fixture();
f.control.update('a');
f.buttons[0].click();
f.buttons[0].click();
f.buttons[1].dataset.value = 'renamed';
f.buttons[1].click();
f.buttons[2].disabled = true;
f.buttons[2].dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
assert.equal(
f.buttons[0].getAttribute('aria-checked'),
'true',
'the owner controls selection',
);
f.control.destroy();
});
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
const f = fixture({ keyboard: 'radio' });
f.buttons[1].disabled = true;
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c']);
f.key(f.buttons[2], 'ArrowDown');
assert.equal(f.document.activeElement, f.buttons[0]);
f.key(f.buttons[0], 'Home');
assert.deepEqual(
f.calls,
['c', 'a'],
'Home on the first button does not reselect it',
);
f.key(f.buttons[0], 'End');
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c', 'a', 'c']);
f.control.destroy();
});
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
const f = fixture();
f.control.update('b');
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
assert.equal(f.key(f.buttons[0], 'Home'), false);
assert.deepEqual(f.calls, []);
assert.ok(
f.buttons.every((button) => button.getAttribute('tabindex') === null),
);
f.control.destroy();
});
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
const f = railFixture();
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
let selected = 'c';
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => {
calls.push(value);
selected = value;
},
keyboard: 'arrows',
keyTarget: f.container,
readValue: () => selected,
});
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
f.container.dispatchEvent(event);
assert.deepEqual(calls, ['a']);
assert.equal(f.document.activeElement, buttons[0]);
const home = new Event('keydown', { cancelable: true });
Object.defineProperty(home, 'key', { value: 'Home' });
f.container.dispatchEvent(home);
assert.equal(home.defaultPrevented, false);
assert.deepEqual(calls, ['a']);
control.destroy();
});
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
const f = fixture({ keyboard: 'radio' });
f.control.destroy();
const next = [];
const replacement = createSegmentedControl({
buttons: f.buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => next.push(value),
keyboard: 'radio',
});
f.buttons[0].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(f.calls, []);
assert.deepEqual(next, ['a', 'c']);
replacement.destroy();
replacement.destroy();
f.buttons[1].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(next, ['a', 'c']);
});
test('pure value navigation can be reused without a DOM', () => {
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
assert.equal(
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
undefined,
);
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
});
+29 -53
View File
@@ -1,5 +1,9 @@
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import { syncChipGroup } from './chipGroup.js';
import {
createSegmentedControl,
syncSegmentedControl,
} from './segmentedControl.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
@@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
// Renders run on every layer notification, and the rail's MutationObserver
// relays out on each attribute write: write only what changed.
@@ -106,13 +104,13 @@ export class StreetLevelControls {
}
this._toggleProvider(button.dataset.chipId);
});
for (const button of el.panoButtons) {
this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
createSegmentedControl({
buttons: el.panoButtons,
valueOf: (button) => button.dataset.slPano,
onChange: (pano) => this._setParams({ pano }),
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
});
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
@@ -160,17 +158,20 @@ export class StreetLevelControls {
this._exitExpanded();
});
} else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
createSegmentedControl({
buttons: el.renderButtons,
valueOf: (button) => button.dataset.slRender,
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
onChange: (mode) => {
this.layer.setViewerRenderMode?.(mode);
try {
localStorage.setItem(RENDER_MODE_KEY, mode);
} catch {
/* storage unavailable */
}
});
}
},
});
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
@@ -185,24 +186,6 @@ export class StreetLevelControls {
}
}
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
@@ -416,15 +399,6 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
}
}
_renderFilters(view) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
@@ -434,10 +408,11 @@ export class StreetLevelControls {
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
syncSegmentedControl(el.panoButtons, view.filter.pano, {
valueOf: (button) => button.dataset.slPano,
activeClass: 'is-active',
rovingFocus: true,
});
if (el.sinceRange) {
// Never move the thumb under the user's hand; once the committed value
// matches the thumb, show the full readout with its cut-off date.
@@ -467,10 +442,11 @@ export class StreetLevelControls {
setProp(wrap, 'hidden', !viewer.open);
}
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
valueOf: (button) => button.dataset.slRender,
activeClass: 'is-active',
rovingFocus: true,
});
// Google's viewer always fills its frame: no FIT/FILL to offer.
const renderGroup = el.renderButtons[0]?.parentElement;
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
+4 -20
View File
@@ -574,11 +574,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
padding: 5px 8px 6px;
}
.detection-allocation-seg .pp-mode-btn {
font-size: 8px;
letter-spacing: 0.35px;
}
#hud-layout-row {
min-width: 200px;
}
@@ -596,7 +591,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn {
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
margin-left: auto;
min-width: 48px;
}
@@ -748,11 +743,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -881,11 +871,8 @@ body.gev-geo-import-drag #cesiumContainer {
.pp-mode-seg {
display: flex;
flex: 1;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
overflow: hidden;
}
.pp-mode-btn {
.pp-mode-btn:not(.gev-segment) {
flex: 1;
height: 22px;
background: rgba(255, 255, 255, 0.04);
@@ -900,14 +887,11 @@ body.gev-geo-import-drag #cesiumContainer {
background-color var(--transition-fast),
color var(--transition-fast);
}
.pp-mode-btn + .pp-mode-btn {
border-left: 1px solid rgba(255, 255, 255, 0.14);
}
.pp-mode-btn:hover {
.pp-mode-btn:not(.gev-segment):hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.pp-mode-btn.active {
.pp-mode-btn:not(.gev-segment).active {
background: var(--accent);
color: #000;
}
+2
View File
@@ -506,11 +506,13 @@
0 0 12px rgba(196, 84, 84, 0.14);
}
:root[data-ui-theme='cyber'] .gev-segmented,
:root[data-ui-theme='cyber'] .pp-mode-seg {
border-color: rgba(206, 222, 226, 0.24);
border-radius: 1px;
}
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
background: var(--cyber-red);
color: #f7f8f7;
+2 -2
View File
@@ -184,8 +184,8 @@
max-height: 100%;
pointer-events: auto;
}
#right-context-rail > #weather-panel,
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
#right-context-rail > #weather-panel:not(.collapsed),
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
display: flex;
flex-direction: column;
+2 -3
View File
@@ -309,10 +309,10 @@ html:not([lang='en'])
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. */
/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-rows: 20px 20px 20px;
grid-template-rows: 26px 20px 20px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
@@ -321,7 +321,6 @@ html:not([lang='en'])
.recent-imagery-readout .ri-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-selection .data-toggle-chip {
height: 20px;
+51
View File
@@ -0,0 +1,51 @@
/* Shared one-of-many control. Feature classes retain surrounding placement. */
.gev-segmented {
display: inline-flex;
min-width: 0;
border: 1px solid var(--glass-border-hover);
border-radius: 6px;
overflow: hidden;
}
.gev-segmented[hidden] {
display: none;
}
.gev-segment {
flex: 1;
min-width: min-content;
min-height: 24px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 0;
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font: 9px/1.25 var(--font-mono);
letter-spacing: 0.6px;
text-transform: uppercase;
overflow-wrap: normal;
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}
.gev-segment + .gev-segment {
border-left: 1px solid var(--glass-border);
}
.gev-segment:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.gev-segment[aria-checked='true'] {
background: var(--accent);
color: #000;
}
.gev-segment:focus-visible {
position: relative;
outline: 2px solid var(--text-primary);
outline-offset: -3px;
box-shadow: inset 0 0 0 4px #061014;
}
.gev-segment:disabled {
opacity: 0.42;
cursor: not-allowed;
}
-42
View File
@@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex-wrap: wrap;
}
/* No overflow clipping: it would cut off the buttons' focus ring. The end
buttons round themselves instead. */
.sl-seg {
display: inline-flex;
border: 1px solid var(--glass-border-hover);
border-radius: 999px;
}
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
@@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
padding: 4px 9px;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sl-seg-btn:first-child {
border-radius: 999px 0 0 999px;
}
.sl-seg-btn:last-child {
border-radius: 0 999px 999px 0;
}
.sl-seg-btn + .sl-seg-btn {
border-left: 1px solid var(--glass-border);
}
.sl-seg-btn:focus-visible {
position: relative;
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.sl-seg-btn.is-active {
background: rgba(5, 203, 99, 0.22);
color: var(--text-primary);
}
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
+10 -10
View File
@@ -63,9 +63,9 @@
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
@@ -99,9 +99,9 @@
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
</div>
</div>
</div>
@@ -128,10 +128,10 @@
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
+7 -7
View File
@@ -87,9 +87,9 @@
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
</div>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
@@ -118,10 +118,10 @@
<fieldset id="sl-controls" class="sl-controls">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
+4 -5
View File
@@ -1,4 +1,5 @@
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
import {
@@ -557,11 +558,9 @@ export class VisualSettings {
const normalized = normalizeAllocationStrategy(raw);
this._detectionAllocationPreference = normalized;
setDetectionTuning({ allocationStrategy: normalized });
for (const button of this._detectionAllocationBtns) {
const active = button.dataset.allocation === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
valueOf: (button) => button.dataset.allocation,
});
if (persist) {
try {
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
+496
View File
@@ -0,0 +1,496 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from '../data/manager.js';
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import { controlCctv, createGevActionRunner } from './gevActions.js';
const originalWindow = globalThis.window;
before(() => {
globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null };
});
after(() => {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
});
function viewerFixture() {
return {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
};
}
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function runnerFixture(t, dataManager, options = {}) {
const runner = createGevActionRunner({
viewer: viewerFixture(),
styleManager: {},
dataManager,
...options,
});
t.after(() => runner.dispose());
return runner;
}
// Only the inert module's lifecycle completion is controlled. The manager's
// intent queue, cancellation, compensation, events, and runner are production.
async function lifecycleFixture(t, targetEnabled) {
const viewer = viewerFixture();
const manager = new DataLayerManager(viewer);
const calls = [];
const gates = [];
const pending = [];
let nextGate = null;
let moduleEnabled = false;
const phase = targetEnabled ? 'enable' : 'disable';
const lifecycle = async (name, options = {}) => {
calls.push(name);
if (name === phase && nextGate) {
const gate = nextGate;
nextGate = null;
gate.entered.resolve(options.signal);
await gate.release.promise;
if (gate.outcome instanceof Error) throw gate.outcome;
if (gate.outcome === false) return false;
}
moduleEnabled = name === 'enable';
return true;
};
manager.register({
id: 'cctv',
name: 'CCTV',
source: 'test',
updateInterval: -1,
init: async () => true,
enable: (_viewer, options) => lifecycle('enable', options),
disable: (_viewer, options) => lifecycle('disable', options),
update: async () => true,
getStats: () => ({ count: 0, lastUpdate: 0 }),
});
if (!targetEnabled)
assert.equal(await manager.setEnabled('cctv', true), true);
calls.length = 0;
const events = [];
const unsubscribe = manager.subscribe((event) => events.push(event));
const runner = runnerFixture(t, manager, { viewer });
t.after(async () => {
for (const gate of gates) gate.release.resolve();
await Promise.allSettled(pending);
unsubscribe();
await manager.destroyAll();
});
return {
manager,
events,
calls,
get moduleEnabled() {
return moduleEnabled;
},
gate(outcome = true) {
const gate = { entered: deferred(), release: deferred(), outcome };
gates.push(gate);
nextGate = gate;
return gate;
},
run(action, options = {}) {
const work = runner('control_cctv', { action }, options);
pending.push(work);
return work;
},
};
}
function assertLifecycle(result, manager, enabled, uncertain = false) {
assert.equal(result.action, 'control_cctv');
assert.equal(result.layerId, 'cctv');
assert.equal(result.enabled, enabled);
assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled');
assert.equal(result.lifecycleUncertain, uncertain);
assert.deepEqual(manager.getLayerLifecycleState('cctv'), {
enabled,
lifecycleState: result.lifecycleState,
uncertain,
});
}
const terminalTypes = new Set([
'visibility',
'visibility-cancelled',
'visibility-failed',
'visibility-blocked',
]);
for (const action of ['enable', 'disable']) {
const targetEnabled = action === 'enable';
for (const outcome of [
'success',
'false',
'throw',
'caller-abort',
'resource-abort',
'late-abort',
]) {
test(
`CCTV ${action}: ${outcome} preserves the manager outcome`,
{ timeout: 2000 },
async (t) => {
const h = await lifecycleFixture(t, targetEnabled);
const controller = new AbortController();
const failure =
outcome === 'resource-abort'
? Object.assign(new Error('fixture resource ended'), {
name: 'AbortError',
})
: outcome === 'throw'
? new Error('fixture lifecycle failed')
: null;
const gate = h.gate(failure || (outcome === 'false' ? false : true));
let lateAbortObserved = false;
if (outcome === 'late-abort') {
t.after(
h.manager.subscribe((event) => {
if (event.type === 'visibility' && event.origin === 'voice') {
lateAbortObserved = true;
controller.abort();
}
}),
);
}
let settled = false;
// Uppercase exercises the existing case-insensitive action contract.
const work = h.run(
outcome === 'success' ? action.toUpperCase() : action,
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
void work.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const moduleSignal = await gate.entered.promise;
assert.equal(
settled,
false,
'the tool must wait for lifecycle completion',
);
if (outcome === 'caller-abort') {
controller.abort();
assert.ok(
moduleSignal,
'the manager must receive a lifecycle signal',
);
assert.equal(
moduleSignal.aborted,
true,
'caller abort must reach the manager lifecycle',
);
}
gate.release.resolve();
const result = await work;
const committed = outcome === 'success' || outcome === 'late-abort';
const cancelled =
outcome === 'caller-abort' || outcome === 'resource-abort';
assert.equal(result.ok, committed);
assert.equal(result.cancelled, cancelled ? true : undefined);
assertLifecycle(
result,
h.manager,
committed ? targetEnabled : !targetEnabled,
!targetEnabled && (outcome === 'false' || outcome === 'throw'),
);
assert.equal(
h.moduleEnabled,
committed ? targetEnabled : !targetEnabled,
'the module must match the committed or compensated manager state',
);
if (cancelled) {
assert.equal(result.cancellationReason, outcome);
assert.equal(result.phase, action);
assert.equal(result.successorIntentEpoch, null);
assert.equal(result.successorEnabled, null);
assert.equal(result.successorOrigin, null);
} else if (!committed) {
assert.equal(result.error, `Could not ${action} the requested layer`);
}
const terminal = h.events.filter(
(event) => event.origin === 'voice' && terminalTypes.has(event.type),
);
assert.equal(
terminal.length,
1,
'each voice intent has one terminal manager event',
);
assert.equal(
terminal[0].type,
committed
? 'visibility'
: cancelled
? 'visibility-cancelled'
: 'visibility-failed',
);
if (outcome === 'resource-abort')
assert.equal(controller.signal.aborted, false);
if (outcome === 'late-abort') assert.equal(lateAbortObserved, true);
},
);
}
test(
`CCTV ${action}: already-aborted caller cannot enter lifecycle work`,
{ timeout: 2000 },
async (t) => {
const h = await lifecycleFixture(t, targetEnabled);
const controller = new AbortController();
controller.abort();
const result = await h.run(action, { signal: controller.signal });
assert.equal(result.ok, false);
assert.equal(result.cancelled, true);
assert.equal(result.cancellationReason, 'caller-abort');
assert.equal(result.phase, 'queued');
assertLifecycle(result, h.manager, !targetEnabled);
assert.equal(h.moduleEnabled, !targetEnabled);
assert.deepEqual(h.calls, []);
assert.equal(
h.events.filter((event) => terminalTypes.has(event.type)).length,
1,
);
},
);
}
test(
'CCTV toggles retain missing-module errors without calling visibility',
{ timeout: 2000 },
async (t) => {
for (const layers of [new Map(), new Map([['cctv', {}]])]) {
const runner = runnerFixture(t, {
layers,
getAll: () => [],
setEnabled: () =>
assert.fail('missing CCTV must not dispatch visibility'),
});
for (const action of ['enable', 'disable']) {
assert.deepEqual(await runner('control_cctv', { action }), {
ok: false,
action: 'control_cctv',
error: 'CCTV layer unavailable',
});
}
}
},
);
test('exported controlCctv refuses toggles without calling the manager', async () => {
const manager = {
layers: new Map([['cctv', { module: {} }]]),
setEnabled: () => assert.fail('direct helper must not change visibility'),
isEnabled: () => assert.fail('direct toggle must not read enabled state'),
};
for (const action of ['ENABLE', 'disable']) {
const result = await controlCctv(manager, { action });
assert.equal(result.ok, false);
assert.equal(result.action, 'control_cctv');
assert.match(result.error, /set_layer_visibility/);
}
});
test(
'CCTV fallback managers report rejection, transitional state, and uncertainty',
{ timeout: 2000 },
async (t) => {
for (const enabled of [true, false]) {
const action = enabled ? 'enable' : 'disable';
for (const mode of [
'rejection',
'mismatch',
'transitional',
'uncertain',
]) {
const actualEnabled =
mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled;
const lifecycleState =
mode === 'transitional'
? enabled
? 'enabling'
: 'disabling'
: actualEnabled
? 'enabled'
: 'disabled';
const manager = {
layers: new Map([['cctv', { module: {} }]]),
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
isEnabled: () => actualEnabled,
getLayerLifecycleState: () => ({
enabled: actualEnabled,
lifecycleState,
uncertain: mode === 'uncertain',
}),
async setEnabled() {
if (mode === 'rejection') throw new Error('adapter rejected');
return true;
},
};
const runner = runnerFixture(t, manager);
const result = await runner('control_cctv', { action });
assert.equal(result.ok, false, `${action}: ${mode}`);
assert.equal(result.action, 'control_cctv');
assert.equal(result.enabled, actualEnabled);
assert.equal(result.lifecycleState, lifecycleState);
assert.equal(result.lifecycleUncertain, mode === 'uncertain');
assert.equal(
result.error,
mode === 'rejection'
? 'adapter rejected'
: `Could not ${action} the requested layer`,
);
}
}
},
);
test(
'CCTV visibility composes only its outer speech envelope',
{ timeout: 2000 },
async (t) => {
let enabled = false;
const calls = [];
const runner = runnerFixture(
t,
{
layers: new Map([['cctv', { module: {} }]]),
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
isEnabled: () => enabled,
async setEnabled(_id, value) {
enabled = value;
return true;
},
},
{
speechBuilders: {
control_cctv(result, args) {
calls.push(['cctv', result.action, args.action]);
return {
say: 'CCTV response',
display: { title: 'CCTV', lines: [] },
};
},
set_layer_visibility() {
calls.push(['generic']);
return { say: 'Layer response' };
},
},
},
);
const result = await runner('control_cctv', { action: 'enable' });
assert.equal(result.ok, true);
assert.equal(result.action, 'control_cctv');
assert.equal(result.say, 'CCTV response');
assert.deepEqual(result.display, { title: 'CCTV', lines: [] });
assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]);
},
);
test(
'CCTV non-toggle actions retain camera navigation and durable coverage',
{ timeout: 2000 },
async (t) => {
const calls = [];
const cameras = [
{ id: 'a', name: 'Camera A' },
{ id: 'b', name: 'Camera B' },
];
let activeCameraId = 'a';
let coverageMode = 'viewshed';
const module = {
getUIState: () => ({
cameras,
activeCameraId,
activeCamera: cameras.find((camera) => camera.id === activeCameraId),
coverageMode,
showCoverage: coverageMode !== 'off',
}),
selectCamera(id) {
calls.push(['select', id]);
activeCameraId = id;
return true;
},
cycleCamera(step) {
calls.push(['cycle', step]);
activeCameraId = step > 0 ? 'b' : 'a';
return activeCameraId;
},
focusNearest(options) {
calls.push(['nearest', options]);
activeCameraId = 'a';
return 'a';
},
focusCamera(id, duration) {
calls.push(['focus', id, duration]);
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
},
};
let superseded = 0;
const runner = runnerFixture(
t,
{
layers: new Map([['cctv', { module }]]),
isEnabled: () => true,
setEnabled: () =>
assert.fail('non-toggle action must not dispatch visibility'),
setLayerParams(id, params, options) {
calls.push(['params', id, params, options]);
coverageMode = params.coverageMode;
},
},
{
styleManager: {
supersedeDeferredNavigation() {
superseded += 1;
},
},
},
);
for (const args of [
{ action: 'select', cameraQuery: 'Camera B' },
{ action: 'next' },
{ action: 'prev' },
{ action: 'nearest' },
{ action: 'focus' },
]) {
const result = await runner('control_cctv', args);
assert.equal(result.ok, false);
assert.equal(result.action, 'control_cctv');
assert.match(result.error, /tracking holds the view/);
}
const coverage = await runner('control_cctv', { action: 'coverage' });
assert.equal(coverage.ok, true);
assert.equal(coverage.coverageMode, 'off');
assert.equal(superseded, 5, 'coverage must not acquire camera navigation');
assert.deepEqual(calls, [
['select', 'b'],
['focus', 'b', 1.8],
['cycle', 1],
['focus', 'b', 1.8],
['cycle', -1],
['focus', 'a', 1.8],
['nearest', { focus: false }],
['focus', 'a', 1.8],
['focus', 'a', undefined],
['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }],
]);
},
);
+14 -5
View File
@@ -1092,6 +1092,18 @@ export function createGevActionRunner({
}
if (name === 'control_cctv') {
const action = String(args.action || '').toLowerCase();
if (
(action === 'enable' || action === 'disable') &&
dataManager.layers.get('cctv')?.module
) {
const result = await runGevAction(
'set_layer_visibility',
{ layerId: 'cctv', enabled: action === 'enable' },
runOptions,
);
return { ...result, action: 'control_cctv' };
}
return controlCctv(dataManager, args, styleManager);
}
@@ -1432,13 +1444,10 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
}
if (action === 'enable' || action === 'disable') {
await dataManager.setEnabled('cctv', action === 'enable', {
origin: 'voice',
});
return {
ok: true,
ok: false,
action: 'control_cctv',
enabled: dataManager.isEnabled('cctv'),
error: 'Enable or disable CCTV through set_layer_visibility',
};
}
if (!dataManager.isEnabled('cctv')) {