Compare commits

..
Author SHA1 Message Date
German Khokhlov c06eb89190 Merge branch 'main' into german/transit-bikeshare-empty-state 2026-11-06 23:37:24 +04:00
German Khokhlov ea76fc4bc0 Fold duplicate coverage tests into their neighbours 2026-11-06 22:01:39 +04:00
German Khokhlov 5c4918affb Tighten coverage prompt comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 3a78f40024 Merge main into german/transit-bikeshare-empty-state
Conflict in src/layers/transit/queries.js: keep the branch's shared note with the gate's enter altitude and main's distinct region count.
2026-11-06 20:00:58 +04:00
German Khokhlov 697b6b59e6 Repaint coverage rows after loads, nudges and cleared failures 2026-11-06 14:47:47 +04:00
German Khokhlov 90db4eb2c9 Name the real gate altitude and repaint coverage rows at once
The Transit prompt said "Fly below 3,000 km", but the closed gate opens at
2,850 km; it now names the enter threshold, as Bike Share does. When the
camera changes coverage, Transit and Bike Share ask the Layers panel to
repaint instead of leaving the old prompt up until the next 15 s / 60 s tick.
2026-11-05 21:21:03 +04:00
German Khokhlov 25c33893f6 Add the changelog entry for #1576 2026-11-05 12:53:09 +04:00
German Khokhlov b6ad32009f Say why Transit and Bike Share rows are empty outside coverage
Over a city with no feed or system (London, Tokyo) both rows read
"ON · — · never", the same as a feed that never answered. Transit already
computed a coverage note but put it only in `coverage`, which the row does
not show; Bike Share reported nothing at all.

Both layers now report a `zoom-in` guidance status with a `statusMessage`:
"Fly below 3,000 km to a covered region" / "No feed here yet · N regions
available" for Transit, and "Fly below 48 km to a covered city" / "No bike
share here yet · N systems available" for Bike Share. A loading or failed
Bike Share city keeps its own text. Leaving coverage clears the layer's
last-update time, so the row no longer dates data it no longer draws.

The coverage-note translations move from the voice card into
`translateStatusMessage` (ui/statusText.js), which the Layers row and the
voice card now share.
2026-11-05 12:52:28 +04:00
61 changed files with 1029 additions and 1167 deletions

No files matched your search

