mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cac463c6f | ||
|
|
479286a6a6 | ||
|
|
c0a5d4141d | ||
|
|
8ec92eb48c | ||
|
|
5e42a83d88 | ||
|
|
6e4da23cf7 | ||
|
|
574430fb1d | ||
|
|
9c8bb206ef |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- 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).
|
||||
+21
-2
@@ -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 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.
|
||||
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.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
@@ -6437,7 +6437,26 @@ 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. With no stored choice
|
||||
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
|
||||
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
|
||||
|
||||
@@ -64,6 +64,30 @@ 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,
|
||||
@@ -126,7 +150,12 @@ export function createOpenAiChatHandler({
|
||||
tools: offeredTools(tools, { overpass }).map(
|
||||
({ name, description, parameters }) => ({
|
||||
type: 'function',
|
||||
function: { name, description, parameters, strict: false },
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: chatSchema(parameters),
|
||||
strict: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -67,6 +67,8 @@ 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,
|
||||
|
||||
@@ -61,6 +61,8 @@ import cctvLayer, {
|
||||
materializeCctvActiveCoverageEntities,
|
||||
materializeCctvVisibleCoverageEntities,
|
||||
maybeAutoHop,
|
||||
openingCameraId,
|
||||
reopenNearestCamera,
|
||||
prioritizeActiveCctvGeometryRecord,
|
||||
processCctvGeometryDrainBatch,
|
||||
processCctvGeometryQueueBatch,
|
||||
@@ -1296,6 +1298,120 @@ 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({
|
||||
|
||||
@@ -335,6 +335,9 @@ 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'], {
|
||||
|
||||
@@ -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 }, source: {} });
|
||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
|
||||
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
@@ -1749,6 +1749,11 @@
|
||||
"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": "تم استهلاك حصة حركة المرور الشهرية",
|
||||
|
||||
@@ -1667,6 +1667,11 @@
|
||||
"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",
|
||||
|
||||
@@ -2699,6 +2699,11 @@
|
||||
"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",
|
||||
|
||||
@@ -1103,6 +1103,11 @@
|
||||
"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é",
|
||||
|
||||
@@ -1103,6 +1103,11 @@
|
||||
"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",
|
||||
|
||||
@@ -1071,6 +1071,11 @@
|
||||
"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": "今月の交通情報の上限に達しました",
|
||||
|
||||
@@ -1101,6 +1101,11 @@
|
||||
"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",
|
||||
|
||||
@@ -2757,6 +2757,11 @@
|
||||
"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 сопоставляется, где он есть",
|
||||
|
||||
@@ -1118,6 +1118,11 @@
|
||||
"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": "Місячний ліміт даних про трафік вичерпано",
|
||||
|
||||
@@ -1047,6 +1047,11 @@
|
||||
"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": "本月交通流量额度已用完",
|
||||
|
||||
@@ -1042,6 +1042,11 @@
|
||||
"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": "本月交通流量額度已用完",
|
||||
|
||||
@@ -112,6 +112,8 @@ 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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,6 +151,7 @@ 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();
|
||||
@@ -299,11 +300,8 @@ export function createLifecycle({
|
||||
}
|
||||
|
||||
layerState._count = layerState._records.length;
|
||||
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;
|
||||
}
|
||||
// No camera is picked here: enable() opens on the one nearest the
|
||||
// viewer, unless restored params selected one first.
|
||||
|
||||
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||
@@ -361,6 +359,7 @@ export function createLifecycle({
|
||||
layerState._horizonCullListener = () => {
|
||||
layerState._cameraMoving = false;
|
||||
stopViewMotionTimer();
|
||||
parts.navigation.reopenNearestCamera();
|
||||
settleView();
|
||||
};
|
||||
layerState._viewer.camera.moveEnd.addEventListener(
|
||||
@@ -508,8 +507,9 @@ export function createLifecycle({
|
||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
||||
});
|
||||
if (!layerState._activeCameraId && layerState._records.length) {
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
layerState._activeCameraId = parts.navigation.openingCameraId();
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = true;
|
||||
}
|
||||
const activeRecord = parts.selection.getActiveRecord();
|
||||
if (activeRecord) {
|
||||
@@ -629,6 +629,7 @@ 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();
|
||||
|
||||
@@ -33,6 +33,36 @@ 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.
|
||||
@@ -149,6 +179,8 @@ export function createNavigation({
|
||||
}
|
||||
return {
|
||||
nearestCameraIdToViewer,
|
||||
openingCameraId,
|
||||
reopenNearestCamera,
|
||||
focusCctvRecord,
|
||||
focusCamera,
|
||||
maybeAutoHop,
|
||||
|
||||
@@ -83,6 +83,7 @@ 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) {
|
||||
@@ -169,6 +170,7 @@ 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(
|
||||
|
||||
@@ -36,6 +36,12 @@ 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.
|
||||
|
||||
@@ -91,6 +91,7 @@ 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 : [];
|
||||
@@ -104,6 +105,7 @@ 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,
|
||||
|
||||
@@ -21,9 +21,20 @@ 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._dots.length || layerState._heatJamPrim),
|
||||
((layerState._showDots !== false && layerState._dots.length) ||
|
||||
(layerState._heatJamPrim && !layerState._showLines) ||
|
||||
layerState._heatRetired?.length ||
|
||||
lines.some((primitive) => !primitive.ready)),
|
||||
);
|
||||
// Height easing only runs in a held frame. Settle it before releasing, so
|
||||
// a population emptied mid-correction cannot report loading forever.
|
||||
@@ -224,6 +235,7 @@ export function createAnimation({
|
||||
*/
|
||||
|
||||
function animate() {
|
||||
parts.rendering.retireHeatLines?.();
|
||||
syncRenderDemand();
|
||||
if (!layerState._renderHeld) return;
|
||||
const now = Date.now();
|
||||
@@ -317,8 +329,9 @@ 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.
|
||||
if (layerState._heatJamPrim?.appearance) {
|
||||
// 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) {
|
||||
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
|
||||
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
|
||||
}
|
||||
|
||||
Vendored
+95
-20
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
normalizeRoadMode,
|
||||
resolveRoadMode,
|
||||
TRAFFIC_ROAD_MODES,
|
||||
roadModesSimulate,
|
||||
} from './roadModes.js';
|
||||
import {
|
||||
trafficBucketTier,
|
||||
@@ -19,19 +19,25 @@ 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.
|
||||
// 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.
|
||||
if (
|
||||
!layerState._liveMode &&
|
||||
['tomtom', 'hybrid'].includes(layerState._roadMode)
|
||||
['tomtom', 'hybrid'].includes(requested) &&
|
||||
(roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown)
|
||||
)
|
||||
notes.push(
|
||||
layerState._flowStatusUnavailable
|
||||
? layerState._roadMode === 'tomtom'
|
||||
? requested === 'tomtom'
|
||||
? t(
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
)
|
||||
: t('Hybrid unavailable while the traffic service is unreachable')
|
||||
: layerState._roadMode === 'tomtom'
|
||||
: requested === 'tomtom'
|
||||
? t('TomTom roads need a TomTom key')
|
||||
: t('Hybrid needs a TomTom key'),
|
||||
);
|
||||
@@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
updateInterval: 0,
|
||||
|
||||
/**
|
||||
* Update user-adjustable parameters (road source, density and speed scaling).
|
||||
* Update user-adjustable parameters (road source, row toggles, density and
|
||||
* speed scaling).
|
||||
*
|
||||
* @param {Object} [params]
|
||||
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
|
||||
* @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 {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=`.
|
||||
@@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const previousSpeed = layerState._speedScale;
|
||||
if (
|
||||
Object.hasOwn(params, 'roadMode') &&
|
||||
(params.roadMode === null || normalizeRoadMode(params.roadMode))
|
||||
(params.roadMode === null ||
|
||||
normalizeRoadMode(params.roadMode, layerState._roadModes))
|
||||
) {
|
||||
const explicit = ['user', 'voice', 'tool'].includes(origin);
|
||||
const mode =
|
||||
@@ -98,6 +108,31 @@ 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,
|
||||
@@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return the current user-adjustable parameters.
|
||||
* @returns {{densityScale:number, speedScale:number}}
|
||||
* @returns {{roadMode:string|null, lines:boolean, dots:boolean,
|
||||
* densityScale:number, speedScale:number}}
|
||||
*/
|
||||
getParams() {
|
||||
return {
|
||||
roadMode: layerState._roadMode,
|
||||
lines: layerState._showLines,
|
||||
dots: layerState._showDots,
|
||||
densityScale: layerState._densityScale,
|
||||
speedScale: layerState._speedScale,
|
||||
uncoveredRoads: layerState._uncoveredMode,
|
||||
@@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
},
|
||||
|
||||
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
getRowControls() {
|
||||
const modes = layerState._roadModes;
|
||||
const selected =
|
||||
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
|
||||
layerState._roadMode ||
|
||||
resolveRoadMode(null, layerState._liveMode, modes);
|
||||
const live = layerState._liveMode;
|
||||
const titles = {
|
||||
tomtom: live
|
||||
@@ -187,14 +230,36 @@ 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: TRAFFIC_ROAD_MODES.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === selected,
|
||||
params: { roadMode: mode },
|
||||
})),
|
||||
chips: [
|
||||
...(modes.length > 1
|
||||
? modes.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === selected,
|
||||
params: { roadMode: mode },
|
||||
}))
|
||||
: []),
|
||||
...toggles,
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || layerState._dots.length === 0) return [];
|
||||
// Hidden dots are not on screen to detect.
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._showDots ||
|
||||
layerState._dots.length === 0
|
||||
)
|
||||
return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: layerState._dots.length;
|
||||
@@ -382,7 +453,11 @@ 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),
|
||||
roadMode: resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
),
|
||||
roadModeRequested: layerState._roadMode,
|
||||
roadSource: layerState._roadSource,
|
||||
source: layerState._roadSource,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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']);
|
||||
});
|
||||
@@ -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 });
|
||||
const state = createState({ services, source });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.style = createStyle(context);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { requestWithDeadline } from '../../sources/requestDeadline.js';
|
||||
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
|
||||
import {
|
||||
normalizeRoadMode,
|
||||
resolveRoadMode,
|
||||
roadModesSimulate,
|
||||
ROAD_SOURCE_LABELS,
|
||||
} from './roadModes.js';
|
||||
import { phaseTiming } from '../../sources/phaseTiming.js';
|
||||
import { RoadRequestError, roadRequestError } from './source.js';
|
||||
import {
|
||||
@@ -294,10 +299,14 @@ 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 =
|
||||
layerState._roadMode === 'osm'
|
||||
normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm'
|
||||
? 'osm'
|
||||
: layerState._flowStatusKnown
|
||||
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
|
||||
? resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
)
|
||||
: null;
|
||||
// Cache key: fixed-precision bounding-box string for deterministic lookups
|
||||
const cacheKey =
|
||||
@@ -378,6 +387,7 @@ export function createIngestion({
|
||||
(segments) => ({
|
||||
segments,
|
||||
hasKey: layerState._liveMode,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
partial: source.getFlowSessionStats?.().partial,
|
||||
}),
|
||||
(error) => {
|
||||
@@ -388,6 +398,7 @@ export function createIngestion({
|
||||
return {
|
||||
segments: [],
|
||||
hasKey: layerState._liveMode,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
error: reason,
|
||||
};
|
||||
},
|
||||
@@ -397,7 +408,9 @@ export function createIngestion({
|
||||
// This uses the same shared XYZ cache and cap, with no extra ring.
|
||||
if (
|
||||
altitude <= FAST_FETCH_ALTITUDE &&
|
||||
layerState._roadMode !== 'tomtom'
|
||||
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
|
||||
'tomtom' &&
|
||||
(roadModesSimulate(layerState._roadModes) || layerState._liveMode)
|
||||
) {
|
||||
source
|
||||
.requestOsmRoads?.(clamped, {
|
||||
@@ -411,8 +424,19 @@ export function createIngestion({
|
||||
.catch(() => {});
|
||||
}
|
||||
requestSignal.throwIfAborted();
|
||||
// Until the probe settles only OpenStreetMap roads can be drawn.
|
||||
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
|
||||
// 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,
|
||||
))
|
||||
];
|
||||
let cache =
|
||||
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
|
||||
if (cache) {
|
||||
@@ -581,7 +605,11 @@ export function createIngestion({
|
||||
if (generation === layerState._loadGeneration && !renderedSomething)
|
||||
layerState._roadSource =
|
||||
ROAD_SOURCE_LABELS[
|
||||
resolveRoadMode(layerState._roadMode, layerState._liveMode)
|
||||
resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
)
|
||||
];
|
||||
retryable = !isUnavailableCapability(e);
|
||||
layerState._roadRetryStopped = !retryable;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -81,10 +82,15 @@ export function createLifecycle({
|
||||
if (layerState._enabled) return;
|
||||
layerState._enabled = true;
|
||||
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
|
||||
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
|
||||
// OpenFreeMap metadata, unless only TomTom roads can be drawn.
|
||||
if (
|
||||
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
|
||||
'tomtom'
|
||||
)
|
||||
source.prefetch?.();
|
||||
parts.animation.syncRenderDemand();
|
||||
layerState._lastAnimTime = 0;
|
||||
layerState._pointCollection.show = true;
|
||||
layerState._pointCollection.show = layerState._showDots;
|
||||
|
||||
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
|
||||
parts.animation.animate,
|
||||
|
||||
@@ -117,6 +117,23 @@ 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). */
|
||||
|
||||
|
||||
+131
-60
@@ -2,6 +2,10 @@ 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,
|
||||
@@ -34,45 +38,81 @@ export function createRendering({
|
||||
: roads;
|
||||
}
|
||||
|
||||
/** Remove both heat-line ground primitives from the scene. */
|
||||
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
|
||||
|
||||
function removeHeatLines() {
|
||||
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;
|
||||
/** 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. */
|
||||
|
||||
function removeHeatLines() {
|
||||
removePrimitives(takeHeatLines());
|
||||
removePrimitives(layerState._heatRetired || []);
|
||||
layerState._heatRetired = [];
|
||||
parts.animation.syncRenderDemand?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param {Array} roads - Road objects visible in the current render.
|
||||
*/
|
||||
|
||||
function rebuildHeatLines(roads) {
|
||||
removeHeatLines();
|
||||
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?.();
|
||||
};
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!layerState._liveMode ||
|
||||
!parts.style.heatlineOn()
|
||||
)
|
||||
return;
|
||||
return finish();
|
||||
if (layerState._heatSupported === null) {
|
||||
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
|
||||
layerState._viewer.scene,
|
||||
@@ -82,22 +122,25 @@ export function createRendering({
|
||||
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
|
||||
);
|
||||
}
|
||||
if (!layerState._heatSupported) return;
|
||||
if (!layerState._heatSupported) return finish();
|
||||
|
||||
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') continue;
|
||||
if (bucket === 'free' && !flowLines) 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 : a.bucket === 'jam' ? -1 : 1,
|
||||
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
|
||||
);
|
||||
const kept = candidates.slice(0, HEAT_LINE_CAP);
|
||||
const kept = candidates.slice(0, cap);
|
||||
|
||||
const instancesFor = (bucket, width) =>
|
||||
kept
|
||||
@@ -112,51 +155,72 @@ export function createRendering({
|
||||
}),
|
||||
);
|
||||
|
||||
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
|
||||
// luminance like the dots: jam = white glow, slow = faint gray.
|
||||
const monoHeat =
|
||||
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
|
||||
// luminance like the dots: jam = white, slow = gray, free = dim gray.
|
||||
const mono =
|
||||
layerState._presetDots === 'on' &&
|
||||
trafficStyleProfile(layerState._stylePreset) === 'mono';
|
||||
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;
|
||||
|
||||
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,
|
||||
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;
|
||||
|
||||
// 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),
|
||||
}),
|
||||
);
|
||||
} 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
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._heatSlowPrim = add(
|
||||
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
|
||||
Cesium.Material.fromType('Color', { color: slowLineColor }),
|
||||
);
|
||||
}
|
||||
|
||||
layerState._heatLineCount = kept.length;
|
||||
parts.animation.syncRenderDemand?.();
|
||||
finish();
|
||||
if (candidates.length > kept.length) {
|
||||
console.log(
|
||||
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
|
||||
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,7 +334,13 @@ export function createRendering({
|
||||
renderMetrics,
|
||||
)
|
||||
: null;
|
||||
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
|
||||
// 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 (state) {
|
||||
const heatEnd = parts.timing.trafficTimingMark(
|
||||
state,
|
||||
@@ -314,6 +384,7 @@ export function createRendering({
|
||||
return {
|
||||
visibleRoadsForAltitude,
|
||||
removeHeatLines,
|
||||
retireHeatLines,
|
||||
rebuildHeatLines,
|
||||
renderRoadsForAltitude,
|
||||
};
|
||||
|
||||
@@ -46,24 +46,59 @@ 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) {
|
||||
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
|
||||
export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) {
|
||||
return modes.includes(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the mode actually drawn. Without a TomTom key every choice draws
|
||||
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
|
||||
* 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.
|
||||
* @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) {
|
||||
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
|
||||
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]);
|
||||
}
|
||||
|
||||
const FLOW_ROAD_TYPES = Object.freeze({
|
||||
|
||||
@@ -74,7 +74,23 @@ function roadChips(liveMode) {
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
});
|
||||
return controls.methods.getRowControls().chips;
|
||||
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-'));
|
||||
}
|
||||
|
||||
test('en: road-source chips keep their English labels and titles', () => {
|
||||
@@ -124,3 +140,28 @@ 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
createHybridFill,
|
||||
flowSegmentsToRoads,
|
||||
normalizeRoadMode,
|
||||
normalizeRoadModes,
|
||||
resolveRoadMode,
|
||||
roadModesSimulate,
|
||||
ROAD_SOURCE_LABELS,
|
||||
} from './roadModes.js';
|
||||
import { phaseTiming } from '../../sources/phaseTiming.js';
|
||||
@@ -80,7 +83,15 @@ export function roadRequestError(status, cause) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function createTrafficSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
tileFetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -88,10 +99,13 @@ 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();
|
||||
@@ -247,8 +261,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).
|
||||
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
|
||||
* @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 {() => 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.
|
||||
@@ -269,7 +283,16 @@ export function createTrafficSource({
|
||||
box.east - box.west > 10
|
||||
)
|
||||
throw new TypeError('A bounded road viewport is required');
|
||||
if (roadMode === 'osm' || !flowSnapshot)
|
||||
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')
|
||||
)
|
||||
return api.requestOsmRoads(box, { ...options, onTile });
|
||||
const signal = options.signal;
|
||||
const osm = [];
|
||||
@@ -332,7 +355,13 @@ export function createTrafficSource({
|
||||
osmError = error;
|
||||
}
|
||||
};
|
||||
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
|
||||
// A host without OpenStreetMap waits for the key before fetching fill.
|
||||
const osmJob =
|
||||
roadMode === 'tomtom' ||
|
||||
modes.every((entry) => entry === 'tomtom') ||
|
||||
!roadModesSimulate(modes)
|
||||
? 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
|
||||
@@ -357,7 +386,21 @@ export function createTrafficSource({
|
||||
throw new TypeError(
|
||||
'Road selection requires a flow availability snapshot',
|
||||
);
|
||||
mode = resolveRoadMode(roadMode, live.hasKey);
|
||||
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) },
|
||||
);
|
||||
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
|
||||
if (mode !== 'osm') {
|
||||
tomtom = flowSegmentsToRoads(live.segments);
|
||||
@@ -365,7 +408,7 @@ export function createTrafficSource({
|
||||
publish(composed(), true);
|
||||
}
|
||||
if (osmJob) await osmJob;
|
||||
else if (mode === 'osm') await loadOsm();
|
||||
else if (mode !== 'tomtom') await loadOsm();
|
||||
signal?.throwIfAborted();
|
||||
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
|
||||
const data = snapshot(composed(), true);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { normalizeRoadMode } from './roadModes.js';
|
||||
import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { FLOW_BUCKET_COLORS } from './policy.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
export function createState({ services, source }) {
|
||||
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;
|
||||
@@ -167,6 +170,14 @@ export function createState({ services }) {
|
||||
|
||||
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
|
||||
@@ -181,6 +192,14 @@ export function createState({ services }) {
|
||||
|
||||
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;
|
||||
|
||||
@@ -137,10 +137,14 @@ export function createStyle({ state: layerState, services, parts, source }) {
|
||||
return layerState._jamViz === 'density' || layerState._jamViz === 'both';
|
||||
}
|
||||
|
||||
/** @returns {boolean} Heat-line prototype active. */
|
||||
/** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */
|
||||
|
||||
function heatlineOn() {
|
||||
return layerState._jamViz === 'heatline' || layerState._jamViz === 'both';
|
||||
return (
|
||||
layerState._showLines ||
|
||||
layerState._jamViz === 'heatline' ||
|
||||
layerState._jamViz === 'both'
|
||||
);
|
||||
}
|
||||
return {
|
||||
presetProfileActive,
|
||||
|
||||
@@ -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 }),
|
||||
traffic: Object.freeze({ roadMode: null, lines: false, dots: true }),
|
||||
'weather-lightning': Object.freeze({ opacity: 'strong' }),
|
||||
'weather-radar': Object.freeze({ opacity: 'strong' }),
|
||||
'weather-satellite': Object.freeze({
|
||||
|
||||
@@ -3,7 +3,10 @@ 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 { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
|
||||
import {
|
||||
chatSchema,
|
||||
createOpenAiChatHandler,
|
||||
} from '../server/providers/openai/chat.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||
import {
|
||||
realtimeDirectives,
|
||||
@@ -534,6 +537,11 @@ 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) => {
|
||||
@@ -697,3 +705,25 @@ 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: [{}] },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user