feat(traffic): Lines and Dots toggles; hosts choose which road modes they offer

createTrafficSource({ roadModes }) lets a host offer a subset of the TomTom,
OSM and Hybrid road modes (all three by default). Without OSM the layer never
draws simulated roads, and a single mode shows no road chip.

While flow is live the Street Traffic row offers Lines (every road with flow
as a steady green/amber/red line, from the heat-line renderer) and Dots (the
moving dots), saved in layer state and share links. Defaults: Lines off,
Dots on.
This commit is contained in:
Sameh Khamis committed 2026-11-06 11:37:57 -08:00
1 parent c56d5f2f06
commit 9c8bb206ef
27 files changed
+868 -113

No files matched your search

+3
View File
@@ -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'], {
+5
View File
@@ -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": "تم استهلاك حصة حركة المرور الشهرية",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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é",
+5
View File
@@ -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",
+5
View File
@@ -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": "今月の交通情報の上限に達しました",
+5
View File
@@ -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",
+5
View File
@@ -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 сопоставляется, где он есть",
+5
View File
@@ -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": "Місячний ліміт даних про трафік вичерпано",
+5
View File
@@ -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": "本月交通流量额度已用完",
+5
View File
@@ -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": "本月交通流量額度已用完",
+16 -3
View File
@@ -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);
}
+95 -20
View File
@@ -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,
+326
View File
@@ -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']);
});
+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 });
const state = createState({ services, source });
const parts = {};
const context = { state, services, parts, source };
parts.style = createStyle(context);
+35 -7
View File
@@ -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;
+8 -2
View File
@@ -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,
+17
View File
@@ -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
View File
@@ -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,
};
+40 -5
View File
@@ -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({
+42 -1
View File
@@ -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');
}
});
}
+50 -7
View File
@@ -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);
+21 -2
View File
@@ -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;
+6 -2
View File
@@ -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,
+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 }),
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({