@@ -0,0 +1,4 @@
- Say why Transit and Bike Share are empty outside coverage. Over a city with
no feed or system (London, Tokyo) their Layers rows say so and count the
covered regions or systems, or say how low to fly, instead of `never`;
leaving coverage clears the last-update time (German, @gekh, #1576).
@@ -1,4 +0,0 @@
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
Lines draws each road with flow in green, amber or red, and Dots shows or
hides the moving dots. A host can choose which road modes it offers; without
OpenStreetMap the layer never draws simulated roads (#1609).
@@ -1,4 +0,0 @@
- Typed chat on OpenAI answers again: OpenAI refused every message because
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
not accept. Chat leaves those patterns out; area ids are still checked
before the tool runs (samehkhamis, #1616).
-3
View File
@@ -1,3 +0,0 @@
- Turning CCTV on opens the camera nearest you instead of the first camera in
the catalog, which showed a Sydney camera in London. A share link opens on
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
+13 -21
View File
@@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
@@ -1980,6 +1980,17 @@ Mapped-installation zoom guidance appears in the layer row without counting as
a global loading failure. Guidance statuses do not suppress independent refresh
errors.
Transit and Bike Share say the same when no feed or system covers the view,
instead of an undated `never`. Above their altitude gates the rows read
`GTFS-RT · Fly below 2,850 km to a covered region` and
`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers
(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and
`GBFS · No bike share here yet · N systems available` (covered regions,
catalogued systems). The chip stays ON. A loading or failed Bike Share city
shows its own text. Leaving coverage clears the last-update time. The voice
analyst gets the same English notes; both translate them through
`translateStatusMessage` (`src/ui/statusText.js`).
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
@@ -6437,26 +6448,7 @@ easier to meet (detection is now on more often), but does not create it.
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
overrides restored state until the user picks a chip. A host may offer
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
default all three): only offered modes get chips (none for a single mode),
and other choices from chips, restores, share links or the query are
ignored. Without `osm` the layer never simulates: keyless or with the
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
or "TomTom roads unavailable while the traffic service is unreachable" and
no roads are drawn.
- Row toggles (live mode only): after the road-source chips, Lines draws
every road with flow as a steady draped line in its bucket color (green
free, amber slow, red jam; jam first, then slow, then free, longest first,
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
are not offered to detection overlays, and hold no render frames).
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
live choice, a restore or a share link can always be shown again.
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
is off. With no stored choice
overrides restored state until the user picks a chip. With no stored choice
the default is Hybrid with a TomTom key and OSM without one; share links
without a road-source choice (including older ones) likewise use the
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
+1 -30
View File
@@ -64,30 +64,6 @@ function validMessages(messages) {
return !pending.length;
}
/**
* A tool schema without the `pattern`s Chat Completions refuses: it answers
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
* as `resolve_area`'s area ids do. Every argument is still checked against
* the action's own contract before the tool runs, so the model loses a hint,
* not a rule. Realtime voice accepts these patterns and keeps them.
*/
export function chatSchema(schema) {
if (Array.isArray(schema)) return schema.map(chatSchema);
if (!object(schema)) return schema;
return Object.fromEntries(
Object.entries(schema)
.filter(
([key, value]) =>
!(
key === 'pattern' &&
typeof value === 'string' &&
/\\[pP]\{/.test(value)
),
)
.map(([key, value]) => [key, chatSchema(value)]),
);
}
export function createOpenAiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
@@ -150,12 +126,7 @@ export function createOpenAiChatHandler({
tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
type: 'function',
function: {
name,
description,
parameters: chatSchema(parameters),
strict: false,
},
function: { name, description, parameters, strict: false },
}),
),
},
-2
View File
@@ -67,8 +67,6 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
CCTV_CALIBRATION_STORAGE_KEY_V1,
-116
View File
@@ -61,8 +61,6 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch,
@@ -1298,120 +1296,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
}
});
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = [];
_setCctvOverlayHostForTest({
-3
View File
@@ -335,9 +335,6 @@ const OPTION_GROUPS = Object.freeze({
osm: 'o',
hybrid: 'h',
}),
// Street Traffic row toggles: flow-colored road lines, moving dots.
booleanOption('lines', 'l', false),
booleanOption('dots', 'd', true),
]),
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
+17 -1
View File
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
/**
* Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps).
* @param {string} [layerId] - The layer whose row changed.
*/
refreshLayerStats() {
refreshLayerStats(layerId) {
this._dropRecoveredRefreshError(layerId);
this._publishActivity({ type: 'status' });
}
/**
* Drop a tick failure the layer's stats no longer report (the camera left
* that feed) instead of showing it until the next tick.
*/
_dropRecoveredRefreshError(layerId) {
const entry = layerId ? this.layers.get(layerId) : null;
if (!entry?.managerRefreshError || entry.refreshing) return;
const failure = refreshFailureFromStats(
this._moduleStats(entry),
entry.module.name || layerId,
);
if (!failure) entry.managerRefreshError = null;
}
_publishActivity(change) {
for (const callback of this._activityListeners) {
try {
-10
View File
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
this._presentation.refresh();
}
/**
* Repaint the toggle panel now. For layers whose data arrives outside their
* manager tick (camera-driven loads such as Transit's proximity polls), so a
* row shows its count when the data lands instead of at the next interval.
* One DOM pass; skipped while the document is hidden.
*/
refreshLayerStats() {
this._refreshTogglePanel();
}
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
+25
View File
@@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
assert.deepEqual(changes, ['status']);
});
test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
let stats = { count: 0, error: 'Feed unavailable' };
lifecycle.register({
id: 'feed',
name: 'Feed',
getStats: () => stats,
async update() {},
});
const entry = lifecycle.layers.get('feed');
entry.enabled = true;
entry.initialized = true;
entry.managerRefreshError = 'Feed unavailable';
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, 'Feed unavailable');
stats = { count: 0 };
lifecycle.refreshLayerStats();
assert.equal(entry.managerRefreshError, 'Feed unavailable');
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, null);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
+9 -5
View File
@@ -1749,11 +1749,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
"Hybrid": "هجين",
"Lines": "خطوط",
"Dots": "نقاط",
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
"Moving dots": "نقاط متحركة",
"TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
@@ -2925,6 +2920,15 @@
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
},
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
"No bike share here yet · {n} systems available": {
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
},
"degraded": "متدهور",
"stale": "متقادم",
"partial": "جزئي",
+5 -5
View File
@@ -1667,11 +1667,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
"Hybrid": "Hybrid",
"Lines": "Linien",
"Dots": "Punkte",
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
"Moving dots": "Bewegte Punkte",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
@@ -2675,6 +2670,11 @@
"one": "Hier noch kein Feed · {n} Region verfügbar",
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
},
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
"No bike share here yet · {n} systems available": {
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
},
"degraded": "eingeschränkt",
"stale": "veraltet",
"partial": "teilweise",
+6 -5
View File
@@ -2699,11 +2699,6 @@
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
"Hybrid": "Híbrido",
"Lines": "Líneas",
"Dots": "Puntos",
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
"Moving dots": "Puntos en movimiento",
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
@@ -2834,6 +2829,12 @@
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
},
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
"No bike share here yet · {n} systems available": {
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
},
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
"Decoder feeds": "Fuentes del decodificador",
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
+6 -5
View File
@@ -1103,11 +1103,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
@@ -2542,6 +2537,12 @@
"many": "Pas encore de flux ici · {n} de régions disponibles",
"other": "Pas encore de flux ici · {n} régions disponibles"
},
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
"No bike share here yet · {n} systems available": {
"one": "Pas encore de vélos partagés ici · {n} système disponible",
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
},
"degraded": "dégradé",
"stale": "obsolète",
"partial": "partiel",
+6 -5
View File
@@ -1103,11 +1103,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
"Hybrid": "Ibrido",
"Lines": "Linee",
"Dots": "Punti",
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
"Moving dots": "Punti in movimento",
"TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
@@ -2542,6 +2537,12 @@
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
"other": "Ancora nessun feed qui · {n} regioni disponibili"
},
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
"No bike share here yet · {n} systems available": {
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
},
"degraded": "degradato",
"stale": "obsoleto",
"partial": "parziale",
+4 -5
View File
@@ -1071,11 +1071,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
"Hybrid": "ハイブリッド",
"Lines": "ライン",
"Dots": "ドット",
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
"Moving dots": "移動ドット",
"TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
@@ -2412,6 +2407,10 @@
"No feed here yet · {n} regions available": {
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
},
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
"No bike share here yet · {n} systems available": {
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
},
"degraded": "性能低下",
"stale": "更新遅延",
"partial": "部分的",
+6 -5
View File
@@ -1101,11 +1101,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)",
"Hybrid": "Híbrido",
"Lines": "Linhas",
"Dots": "Pontos",
"Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho",
"Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo",
"Moving dots": "Pontos em movimento",
"TomTom key unavailable": "Chave do TomTom indisponível",
"TomTom daily budget reached": "Limite diário do TomTom atingido",
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
@@ -2544,6 +2539,12 @@
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
},
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
"No bike share here yet · {n} systems available": {
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
},
"degraded": "degradado",
"stale": "desatualizado",
"partial": "parcial",
+7 -5
View File
@@ -2757,11 +2757,6 @@
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
"Camera coverage unavailable": "Покрытие камер недоступно",
"Hybrid": "Гибрид",
"Lines": "Линии",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
"Moving dots": "Движущиеся точки",
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
@@ -2897,6 +2892,13 @@
"many": "Здесь пока нет потока данных · доступно {n} регионов",
"other": "Здесь пока нет потока данных · доступно {n} региона"
},
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
"No bike share here yet · {n} systems available": {
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
},
"Wind speed field unavailable": "Поле скорости ветра недоступно",
"Decoder feeds": "Потоки декодера",
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
+7 -5
View File
@@ -1118,11 +1118,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)",
"Hybrid": "Гібрид",
"Lines": "Лінії",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного",
"Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн",
"Moving dots": "Рухомі точки",
"TomTom key unavailable": "Ключ TomTom недоступний",
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
@@ -2606,6 +2601,13 @@
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
"other": "Тут поки немає потоку даних · доступно {n} регіону"
},
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
"No bike share here yet · {n} systems available": {
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
},
"degraded": "з перебоями",
"stale": "застарів",
"partial": "частково",
+4 -5
View File
@@ -1047,11 +1047,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)",
"Hybrid": "混合",
"Lines": "线条",
"Dots": "圆点",
"Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红",
"Moving dots colored by live flow": "按实时路况着色的移动圆点",
"Moving dots": "移动圆点",
"TomTom key unavailable": "TomTom 密钥不可用",
"TomTom daily budget reached": "已达 TomTom 每日配额",
"Monthly traffic allowance used": "本月交通流量额度已用完",
@@ -2313,6 +2308,10 @@
"No feed here yet · {n} regions available": {
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
},
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
"No bike share here yet · {n} systems available": {
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
},
"degraded": "降级",
"stale": "过期",
"partial": "部分可用",
+4 -5
View File
@@ -1042,11 +1042,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)",
"Hybrid": "混合",
"Lines": "線條",
"Dots": "圓點",
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
"Moving dots": "移動圓點",
"TomTom key unavailable": "TomTom 密鑰不可用",
"TomTom daily budget reached": "已達 TomTom 每日配額",
"Monthly traffic allowance used": "本月交通流量額度已用完",
@@ -2301,6 +2296,10 @@
"No feed here yet · {n} regions available": {
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
},
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
"No bike share here yet · {n} systems available": {
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
},
"degraded": "降級",
"stale": "過期",
"partial": "部分可用",
+37 -4
View File
@@ -1,5 +1,6 @@
import { msg, t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
import { GBFS_CITY_REGISTRY } from './registry.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -13,6 +14,14 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: STATUS_POLL_MS,
/**
* Kept so camera moves can repaint the row before the next status poll.
* @param {object} dataManager DataLayerManager instance.
*/
attachDataManager(dataManager) {
layerState._dataManager = dataManager;
},
/**
* Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed).
@@ -57,15 +66,39 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
*/
getStats() {
// A city in range without points is loading even before its fetch
// starts: the row repaints on entry, ahead of beginLoading().
const loading =
layerState._loading ||
[...layerState._activeCityIds].some(
(cityId) =>
!layerState._cityRuntime.has(cityId) &&
!layerState._failedCityIds.has(cityId),
);
const stats = {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
loading,
};
if (layerState._loading) {
// No system in range: the camera is too high or no system is near. A
// failed city also leaves range empty and reports its failure below.
if (
layerState._enabled &&
!loading &&
layerState._activeCityIds.size === 0 &&
layerState._failedCityIds.size === 0
) {
// English: the analyst's empty note and the row's prompt, both shown
// through translateStatusMessage (ui/statusText.js).
const note = layerState._altitudeGateEnabled
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
return { ...stats, status: 'zoom-in', statusMessage: note };
}
if (loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? t('syncing {count} city feeds...', {
+1
View File
@@ -169,6 +169,7 @@ export function createLifecycle({
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._dataManager = null;
layerState._viewer = null;
},
};
+366
View File
@@ -0,0 +1,366 @@
// Bike Share's Layers row. With no system in range it says why instead of
// "never" and keeps no date for data it no longer draws. After the camera
// settles, the last row the panel painted must match what the layer reports
// now. Each case drives the real viewport,
// ingestion, rendering and controls; the manager is a spy that records the
// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
const BOSTON_ID = 'boston-bluebikes';
const TORONTO_ID = 'toronto-bike-share';
const BUFFALO_ID = 'buffalo-reddy';
const LONDON = { lat: 51.5072, lon: -0.1276 };
const BOSTON = { lat: 42.3601, lon: -71.0589 };
const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 };
const BETWEEN = { lat: 43.25, lon: -79.1 };
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
const LOW_M = 10_000;
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
function makeSource() {
const cityOf = (url) => {
for (const [id, city] of CITY_BY_ID) {
if (url === city.stationInformationUrl) return { id, kind: 'info' };
if (url === city.stationStatusUrl) return { id, kind: 'status' };
}
throw new Error(`unexpected GBFS url ${url}`);
};
const source = {
dead: new Set(),
/** `${cityId}:${kind}` → deferred that gates the answer. */
gates: new Map(),
async getStations(url, { signal } = {}) {
const { id, kind } = cityOf(url);
const gate = source.gates.get(`${id}:${kind}`);
if (gate) {
await new Promise((resolve, reject) => {
gate.promise.then(resolve);
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
} else {
await new Promise((resolve) => setTimeout(resolve, 1));
}
signal?.throwIfAborted();
if (source.dead.has(id)) throw new Error('GBFS HTTP 503');
const city = CITY_BY_ID.get(id);
if (kind === 'info') {
return {
data: {
stations: [
{
station_id: `${id}-1`,
name: `${id} dock`,
lat: city.centerLat,
lon: city.centerLon,
capacity: 10,
},
],
},
};
}
return {
data: {
stations: [
{
station_id: `${id}-1`,
num_bikes_available: 3,
num_docks_available: 7,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
};
},
};
return source;
}
/** The row as the panel draws it: state chip, count, meta text. */
const rowText = (raw) => {
const stats = {
count: 0,
lastUpdate: null,
...raw,
loading: raw.loading === true,
};
const meta = LayerPanel.prototype._buildMetaText({
id: 'bikeshare',
source: 'GBFS',
enabled: true,
lifecycleState: 'enabled',
stats,
});
return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`;
};
function makeLiveLayer() {
const source = makeSource();
const services = {
render: { governorRequestRender() {} },
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.queries = { collectDetectableStations: () => [] };
parts.lifecycle = createLifecycle(context);
const { methods: controls } = createControls(context);
const camera = { ...LONDON, height: LOW_M };
const viewer = {
camera: {
get positionCartographic() {
return Cesium.Cartographic.fromDegrees(
camera.lon,
camera.lat,
camera.height,
);
},
computeViewRectangle: () => undefined,
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: { globe: {}, primitives: { add() {}, remove() {} } },
};
parts.lifecycle.methods.init(viewer);
const painted = [];
controls.attachDataManager({
refreshLayerStats() {
painted.push(rowText(controls.getStats()));
},
});
return {
state,
source,
painted,
lastPainted: () => painted.at(-1),
stats: () => controls.getStats(),
live: () => rowText(controls.getStats()),
moveTo(place, height = LOW_M) {
Object.assign(camera, place, { height });
return parts.viewport.runProximityCheck();
},
async enable() {
parts.lifecycle.methods.enable(viewer);
await settle();
},
};
}
async function settle() {
for (let i = 0; i < 5; i += 1)
await new Promise((resolve) => setTimeout(resolve, 2));
}
test('entering a city paints the loading row, not "never"', async () => {
const layer = makeLiveLayer();
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const before = layer.painted.length;
const entering = layer.moveTo(BOSTON);
assert.ok(layer.painted.length > before, 'entering repainted');
const atEntry = layer.lastPainted();
const liveAfter = layer.live();
info.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('re-entering a city with cached station info paints loading', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
await layer.moveTo(LONDON);
await settle();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
const entering = layer.moveTo(BOSTON);
const atEntry = layer.lastPainted();
await Promise.resolve();
await Promise.resolve();
const liveAfter = layer.live();
status.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('leaving a city mid-load does not leave the row on LOADING', async () => {
const layer = makeLiveLayer();
layer.source.gates.set(`${BOSTON_ID}:info`, deferred());
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.equal(layer.state._loading, true);
await layer.moveTo(LONDON);
await entering;
await settle();
assert.equal(layer.state._loading, false);
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('a camera nudge during the first load still repaints the drawn stations', async () => {
const layer = makeLiveLayer();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn');
await layer.moveTo(BOSTON_NUDGED);
status.resolve();
await entering;
await settle();
assert.equal(layer.state._count, 1, 'one station on the map');
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving a city whose load failed repaints the error away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BOSTON_ID);
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.match(layer.lastPainted(), /GBFS fetch error/i);
await layer.moveTo(LONDON);
await settle();
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving the failed city of two repaints DEGRADED away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO_ID);
await layer.enable();
await layer.moveTo(BETWEEN);
await settle();
assert.match(layer.lastPainted(), /^degraded/);
await layer.moveTo(TORONTO_ONLY);
await settle();
assert.doesNotMatch(layer.live(), /^degraded/);
assert.equal(layer.lastPainted(), layer.live());
});
test('above the altitude gate the row says how low to fly', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(LONDON, 2_000_000);
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
assert.equal(stats.error, undefined);
});
test('a city with no system says so and counts the ones there are', async () => {
const layer = makeLiveLayer();
await layer.enable();
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.statusMessage,
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
);
assert.equal(stats.count, 0);
});
test('a city in range, loading, failed or a disabled layer gets no prompt', async () => {
const layer = makeLiveLayer();
assert.equal(layer.stats().status, undefined, 'disabled');
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const entering = layer.moveTo(BOSTON);
assert.equal(layer.stats().status, undefined, 'loading');
info.resolve();
await entering;
await settle();
assert.equal(layer.stats().status, undefined, 'loaded');
// Left mid-load: range is empty, but the aborted load still owns the row.
const leaving = makeLiveLayer();
leaving.source.gates.set(`${BOSTON_ID}:info`, deferred());
await leaving.enable();
const loading = leaving.moveTo(BOSTON);
await settle();
const left = leaving.moveTo(LONDON);
assert.equal(leaving.state._activeCityIds.size, 0);
assert.equal(leaving.stats().loading, true);
assert.equal(leaving.stats().status, undefined, 'leaving mid-load');
await Promise.all([loading, left]);
const failed = makeLiveLayer();
failed.source.dead.add(BOSTON_ID);
await failed.enable();
await failed.moveTo(BOSTON);
await settle();
assert.equal(failed.stats().status, undefined, 'failed');
assert.equal(failed.stats().error, 'GBFS fetch error');
});
test('leaving every system behind drops the date of the data it drew', async () => {
for (const height of [LOW_M, 2_000_000]) {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.ok(layer.stats().lastUpdate, 'Boston is dated');
await layer.moveTo(LONDON, height);
assert.equal(layer.stats().lastUpdate, null, `at ${height} m`);
}
});
+4
View File
@@ -19,6 +19,10 @@ export function createState({ services }) {
layerState._viewer = null;
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */
layerState._dataManager = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
+25 -3
View File
@@ -116,6 +116,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
parts.rendering.removeCityPoints(cityId);
}
/** Repaint the row now: it follows the camera, the manager tick is 60 s. */
function refreshRow() {
layerState._dataManager?.refreshLayerStats?.('bikeshare');
}
/** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() {
@@ -125,6 +130,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
// A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear();
layerState._count = 0;
// Nothing is left on the map to date.
layerState._lastUpdate = null;
}
/**
@@ -203,9 +210,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer);
const wasGateEnabled = layerState._altitudeGateEnabled;
// Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities();
if (wasGateEnabled) refreshRow();
return;
}
@@ -220,15 +229,25 @@ export function createViewport({ state: layerState, services, parts, source }) {
}
}
// A failure never outlives its city's range.
let failureCleared = false;
for (const cityId of layerState._failedCityIds) {
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
if (!nextActive.has(cityId)) {
layerState._failedCityIds.delete(cityId);
failureCleared = true;
}
}
const changed =
!wasGateEnabled ||
failureCleared ||
nextActive.size !== layerState._activeCityIds.size ||
[...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId));
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
if (nextActive.size === 0) {
layerState._count = 0;
return;
layerState._lastUpdate = null;
}
if (changed) refreshRow();
// Only activate cities that don't already have rendered points
const toActivate = [];
@@ -240,6 +259,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)),
);
// Superseded loads repaint too: a newer check may have painted the row
// while this one was still loading, or found nothing to load itself.
if (layerState._enabled) refreshRow();
}
/** Schedule a debounced proximity check after camera movement. */
-2
View File
@@ -112,8 +112,6 @@ export function createCctvLayer({ services, source }) {
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop,
openingCameraId: parts.navigation.openingCameraId,
reopenNearestCamera: parts.navigation.reopenNearestCamera,
cctvCycleIndex: parts.navigation.cctvCycleIndex,
},
);
+6 -7
View File
@@ -151,7 +151,6 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
layerState._lastHopAt = 0;
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
@@ -300,8 +299,11 @@ export function createLifecycle({
}
layerState._count = layerState._records.length;
// No camera is picked here: enable() opens on the one nearest the
// viewer, unless restored params selected one first.
if (layerState._records.length > 0) {
// Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against
@@ -359,7 +361,6 @@ export function createLifecycle({
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
stopViewMotionTimer();
parts.navigation.reopenNearestCamera();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
@@ -507,9 +508,8 @@ export function createLifecycle({
return Boolean(coverage && layerState._recordById.has(coverage[1]));
});
if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = parts.navigation.openingCameraId();
layerState._activeCameraId = layerState._records[0].camera.id;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = true;
}
const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) {
@@ -629,7 +629,6 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// Clear existing subscribers rather than replacing the Set —
// replacing would silently orphan any unsubscribe() closures
layerState._listeners.clear();
-32
View File
@@ -33,36 +33,6 @@ export function createNavigation({
return best?.id || null;
}
/**
* The camera the layer opens on when none is selected: the one nearest the
* viewer, or the first record when the viewer has no position yet.
* @returns {string|null} Camera ID, or null when the catalog is empty.
*/
function openingCameraId() {
return (
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
);
}
/**
* After a settled move, moves the opening camera to the one nearest the
* view, until someone chooses a camera. A shared view's flight lands after
* enable() picked from the startup view.
* @returns {boolean} True when the active camera changed.
*/
function reopenNearestCamera() {
if (!layerState._openingCameraProvisional || !layerState._enabled)
return false;
const nearest = nearestCameraIdToViewer();
if (!nearest || nearest === layerState._activeCameraId) return false;
parts.selection.setActiveCamera(nearest);
// setActiveCamera ends the opening pick; this is still that pick.
layerState._openingCameraProvisional = true;
return true;
}
/**
* Flies the Cesium viewer camera to frame the specified CCTV camera,
* looking along its heading from above.
@@ -179,8 +149,6 @@ export function createNavigation({
}
return {
nearestCameraIdToViewer,
openingCameraId,
reopenNearestCamera,
focusCctvRecord,
focusCamera,
maybeAutoHop,
-2
View File
@@ -83,7 +83,6 @@ export function createSelection({
}
layerState._activeCameraId = cameraId;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// A real activation creates projection work — wake the self-stopping loop.
parts.projection.startProjectionLoop();
if (previousActiveRecord && previousActiveRecord !== record) {
@@ -170,7 +169,6 @@ export function createSelection({
if (!record) return false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = true;
layerState._openingCameraProvisional = false;
record.activationDone = false;
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
parts.geometry.applyFrustumGeometry(
-6
View File
@@ -36,12 +36,6 @@ export function createState({ services }) {
layerState._autoHop = false;
// True while the active camera is the one enable() opened on and nobody has
// chosen a camera since: each settled move then re-picks the one nearest
// the view, so a share flight that lands after enable still opens nearby.
layerState._openingCameraProvisional = false;
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
// timer from silently choosing a replacement. A later explicit activation or
// AUTO HOP toggle-on releases the hold.
-2
View File
@@ -91,7 +91,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
openingCameraProvisional = false,
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -105,7 +104,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
layerState._billboards = null;
layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = !!openingCameraProvisional;
layerState._enabled = !!enabled;
layerState._coverageMode = parts.model.normalizeCoverageMode(
coverageMode,
+3 -16
View File
@@ -21,20 +21,9 @@ export function createAnimation({
source,
}) {
function syncRenderDemand() {
// Hidden dots (`dots` off) need no frames. Lines need them only while
// their asynchronous geometry builds (or old lines wait to be retired),
// and the heat-line prototype's jam batch while it pulses.
const lines = [
layerState._heatJamPrim,
layerState._heatSlowPrim,
layerState._heatFreePrim,
].filter(Boolean);
const needed = Boolean(
layerState._enabled &&
((layerState._showDots !== false && layerState._dots.length) ||
(layerState._heatJamPrim && !layerState._showLines) ||
layerState._heatRetired?.length ||
lines.some((primitive) => !primitive.ready)),
(layerState._dots.length || layerState._heatJamPrim),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
@@ -235,7 +224,6 @@ export function createAnimation({
*/
function animate() {
parts.rendering.retireHeatLines?.();
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
@@ -329,9 +317,8 @@ export function createAnimation({
}
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
// the whole jam batch, no geometry rebuild. Null outside live heatline
// mode; the steady flow lines (`lines` toggle) never pulse.
if (layerState._heatJamPrim?.appearance && !layerState._showLines) {
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
if (layerState._heatJamPrim?.appearance) {
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
}
+20 -95
View File
@@ -1,7 +1,7 @@
import {
normalizeRoadMode,
resolveRoadMode,
roadModesSimulate,
TRAFFIC_ROAD_MODES,
} from './roadModes.js';
import {
trafficBucketTier,
@@ -19,25 +19,19 @@ export function createControls({ state: layerState, services, parts, source }) {
const notes = [feed.loadingLabel];
if (!layerState._liveMode || feed.error)
notes.push(t('Roads: {source}', { source }));
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap;
// a host without OpenStreetMap draws nothing.
const requested = roadModesSimulate(layerState._roadModes)
? layerState._roadMode
: resolveRoadMode(layerState._roadMode, true, layerState._roadModes);
// A host without OpenStreetMap waits for the probe before naming a key.
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
if (
!layerState._liveMode &&
['tomtom', 'hybrid'].includes(requested) &&
(roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown)
['tomtom', 'hybrid'].includes(layerState._roadMode)
)
notes.push(
layerState._flowStatusUnavailable
? requested === 'tomtom'
? layerState._roadMode === 'tomtom'
? t(
'TomTom roads unavailable while the traffic service is unreachable',
)
: t('Hybrid unavailable while the traffic service is unreachable')
: requested === 'tomtom'
: layerState._roadMode === 'tomtom'
? t('TomTom roads need a TomTom key')
: t('Hybrid needs a TomTom key'),
);
@@ -68,13 +62,10 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: 0,
/**
* Update user-adjustable parameters (road source, row toggles, density and
* speed scaling).
* Update user-adjustable parameters (road source, density and speed scaling).
*
* @param {Object} [params]
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored.
* @param {boolean} [params.lines] - Draw flow-colored road lines (live mode).
* @param {boolean} [params.dots] - Draw the moving dots.
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
* @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`.
@@ -84,8 +75,7 @@ export function createControls({ state: layerState, services, parts, source }) {
const previousSpeed = layerState._speedScale;
if (
Object.hasOwn(params, 'roadMode') &&
(params.roadMode === null ||
normalizeRoadMode(params.roadMode, layerState._roadModes))
(params.roadMode === null || normalizeRoadMode(params.roadMode))
) {
const explicit = ['user', 'voice', 'tool'].includes(origin);
const mode =
@@ -108,31 +98,6 @@ export function createControls({ state: layerState, services, parts, source }) {
parts.viewport.onCameraChanged({ immediate: true });
}
}
if (
typeof params.lines === 'boolean' &&
params.lines !== layerState._showLines
) {
layerState._showLines = params.lines;
// Rebuild in place from the roads on screen: no refetch, no respawn.
if (layerState._enabled)
parts.rendering.rebuildHeatLines(
parts.rendering.visibleRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
),
);
}
if (
typeof params.dots === 'boolean' &&
params.dots !== layerState._showDots
) {
layerState._showDots = params.dots;
// Hidden dots are kept, so showing them again is instant.
if (layerState._enabled && layerState._pointCollection)
layerState._pointCollection.show = params.dots;
parts.animation.syncRenderDemand();
layerState._viewer?.scene?.requestRender?.();
}
if (typeof params.densityScale === 'number') {
layerState._densityScale = Math.max(
0.2,
@@ -189,14 +154,11 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return the current user-adjustable parameters.
* @returns {{roadMode:string|null, lines:boolean, dots:boolean,
* densityScale:number, speedScale:number}}
* @returns {{densityScale:number, speedScale:number}}
*/
getParams() {
return {
roadMode: layerState._roadMode,
lines: layerState._showLines,
dots: layerState._showDots,
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
@@ -205,15 +167,10 @@ export function createControls({ state: layerState, services, parts, source }) {
};
},
/**
* Street Traffic row chips: a road-source chip per offered mode (none for
* a single mode), then Lines and Dots toggles while live flow colors them.
*/
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
getRowControls() {
const modes = layerState._roadModes;
const selected =
layerState._roadMode ||
resolveRoadMode(null, layerState._liveMode, modes);
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
const live = layerState._liveMode;
const titles = {
tomtom: live
@@ -230,36 +187,14 @@ export function createControls({ state: layerState, services, parts, source }) {
};
// TomTom and OSM are provider names and stay as they are.
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
const lines = {
id: 'lines',
label: t('Lines'),
title: t('Road lines colored by live flow: green to red'),
active: layerState._showLines,
params: { lines: !layerState._showLines },
};
const dots = {
id: 'dots',
label: t('Dots'),
title: live ? t('Moving dots colored by live flow') : t('Moving dots'),
active: layerState._showDots,
params: { dots: !layerState._showDots },
};
// Lines need live flow. Dots, once hidden (a live choice, a restore or
// a share link), stay offered so they can always be shown again.
const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots];
return {
chips: [
...(modes.length > 1
? modes.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
}))
: []),
...toggles,
],
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
})),
};
},
@@ -276,13 +211,7 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
*/
getDetectableObjects(options = {}) {
// Hidden dots are not on screen to detect.
if (
!layerState._enabled ||
!layerState._showDots ||
layerState._dots.length === 0
)
return [];
if (!layerState._enabled || layerState._dots.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._dots.length;
@@ -453,11 +382,7 @@ export function createControls({ state: layerState, services, parts, source }) {
// in one had better be the honest one. This is also where LIVE vs
// SIMULATED mode is surfaced, and it must never imply a live feed the
// layer does not have.
roadMode: resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
),
roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode),
roadModeRequested: layerState._roadMode,
roadSource: layerState._roadSource,
source: layerState._roadSource,
-326
View File
@@ -1,326 +0,0 @@
// A host's road-source offer (createTrafficSource `roadModes`) and the
// Street Traffic Lines / Dots row toggles.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
TRAFFIC_ROAD_MODES,
} from './roadModes.js';
import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js';
import {
createDefaultLayerState,
decodeLayerStateParams,
encodeLayerStateParams,
parseStoredLayerState,
serializeStoredLayerState,
} from '../../data/layerState.js';
const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 };
const flow = {
coords: [
[-97.744, 30.267],
[-97.742, 30.267],
],
trafficLevel: 0.3,
closure: false,
roadType: 'Major road',
};
function tomtomOnlySource() {
let requests = 0;
const source = createTrafficSource({
roadModes: ['tomtom'],
mapTiles: {
async fetchBounds() {
requests++;
return { tiles: [], partial: false };
},
async getMetadata() {
requests++;
},
clear() {},
},
});
return { source, requests: () => requests };
}
test('host road modes are a non-empty subset in canonical order', () => {
assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES);
assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [
'tomtom',
'hybrid',
]);
assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom'])));
for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null])
assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad));
assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError);
assert.equal(roadModesSimulate(), true);
assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false);
});
test('a TomTom-only host resolves every request to TomTom, keyed or not', () => {
for (const value of [null, 'osm', 'hybrid', 'tomtom'])
for (const hasKey of [true, false])
assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom');
// Without Hybrid the default is the first offered mode.
assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom');
assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm');
});
test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => {
const f = tomtomOnlySource();
assert.deepEqual(f.source.roadModes, ['tomtom']);
for (const roadMode of [null, 'osm', 'hybrid']) {
const data = await (
await f.source.requestRoads(box, {
roadMode,
flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }),
})
).json();
assert.equal(data.roadSource, 'TomTom', String(roadMode));
assert.equal(data.roads.length, 1);
assert.equal(data.roads[0].directFlow, true);
}
assert.equal(f.requests(), 0);
await assert.rejects(f.source.requestRoads(box, {}), TypeError);
});
test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => {
const f = tomtomOnlySource();
const failure = (snapshot) =>
f.source
.requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) })
.then(
() => assert.fail('a keyless load drew roads'),
(error) => error,
);
const keyless = await failure({ hasKey: false, segments: [] });
assert.equal(keyless.message, 'TomTom roads need a TomTom key');
assert.equal(keyless.retryable, false);
const unreachable = await failure({
hasKey: false,
segments: [],
statusUnavailable: true,
});
assert.equal(
unreachable.message,
'TomTom roads unavailable while the traffic service is unreachable',
);
assert.equal(unreachable.retryable, true);
const late = await failure({
hasKey: false,
segments: [],
error: 'TomTom flow timed out',
});
assert.equal(late.message, 'TomTom flow timed out');
assert.equal(f.requests(), 0);
});
test('a TomTom-only layer offers no road chips and ignores other road choices', () => {
const oldLocation = globalThis.location;
globalThis.location = { search: '?trafficRoads=osm' };
try {
const { source } = tomtomOnlySource();
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source,
});
assert.equal(layer.getParams().roadMode, null);
for (const origin of ['user', 'local-restore', 'share-restore'])
layer.setParams({ roadMode: 'osm' }, { origin });
assert.equal(layer.getParams().roadMode, null);
assert.deepEqual(layer.getRowControls().chips, []);
const stats = layer.getStats();
assert.equal(stats.roadMode, 'tomtom');
// No key verdict before the status probe settles.
assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/);
} finally {
if (oldLocation === undefined) delete globalThis.location;
else globalThis.location = oldLocation;
}
});
test('Lines and Dots are boolean params that only appear as chips with live flow', () => {
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source: tomtomOnlySource().source,
});
assert.equal(layer.getParams().lines, false);
assert.equal(layer.getParams().dots, true);
// Keyless (the status probe has not reported a key): nothing to color.
assert.deepEqual(layer.getRowControls().chips, []);
layer.setParams({ lines: true });
assert.equal(layer.getParams().lines, true);
layer.setParams({ lines: 'no', dots: 0 });
assert.equal(layer.getParams().lines, true, 'non-boolean ignored');
assert.equal(layer.getParams().dots, true, 'non-boolean ignored');
layer.setParams({ lines: false, dots: false });
assert.deepEqual(
[layer.getParams().lines, layer.getParams().dots],
[false, false],
);
assert.deepEqual(layer.getDetectableObjects(), []);
// Hidden dots stay recoverable without live flow (restore, share link).
assert.deepEqual(
layer.getRowControls().chips.map((chip) => [chip.id, chip.title]),
[['dots', 'Moving dots']],
);
layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' });
assert.equal(layer.getParams().dots, true);
assert.deepEqual(layer.getRowControls().chips, []);
});
test('live chips toggle each control without touching the other', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({
services: {},
source: { roadModes: ['tomtom'] },
});
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {
animation: { syncRenderDemand() {} },
rendering: {},
},
}).methods;
const chip = (id) =>
controls.getRowControls().chips.find((entry) => entry.id === id);
assert.deepEqual(
controls.getRowControls().chips.map((entry) => entry.id),
['lines', 'dots'],
);
assert.equal(chip('lines').active, false);
assert.equal(chip('dots').active, true);
controls.setParams(chip('lines').params, { origin: 'user' });
assert.equal(chip('lines').active, true);
assert.equal(chip('dots').active, true);
controls.setParams(chip('dots').params, { origin: 'user' });
assert.equal(chip('dots').active, false);
assert.equal(chip('lines').active, true);
// Every mode keeps its road chips beside the toggles.
const all = createState({ services: {}, source: {} });
all._liveMode = true;
assert.deepEqual(
createControls({
state: all,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
})
.methods.getRowControls()
.chips.map((entry) => entry.id),
['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'],
);
});
test('hiding dots hides the point collection of an enabled layer', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({ services: {}, source: {} });
let demand = 0;
state._enabled = true;
state._pointCollection = { show: true };
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: { animation: { syncRenderDemand: () => demand++ } },
}).methods;
controls.setParams({ dots: false });
assert.equal(state._pointCollection.show, false);
controls.setParams({ dots: true });
assert.equal(state._pointCollection.show, true);
assert.equal(demand, 2);
});
test('Lines and Dots persist in layer state and local storage', () => {
const state = createDefaultLayerState();
assert.equal(state.options.traffic.lines, false);
assert.equal(state.options.traffic.dots, true);
state.enabledLayerIds = ['traffic'];
state.options.traffic.lines = true;
state.options.traffic.dots = false;
const params = new URLSearchParams('v=2');
encodeLayerStateParams(params, state);
const decoded = decodeLayerStateParams(params).options.traffic;
assert.equal(decoded.lines, true);
assert.equal(decoded.dots, false);
const stored = parseStoredLayerState(serializeStoredLayerState(state)).options
.traffic;
assert.equal(stored.lines, true);
assert.equal(stored.dots, false);
});
test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => {
const { createRendering } = await import('./rendering.js');
const removed = [];
const state = {
_viewer: {
scene: { groundPrimitives: { remove: (p) => removed.push(p) } },
},
_liveMode: false,
_heatFreePrim: { ready: true, id: 'old' },
_heatRetired: [],
};
const parts = {
style: { heatlineOn: () => true },
animation: { syncRenderDemand() {} },
};
const rendering = createRendering({ state, services: {}, parts, source: {} });
// Nothing new to draw (not live): the old line goes at once.
rendering.rebuildHeatLines([]);
assert.deepEqual(
removed.map((p) => p.id),
['old'],
);
// A pending successor holds the retired line until it is ready.
const next = { ready: false, id: 'new' };
state._heatRetired = [{ id: 'older' }];
state._heatJamPrim = next;
rendering.retireHeatLines();
assert.equal(state._heatRetired.length, 1);
next.ready = true;
rendering.retireHeatLines();
assert.deepEqual(state._heatRetired, []);
assert.deepEqual(
removed.map((p) => p.id),
['old', 'older'],
);
});
test('lines hold frames only while building; hidden dots hold none', async () => {
const { createAnimation } = await import('./animation.js');
const calls = [];
const state = {
_enabled: true,
_dots: [{}],
_showDots: false,
_showLines: true,
_motion: { rebuilds: 0 },
};
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: () => calls.push('hold'),
releaseContinuousRender: () => calls.push('release'),
},
},
parts: {},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._heatFreePrim = { ready: false };
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold']);
state._heatFreePrim.ready = true;
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold', 'release']);
});
+1 -1
View File
@@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) {
].every((key) => typeof source?.[key] === 'function')
)
throw new TypeError('A traffic source is required');
const state = createState({ services, source });
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.style = createStyle(context);
+7 -35
View File
@@ -1,11 +1,6 @@
import * as Cesium from 'cesium';
import { requestWithDeadline } from '../../sources/requestDeadline.js';
import {
normalizeRoadMode,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
import { RoadRequestError, roadRequestError } from './source.js';
import {
@@ -299,14 +294,10 @@ export function createIngestion({
// settled; before that (a session's first load) the cache is skipped
// rather than waiting on the probe. Writes are decided by the snapshot.
const cacheMode =
normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm'
layerState._roadMode === 'osm'
? 'osm'
: layerState._flowStatusKnown
? resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
: null;
// Cache key: fixed-precision bounding-box string for deterministic lookups
const cacheKey =
@@ -387,7 +378,6 @@ export function createIngestion({
(segments) => ({
segments,
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
partial: source.getFlowSessionStats?.().partial,
}),
(error) => {
@@ -398,7 +388,6 @@ export function createIngestion({
return {
segments: [],
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
error: reason,
};
},
@@ -408,9 +397,7 @@ export function createIngestion({
// This uses the same shared XYZ cache and cap, with no extra ring.
if (
altitude <= FAST_FETCH_ALTITUDE &&
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom' &&
(roadModesSimulate(layerState._roadModes) || layerState._liveMode)
layerState._roadMode !== 'tomtom'
) {
source
.requestOsmRoads?.(clamped, {
@@ -424,19 +411,8 @@ export function createIngestion({
.catch(() => {});
}
requestSignal.throwIfAborted();
// Until the probe settles only OpenStreetMap roads can be drawn (or,
// for a host without them, the TomTom roads still to come).
layerState._roadSource =
ROAD_SOURCE_LABELS[
cacheMode ||
(roadModesSimulate(layerState._roadModes)
? 'osm'
: resolveRoadMode(
layerState._roadMode,
true,
layerState._roadModes,
))
];
// Until the probe settles only OpenStreetMap roads can be drawn.
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
let cache =
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
if (cache) {
@@ -605,11 +581,7 @@ export function createIngestion({
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadSource =
ROAD_SOURCE_LABELS[
resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
resolveRoadMode(layerState._roadMode, layerState._liveMode)
];
retryable = !isUnavailableCapability(e);
layerState._roadRetryStopped = !retryable;
+2 -8
View File
@@ -1,7 +1,6 @@
import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { resolveRoadMode } from './roadModes.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -82,15 +81,10 @@ export function createLifecycle({
if (layerState._enabled) return;
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
// OpenFreeMap metadata, unless only TomTom roads can be drawn.
if (
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom'
)
source.prefetch?.();
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = layerState._showDots;
layerState._pointCollection.show = true;
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
parts.animation.animate,
-17
View File
@@ -117,23 +117,6 @@ export const FLOW_BUCKET_COLORS = {
jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9),
};
// ─── Flow road lines (`lines` row toggle, live mode only) ────────────────
/**
* @const {number} Max flow-colored road lines per render. Jam first, then
* slow, then free-flow, longest first within a bucket, so a dense city keeps
* its congestion when the cap trims the free roads.
*/
export const FLOW_LINE_CAP = 1500;
/** @const {number} Px — flow road-line width (every bucket). */
export const FLOW_LINE_WIDTH = 4;
/** @const {number} Flow road-line opacity: the bucket colors, steady. */
export const FLOW_LINE_ALPHA = 0.8;
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
/** @const {number} Max congestion heat-line polylines per render (jam first). */
+61 -132
View File
@@ -2,10 +2,6 @@ import * as Cesium from 'cesium';
import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
FLOW_BUCKET_COLORS,
FLOW_LINE_ALPHA,
FLOW_LINE_CAP,
FLOW_LINE_WIDTH,
HEAT_LINE_CAP,
HEAT_JAM_COLOR,
HEAT_SLOW_COLOR,
@@ -38,81 +34,45 @@ export function createRendering({
: roads;
}
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
/** Detach the current line primitives, leaving them in the scene. */
function takeHeatLines() {
const taken = [];
for (const key of LINE_KEYS) {
if (layerState[key]) taken.push(layerState[key]);
layerState[key] = null;
}
layerState._heatLineCount = 0;
return taken;
}
function removePrimitives(primitives) {
for (const primitive of primitives)
layerState._viewer?.scene.groundPrimitives.remove(primitive);
}
/** Remove the heat-line / flow-line ground primitives from the scene. */
/** Remove both heat-line ground primitives from the scene. */
function removeHeatLines() {
removePrimitives(takeHeatLines());
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
}
/**
* Replaced lines stay drawn until their successors finish building
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
* blinks. Called every held frame.
*/
function retireHeatLines() {
if (!layerState._heatRetired?.length) return;
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
return;
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
}
/**
* Rebuild the road-line underlay drawn from live flow, in one of two looks:
*
* - Flow lines (the `lines` row toggle): every road with flow drapes a
* steady line in its bucket color — green free-flow, amber slow, red jam.
* Capped at FLOW_LINE_CAP.
* - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a
* glowing pulsing red for jam and a faint flat amber for slow. Capped at
* HEAT_LINE_CAP.
*
* One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch
* pulses through one shared material. Kept roads are jam first, then slow,
* then free, longest first; overflow is logged. Lines drape on 3D tiles when
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
* sim mode, when both looks are off, or without ground-primitive support.
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
* pulsing red for jam, faint flat amber for slow — with the dots animating
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
* first, longest first); overflow is logged. No-op in sim mode, when the
* heatline mode is off, or without ground-primitive support.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
const previous = takeHeatLines();
const finish = () => {
// Nothing new to wait for: the old lines go now.
if (LINE_KEYS.some((key) => layerState[key]))
(layerState._heatRetired ||= []).push(...previous);
else removePrimitives(previous);
parts.animation.syncRenderDemand?.();
};
removeHeatLines();
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return finish();
return;
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
@@ -122,25 +82,22 @@ export function createRendering({
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return finish();
if (!layerState._heatSupported) return;
const flowLines = layerState._showLines;
const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP;
const rank = { jam: 0, slow: 1, free: 2 };
const candidates = [];
for (const road of roads) {
const flow = road.flow;
if (!flow || flow.closure) continue;
const bucket = flowBucket(flow.level);
if (bucket === 'free' && !flowLines) continue;
if (bucket === 'free') continue;
let len = 0;
for (const d of road.segmentDist) len += d;
candidates.push({ road, bucket, len });
}
candidates.sort((a, b) =>
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1,
);
const kept = candidates.slice(0, cap);
const kept = candidates.slice(0, HEAT_LINE_CAP);
const instancesFor = (bucket, width) =>
kept
@@ -155,72 +112,51 @@ export function createRendering({
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
// luminance like the dots: jam = white, slow = gray, free = dim gray.
const mono =
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
// luminance like the dots: jam = white glow, slow = faint gray.
const monoHeat =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha);
const classificationType =
layerState._viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: flowLines
? Cesium.ClassificationType.BOTH
: Cesium.ClassificationType.CESIUM_3D_TILE;
const add = (instances, material) =>
instances.length
? layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
classificationType,
appearance: new Cesium.PolylineMaterialAppearance({ material }),
}),
)
: null;
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = monoHeat
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
// Lines take the dots' active (preset-aware) colors.
const dotColor = (bucket) =>
layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket];
if (flowLines) {
const lineColor = {
jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'),
slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'),
free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'),
};
for (const [bucket, key] of [
['free', '_heatFreePrim'],
['slow', '_heatSlowPrim'],
['jam', '_heatJamPrim'],
])
layerState[key] = add(
instancesFor(bucket, FLOW_LINE_WIDTH),
Cesium.Material.fromType('Color', {
color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA),
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: jamInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
}),
);
} else {
const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = mono
? gray(0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
layerState._heatJamPrim = add(
instancesFor('jam', HEAT_LINE_JAM_WIDTH),
Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
);
layerState._heatSlowPrim = add(
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
Cesium.Material.fromType('Color', { color: slowLineColor }),
}
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
if (slowInstances.length) {
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: slowInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('Color', {
color: slowLineColor,
}),
}),
}),
);
}
layerState._heatLineCount = kept.length;
finish();
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
);
}
}
@@ -334,13 +270,7 @@ export function createRendering({
renderMetrics,
)
: null;
// The flow recolor above already rebuilt the lines from every retained
// road; without dots to recolor, rebuild them here.
if (
!label.includes('tile') &&
!(layerState._liveMode && layerState._dots.length)
)
rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude));
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
@@ -384,7 +314,6 @@ export function createRendering({
return {
visibleRoadsForAltitude,
removeHeatLines,
retireHeatLines,
rebuildHeatLines,
renderRoadsForAltitude,
};
+5 -40
View File
@@ -46,59 +46,24 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
/**
* Validate a host's road-source list (`createTrafficSource({ roadModes })`):
* a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order.
* @param {*} value - Candidate list; undefined means every mode.
* @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>}
*/
export function normalizeRoadModes(value) {
if (value === undefined) return TRAFFIC_ROAD_MODES;
const modes = Array.isArray(value)
? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode))
: [];
if (!modes.length || modes.length !== value.length)
throw new TypeError(
`Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`,
);
return Object.freeze(modes);
}
/**
* Whether a host's modes allow simulated roads. Without OpenStreetMap the
* layer never simulates: keyless, it draws no roads and says why.
* @param {ReadonlyArray<string>} [modes]
* @returns {boolean}
*/
export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) {
return modes.includes('osm');
}
/**
* Validate an explicit choice.
* @param {*} value - Candidate mode.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default".
*/
export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) {
return modes.includes(value) ? value : null;
export function normalizeRoadMode(value) {
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
}
/**
* Resolve the mode actually drawn. Without a TomTom key every choice draws
* OpenStreetMap roads when the host offers them; with one, an explicit
* choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps
* its TomTom choice keyless (nothing is drawn; the status names the reason),
* and a host without Hybrid defaults to its first offered mode.
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
* @param {*} value - Requested mode (null for the default).
* @param {boolean} hasKey - The server reported a TomTom key.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'}
*/
export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) {
const chosen = normalizeRoadMode(value, modes);
if (!hasKey && roadModesSimulate(modes)) return 'osm';
return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]);
export function resolveRoadMode(value, hasKey) {
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
}
const FLOW_ROAD_TYPES = Object.freeze({
+1 -42
View File
@@ -74,23 +74,7 @@ function roadChips(liveMode) {
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods
.getRowControls()
.chips.filter((chip) => chip.id.startsWith('roads-'));
}
function toggleChips() {
const state = createState({ services: {} });
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods
.getRowControls()
.chips.filter((chip) => !chip.id.startsWith('roads-'));
return controls.methods.getRowControls().chips;
}
test('en: road-source chips keep their English labels and titles', () => {
@@ -140,28 +124,3 @@ for (const [locale, hybrid] of [
}
});
}
test('en: the Lines and Dots toggles keep their English labels and titles', () => {
setLocale('en');
assert.deepEqual(
toggleChips().map((chip) => [chip.id, chip.label, chip.title]),
[
['lines', 'Lines', 'Road lines colored by live flow: green to red'],
['dots', 'Dots', 'Moving dots colored by live flow'],
],
);
});
for (const locale of ['ru', 'es', 'ja']) {
test(`${locale}: the Lines and Dots toggles are translated`, () => {
setLocale(locale);
try {
for (const chip of toggleChips()) {
assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale);
assert.doesNotMatch(chip.title, /colored by live flow/, locale);
}
} finally {
setLocale('en');
}
});
}
+7 -50
View File
@@ -1,10 +1,7 @@
import {
createHybridFill,
flowSegmentsToRoads,
normalizeRoadMode,
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
@@ -83,15 +80,7 @@ export function roadRequestError(status, cause) {
});
}
/**
* Supply tile-derived road geometry and flow availability without Overpass queries.
*
* `roadModes` is the host's offer of road sources (default: all three). The
* layer shows a chip per offered mode (none for a single mode) and never
* draws one outside it. Without `osm` nothing is simulated: keyless, or with
* the traffic service unreachable, requests fail with the reason instead of
* drawing white OpenStreetMap dots.
*/
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
@@ -99,13 +88,10 @@ export function createTrafficSource({
fetchImpl: tileFetchImpl,
selectDecoded: ({ roads }) => ({ roads }),
}),
roadModes,
} = {}) {
const modes = normalizeRoadModes(roadModes);
const flow = createFlowTileSource({ fetchImpl });
const api = {
...flow,
roadModes: modes,
prefetch: () => mapTiles.getMetadata().catch(() => {}),
resetFlowTileCache() {
flow.resetFlowTileCache();
@@ -261,8 +247,8 @@ export function createTrafficSource({
*
* @param {{south:number, west:number, north:number, east:number}} box
* @param {Object} [options]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
* @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline.
* @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait.
* @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets.
@@ -283,16 +269,7 @@ export function createTrafficSource({
box.east - box.west > 10
)
throw new TypeError('A bounded road viewport is required');
roadMode = normalizeRoadMode(roadMode, modes);
if (!roadModesSimulate(modes) && !flowSnapshot)
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
if (
roadMode === 'osm' ||
!flowSnapshot ||
(modes.length === 1 && modes[0] === 'osm')
)
if (roadMode === 'osm' || !flowSnapshot)
return api.requestOsmRoads(box, { ...options, onTile });
const signal = options.signal;
const osm = [];
@@ -355,13 +332,7 @@ export function createTrafficSource({
osmError = error;
}
};
// A host without OpenStreetMap waits for the key before fetching fill.
const osmJob =
roadMode === 'tomtom' ||
modes.every((entry) => entry === 'tomtom') ||
!roadModesSimulate(modes)
? null
: loadOsm();
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
osmJob?.catch(() => {});
// The snapshot waits on the status probe and flow tiles; never let it
// hold the road pass past its own deadline. A late snapshot counts as a
@@ -386,21 +357,7 @@ export function createTrafficSource({
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
mode = resolveRoadMode(roadMode, live.hasKey, modes);
// A host without OpenStreetMap never simulates: name why no roads draw.
if (!live.hasKey && !roadModesSimulate(modes))
throw Object.assign(
new RoadRequestError(
live.error ||
(live.statusUnavailable
? msg(
'TomTom roads unavailable while the traffic service is unreachable',
)
: msg('TomTom roads need a TomTom key')),
),
// A known missing key cannot recover this session; the rest can.
{ retryable: Boolean(live.error || live.statusUnavailable) },
);
mode = resolveRoadMode(roadMode, live.hasKey);
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
if (mode !== 'osm') {
tomtom = flowSegmentsToRoads(live.segments);
@@ -408,7 +365,7 @@ export function createTrafficSource({
publish(composed(), true);
}
if (osmJob) await osmJob;
else if (mode !== 'tomtom') await loadOsm();
else if (mode === 'osm') await loadOsm();
signal?.throwIfAborted();
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
const data = snapshot(composed(), true);
+2 -21
View File
@@ -1,17 +1,14 @@
import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js';
import { normalizeRoadMode } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS } from './policy.js';
export function createState({ services, source }) {
export function createState({ services }) {
const layerState = {};
/** The host's offered road sources (createTrafficSource `roadModes`). */
layerState._roadModes = normalizeRoadModes(source?.roadModes);
layerState._roadMode =
typeof location === 'undefined'
? null
: normalizeRoadMode(
new URLSearchParams(location.search).get('trafficRoads'),
layerState._roadModes,
);
// The query override is consumed at construction and shields passive restoration.
layerState._roadModeUrlOverride = layerState._roadMode;
@@ -170,14 +167,6 @@ export function createState({ services, source }) {
layerState._jamViz = 'density';
/**
* Row toggles (`lines` / `dots` params): flow-colored road lines draped on
* the surface, and the moving dots. Live mode only for lines — the keyless
* simulation has no flow to color them with.
*/
layerState._showLines = false;
layerState._showDots = true;
/**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One
@@ -192,14 +181,6 @@ export function createState({ services, source }) {
layerState._heatSlowPrim = null;
/** @type {Cesium.GroundPolylinePrimitive|null} Free-flow road-line batch (`lines` only). */
layerState._heatFreePrim = null;
/** @type {Cesium.GroundPolylinePrimitive[]} Replaced lines kept until successors are ready. */
layerState._heatRetired = [];
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0;
+2 -6
View File
@@ -137,14 +137,10 @@ export function createStyle({ state: layerState, services, parts, source }) {
return layerState._jamViz === 'density' || layerState._jamViz === 'both';
}
/** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */
/** @returns {boolean} Heat-line prototype active. */
function heatlineOn() {
return (
layerState._showLines ||
layerState._jamViz === 'heatline' ||
layerState._jamViz === 'both'
);
return layerState._jamViz === 'heatline' || layerState._jamViz === 'both';
}
return {
presetProfileActive,
+51
View File
@@ -3982,3 +3982,54 @@ test('parked transit reprojects held and reported courses at the bounded camera
'selected unknown course also stays screen-up',
);
});
test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => {
const app = harness(t);
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot');
// London has no registered feed: the row prompts instead of dating Boston's
// last snapshot over an empty map.
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
app.advance(1_000);
const away = app.layer.getStats();
assert.equal(away.status, 'zoom-in');
assert.equal(away.count, 0);
assert.equal(away.lastUpdate, null);
assert.match(
away.statusMessage,
/^No feed here yet · \d+ regions available$/,
);
assert.equal(away.coverage, away.statusMessage);
// Above the altitude gate the prompt says how low to fly.
app.viewer.camera.positionCartographic.height = 10_000_000;
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
app.advance(1_000);
assert.equal(
app.layer.getStats().statusMessage,
'Fly below 2,850 km to a covered region',
);
});
test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) => {
// 2,900 km is under ACTIVATION_ALTITUDE_M but above the enter threshold.
const app = harness(t, { altitude: 2_900_000 });
app.layer.enable(app.viewer);
await app.layer.update();
assert.deepEqual(app.requested, [], 'nothing loads over Boston');
assert.equal(
app.layer.getStats().statusMessage,
'Fly below 2,850 km to a covered region',
);
});
+8 -6
View File
@@ -1,5 +1,5 @@
import {
ACTIVATION_ALTITUDE_M,
ACTIVATION_ENTER_ALTITUDE_M,
FEED_STALE_AFTER_MS,
aggregateTransitFeedHealth,
transitDetectionClass,
@@ -245,17 +245,19 @@ export function createQueries({ state, parts }) {
const health = aggregateTransitFeedHealth(statuses, now);
const count = state._vehicles.size;
if (state._enabled && active.length === 0) {
// English: the analyst's empty note and the row's prompt, both shown
// through translateStatusMessage (ui/statusText.js).
const note = state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`;
return {
count: 0,
lastUpdate: state._lastUpdate,
error: null,
source: 'GTFS-RT',
status: 'zoom-in',
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
statusMessage: note,
coverage: note,
};
}
// Age is shown by the same rule the row state uses, so the text and the
@@ -0,0 +1,100 @@
// Transit: a manager tick that failed over Boston must not keep its error on
// the row the coverage repaint draws after the camera leaves for London.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createViewport } from './viewport.js';
import { createQueries } from './queries.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
const rad = Math.PI / 180;
const LONDON = { lat: 51.5072, lon: -0.1276 };
const BOSTON = { lat: 42.3601, lon: -71.0589 };
function viewer(at, height) {
return {
camera: {
positionCartographic: {
height,
latitude: at.lat * rad,
longitude: at.lon * rad,
},
computeViewRectangle: () => undefined,
},
scene: { globe: {} },
};
}
test('leaving a feed whose tick failed repaints its error away', async () => {
const services = {
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
render: { governorRequestRender() {} },
};
const state = createState({ services });
const feedStatus = (feedId) => {
if (!state._feedStatus.has(feedId))
state._feedStatus.set(feedId, { count: 0, error: null, loading: false });
return state._feedStatus.get(feedId);
};
const parts = {
ingestion: {
feedStatus,
// As pollFeed's catch does when the proxy answers 503.
pollFeed(feed) {
Object.assign(feedStatus(feed.id), {
error: `${feed.name} feed unavailable`,
loading: false,
});
return Promise.resolve();
},
removeFeedVehicles() {},
sweepAgedVehicles() {},
},
rendering: { requestVisibility() {}, syncRenderHold() {} },
};
parts.viewport = createViewport({ state, services, parts });
const queries = createQueries({ state, parts }).methods;
state._enabled = true;
const lifecycle = new LayerLifecycle(null);
lifecycle.register({
id: 'transit',
name: 'Transit',
source: 'GTFS-RT',
getStats: () => queries.getStats(),
async update() {},
});
const entry = lifecycle.layers.get('transit');
entry.enabled = true;
entry.lifecycleState = 'enabled';
entry.initialized = true;
const row = () => {
const layer = lifecycle.getAll()[0];
return `${layerFeedState(layer.stats)} | ${LayerPanel.prototype._buildMetaText(layer)}`;
};
const painted = [];
state._dataManager = {
refreshLayerStats(layerId) {
lifecycle.refreshLayerStats(layerId);
painted.push(row());
},
};
state._viewer = viewer(BOSTON, 4_000);
parts.viewport.runProximityCheck();
await lifecycle.refreshLayer('transit');
assert.match(row(), /feed unavailable/, 'the tick recorded the failure');
const before = painted.length;
state._viewer = viewer(LONDON, 4_000);
parts.viewport.runProximityCheck();
assert.equal(queries.getStats().status, 'zoom-in');
assert.ok(painted.length > before, 'leaving Boston repainted the row');
assert.doesNotMatch(painted.at(-1), /feed unavailable/);
});
+1 -1
View File
@@ -112,7 +112,7 @@ export function createState({ services }) {
state._cockpitVision = false;
/** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */
state._styleListeners = [];
/** @type {{ refreshLayerStats?: () => void }|null} */
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */
state._dataManager = null;
/** @type {number|null} */
state._lastUpdate = null;
+8
View File
@@ -83,7 +83,9 @@ export function createViewport({ state, services, parts }) {
if (!state._enabled || !state._viewer) return;
refreshViewBounds();
const altitude = getCameraAltitude();
const wasGateOpen = state._altitudeGateOpen;
state._altitudeGateOpen = altitudeGateOpen(altitude);
let changed = state._altitudeGateOpen !== wasGateOpen;
const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null;
const desired = new Map();
if (center) {
@@ -106,17 +108,23 @@ export function createViewport({ state, services, parts }) {
state._activeFeeds.delete(feedId);
state._feedStatus.delete(feedId);
parts.ingestion.removeFeedVehicles(feedId);
changed = true;
}
}
// Nothing is left on the map to date.
if (desired.size === 0) state._lastUpdate = null;
for (const [feedId, feed] of desired) {
if (!state._activeFeeds.has(feedId)) {
state._activeFeeds.set(feedId, feed);
void parts.ingestion.pollFeed(feed, state._generation);
changed = true;
}
}
parts.ingestion.sweepAgedVehicles(Date.now());
parts.rendering.syncRenderHold();
governorRequestRender('transit-proximity');
// Repaint now: the row follows the camera, the manager tick is 15 s.
if (changed) state._dataManager?.refreshLayerStats?.('transit');
}
function onCameraChanged() {
+1 -1
View File
@@ -253,7 +253,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null, lines: false, dots: true }),
traffic: Object.freeze({ roadMode: null }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
'weather-satellite': Object.freeze({
+1 -31
View File
@@ -3,10 +3,7 @@ import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
import {
chatSchema,
createOpenAiChatHandler,
} from '../server/providers/openai/chat.js';
import { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import {
realtimeDirectives,
@@ -537,11 +534,6 @@ for (const [name, provider] of Object.entries(providers)) {
assert.equal(offered.includes('osm_query'), overpass);
assert.equal(prompt.includes('osm_query'), overpass);
assert.ok(offered.includes('resolve_area'));
// Chat Completions answers 400 invalid_function_parameters for a
// pattern with Unicode property classes, as resolve_area's area ids
// use; OpenAI chat leaves those patterns out.
if (name === 'openai')
assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/);
}
});
test(`${name} throttled replies say they are rate limited`, async (t) => {
@@ -705,25 +697,3 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t
const body = await (await f.send()).json();
assert.equal(body.content.tool_calls, undefined);
});
test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => {
assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/);
assert.deepEqual(
chatSchema({
type: 'object',
properties: {
id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{ pattern: '\\P{N}' }] },
},
}),
{
type: 'object',
properties: {
id: { type: 'string', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{}] },
},
},
);
});
+6 -3
View File
@@ -11,14 +11,17 @@ import {
} from './layerNames.js';
export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js';
import { translateFeedError, translateStatusLine } from './statusText.js';
import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
translateKnown,
} from '../i18n/index.js';
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
@@ -695,7 +698,7 @@ export class LayerPanel {
typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim()
) {
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) {
+54
View File
@@ -452,3 +452,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie
restore();
}
});
test('a layer with no feed in view says why instead of "never"', async () => {
const { LayerPanel, presentLayerCount, presentLayerToggle } =
await import('./layerPanel.js');
const { setLocale } = await import('../i18n/index.js');
const panel = LayerPanel.prototype;
const rows = [
{
id: 'transit',
name: 'Transit',
source: 'GTFS-RT',
enabled: true,
stats: {
count: 0,
lastUpdate: null,
source: 'GTFS-RT',
status: 'zoom-in',
statusMessage: 'No feed here yet · 16 regions available',
},
meta: 'GTFS-RT · No feed here yet · 16 regions available',
},
{
id: 'bikeshare',
name: 'Bikeshare',
source: 'GBFS',
enabled: true,
stats: {
count: 0,
lastUpdate: null,
loading: false,
status: 'zoom-in',
statusMessage: 'Fly below 48 km to a covered city',
},
meta: 'GBFS · Fly below 48 km to a covered city',
},
];
for (const layer of rows) {
// Guidance is normal operation: the layer is on and nothing has failed.
assert.equal(presentLayerToggle(layer).text, 'ON');
assert.equal(presentLayerCount(layer.stats).empty, true);
assert.equal(panel._buildMetaText(layer), layer.meta);
}
setLocale('ru');
try {
for (const layer of rows) {
const meta = panel._buildMetaText(layer);
assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta);
assert.match(meta, /^(GTFS-RT|GBFS) · /, meta);
}
} finally {
setLocale('en');
}
});
+61
View File
@@ -265,3 +265,64 @@ export function translateStatusLine(text) {
.map(translateStatusPiece)
.join(STATUS_SEPARATOR);
}
/** Thousands separators out of an English number ("3,000" → 3000). */
const englishNumber = (text) => Number(text.replace(/,/g, ''));
// Coverage notes (transit/queries.js, bikeshare/controls.js). Their " · " is
// inside one sentence, so they match whole before the line is split.
const COVERAGE_NOTE_PATTERNS = [
[
/^Fly below ([\d,]+) km to a covered region$/,
([, km]) =>
t('Fly below {km} km to a covered region', {
km: formatNumber(englishNumber(km)),
}),
],
[
/^No feed here yet · ([\d,]+) regions available$/,
([, regions]) => {
const count = englishNumber(regions);
return tn(
count,
'No feed here yet · {n} region available',
'No feed here yet · {n} regions available',
{ n: formatNumber(count) },
);
},
],
[
/^Fly below ([\d,]+) km to a covered city$/,
([, km]) =>
t('Fly below {km} km to a covered city', {
km: formatNumber(englishNumber(km)),
}),
],
[
/^No bike share here yet · ([\d,]+) systems available$/,
([, systems]) => {
const count = englishNumber(systems);
return tn(
count,
'No bike share here yet · {n} system available',
'No bike share here yet · {n} systems available',
{ n: formatNumber(count) },
);
},
],
];
/**
* A layer's status message in the UI language: its catalog entry, a coverage
* note by its shape, or a status line translated piece by piece.
*/
export function translateStatusMessage(text) {
if (typeof text !== 'string') return text;
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return translateStatusLine(text);
}
+57 -1
View File
@@ -3,7 +3,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../i18n/index.js';
import { translateFeedError, translateStatusLine } from './statusText.js';
import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
function inLocale(locale, run) {
setLocale(locale);
@@ -75,3 +79,55 @@ for (const locale of ['es', 'ru']) {
});
});
}
// A layer's coverage note when no feed covers the view: one sentence with a
// " · " in it (transit/queries.js, bikeshare/controls.js).
const COVERAGE_NOTES = [
'Fly below 2,850 km to a covered region',
'No feed here yet · 16 regions available',
'Fly below 48 km to a covered city',
'No bike share here yet · 61 systems available',
];
test('en: coverage notes show as they came', () => {
setLocale('en');
for (const text of COVERAGE_NOTES)
assert.equal(translateStatusMessage(text), text);
assert.equal(
translateStatusMessage('No bike share here yet · 1 systems available'),
'No bike share here yet · 1 system available',
);
});
for (const locale of ['es', 'ru', 'ja']) {
test(`${locale}: a coverage note translates whole, numbers kept`, () => {
inLocale(locale, () => {
for (const text of COVERAGE_NOTES) {
const shown = translateStatusMessage(text);
assert.doesNotMatch(
shown,
/\b(Fly|below|covered|feed|bike share|available)\b/,
shown,
);
const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0];
assert.ok(
shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')),
shown,
);
}
});
});
test(`${locale}: a status message without a coverage note keeps its other forms`, () => {
inLocale(locale, () => {
assert.equal(
translateStatusMessage('Upstream response too large'),
t('Upstream response too large'),
);
assert.equal(
translateStatusMessage('2 feeds live · 14 heard'),
translateStatusLine('2 feeds live · 14 heard'),
);
});
});
}
+1 -1
View File
@@ -70,7 +70,7 @@ const cases = [
// Transit's coverage text when nothing is in view (transit/queries.js).
const transitCoverage = [
'transit: Fly below 3,000 km to a covered region',
'transit: Fly below 2,850 km to a covered region',
'transit: No feed here yet · 12 regions available',
];
+5 -26
View File
@@ -29,7 +29,7 @@ import {
} from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { translateStatusLine } from '../ui/statusText.js';
import { translateStatusMessage } from '../ui/statusText.js';
import { formatQuantity } from '../ui/unitLabels.js';
/** Split "a; b" caveat text into separate notes. */
@@ -397,27 +397,6 @@ const NOTE_PATTERNS = [
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period,
],
// Transit's empty note when no feed covers the view (transit/queries.js);
// the model gets the English.
[
/^Fly below ([\d,]+) km to a covered region$/,
([, km]) =>
t('Fly below {km} km to a covered region', {
km: formatNumber(Number(km.replace(/,/g, ''))),
}),
],
[
/^No feed here yet · ([\d,]+) regions available$/,
([, regions]) => {
const count = Number(regions.replace(/,/g, ''));
return tn(
count,
'No feed here yet · {n} region available',
'No feed here yet · {n} regions available',
{ n: formatNumber(count) },
);
},
],
[
/^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) =>
@@ -444,10 +423,10 @@ function shownNote(text) {
const shown = match ? show(match) : null;
if (shown) return shown;
}
// A layer's empty note can be its status line, English pieces joined by
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
const known = translateKnown(text);
return known !== text ? known : translateStatusLine(text);
// A layer's empty note can be its coverage note ("Fly below 2,850 km to a
// covered region") or its status line, English pieces joined by " · "
// ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
return translateStatusMessage(text);
}
/** Feed states as the card's chips show them; the values stay English. */