mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
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:
1 parent
c56d5f2f06
commit
9c8bb206ef
27 files changed
+868
-113
No files matched your search
@@ -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'], {
|
||||
|
||||
@@ -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": "本月交通流量額度已用完",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user