import { normalizeRoadMode, resolveRoadMode, TRAFFIC_ROAD_MODES, } from './roadModes.js'; import { trafficBucketTier, trafficStyleProfile, } from '../../data/trafficPresetStyle.js'; import { TRAFFIC_TIMING_ENABLED } from './policy.js'; import { t, translateKnown } from '../../i18n/index.js'; export function createControls({ state: layerState, services, parts, source }) { /** One status line: feed, drawn road source, then degraded-coverage notes. */ function roadStatusLabel(feed) { const source = layerState._roadSource; if (layerState._roadError) return t('UNAVAILABLE ยท {source} ยท Roads unavailable', { 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. if ( !layerState._liveMode && ['tomtom', 'hybrid'].includes(layerState._roadMode) ) notes.push( layerState._flowStatusUnavailable ? layerState._roadMode === 'tomtom' ? t( 'TomTom roads unavailable while the traffic service is unreachable', ) : t('Hybrid unavailable while the traffic service is unreachable') : layerState._roadMode === 'tomtom' ? t('TomTom roads need a TomTom key') : t('Hybrid needs a TomTom key'), ); if (layerState._surfacePending) notes.push(t('Local surface still loading')); // The warning is a road-source message (source.js marks the known ones). if (layerState._roadWarning) notes.push(translateKnown(layerState._roadWarning)); else if (layerState._roadPartial) notes.push(t('Partial coverage')); if (layerState._detailError) notes.push(t('Detailed roads unavailable')); else if (layerState._detailLimited) notes.push(t('Reduced detail coverage')); return notes.join(' ยท '); } const { getFlowSessionStats } = source; const methods = { id: 'traffic', name: 'Street Traffic', icon: '๐Ÿš—', source: 'OpenStreetMap / TomTom', /** @type {number} Zero โ€” layer is self-managed via camera listener + preRender */ updateInterval: 0, /** * Update user-adjustable parameters (road source, density and speed scaling). * * @param {Object} [params] * @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default. * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2โ€“2.5). * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3โ€“3.0). * @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`. */ setParams(params = {}, { origin } = {}) { const previousDensity = layerState._densityScale; const previousSpeed = layerState._speedScale; if ( Object.hasOwn(params, 'roadMode') && (params.roadMode === null || normalizeRoadMode(params.roadMode)) ) { const explicit = ['user', 'voice', 'tool'].includes(origin); const mode = !explicit && layerState._roadModeUrlOverride ? layerState._roadModeUrlOverride : params.roadMode; if (explicit) layerState._roadModeUrlOverride = null; if (mode !== layerState._roadMode) { layerState._roadMode = mode; // Redraw from scratch: a different source means different roads. parts.ingestion.cancelActiveFetch(); clearTimeout(layerState._retryTimer); layerState._retryTimer = null; layerState._loadGeneration++; layerState._fetching = false; layerState._lastBounds = null; layerState._lastViewCenter = null; parts.animation.clearDots(); if (layerState._enabled) parts.viewport.onCameraChanged({ immediate: true }); } } if (typeof params.densityScale === 'number') { layerState._densityScale = Math.max( 0.2, Math.min(2.5, params.densityScale), ); } if (typeof params.speedScale === 'number') { layerState._speedScale = Math.max( 0.3, Math.min(3.0, params.speedScale), ); } if (layerState._speedScale !== previousSpeed) { const ratio = layerState._speedScale / previousSpeed; for (const dot of layerState._dots) { dot.baseMps *= ratio; dot.mps *= ratio; } } if ( layerState._densityScale !== previousDensity && layerState._enabled && layerState._roads.length ) parts.rendering.renderRoadsForAltitude( layerState._roads, layerState._lastRenderAltitude, 'Density', ); // Live-mode treatment of roads TomTom has no flow data for: // 'sim' (default) keeps them as today's white ambient dots โ€” colored = // real data, white = simulation; 'hide' spawns nothing on them (strict // data-integrity view). Owner-explorable; re-render applies on the next // camera-driven load. if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') { layerState._uncoveredMode = params.uncoveredRoads; } // Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior; // live mode only, applies on the next camera-driven load like the // uncoveredRoads param above. if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) { layerState._jamViz = params.jamViz; } // Preset-aware dot styling kill switch (owner A/B): 'off' forces the // shipped palette under every post-FX preset. Applies immediately via // in-place restyle โ€” no refetch โ€” so A/B legs share identical dots. if (params.presetDots === 'on' || params.presetDots === 'off') { if (params.presetDots !== layerState._presetDots) { layerState._presetDots = params.presetDots; parts.style.restyleDotsInPlace(); } } }, /** * Return the current user-adjustable parameters. * @returns {{densityScale:number, speedScale:number}} */ getParams() { return { roadMode: layerState._roadMode, densityScale: layerState._densityScale, speedScale: layerState._speedScale, uncoveredRoads: layerState._uncoveredMode, jamViz: layerState._jamViz, presetDots: layerState._presetDots, }; }, /** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */ getRowControls() { const selected = layerState._roadMode || resolveRoadMode(null, layerState._liveMode); const live = layerState._liveMode; const titles = { tomtom: live ? t('TomTom roads only, each with its live flow') : t( 'TomTom roads only, each with its live flow (needs a TomTom key)', ), osm: t('OpenStreetMap roads, with TomTom flow matched when available'), hybrid: live ? t('TomTom roads first; OpenStreetMap fills the rest, simulated') : t( 'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)', ), }; // TomTom and OSM are provider names and stay as they are. const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') }; return { chips: TRAFFIC_ROAD_MODES.map((mode) => ({ id: `roads-${mode}`, label: labels[mode], title: titles[mode], active: mode === selected, params: { roadMode: mode }, })), }; }, /** * Return a sub-sampled list of active dot positions for detection overlays * (e.g. CCTV bounding-box rendering). * * Uses a deterministic stride-based sampling so different seeds yield * non-overlapping subsets without sorting or shuffling. * * @param {Object} [options] * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.seed] - Integer seed to offset the sampling start. * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} */ getDetectableObjects(options = {}) { if (!layerState._enabled || layerState._dots.length === 0) return []; const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) : layerState._dots.length; const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; // Stride-based sampling: step through dots evenly to get ~maxCount samples const stride = Math.max(1, Math.ceil(layerState._dots.length / maxCount)); const start = seed % stride; const result = []; for (let i = start; i < layerState._dots.length; i += stride) { const pos = layerState._dots[i].point.position; if (!pos) continue; const entry = { position: pos, id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`, type: 'VEH', }; // Live mode: the detection bracket carries the congestion signal โ€” // its canvas sits ABOVE the post-FX chain, so tier colors survive // every preset (owner round 2: "bounding boxes do the heavy // lifting"). Keyless mode sets no tier: contacts keep the stock // 'vehicle' bracket and the keyless experience stays untouched. if (layerState._liveMode) { const tier = trafficBucketTier(layerState._dots[i].bucket || 'sim'); if (tier) entry.tier = tier; } result.push(entry); if (result.length >= maxCount) break; } return result; }, /** Visit stable dot identities and their owning geometry without allocating snapshots. */ visitMotionDots(visitor) { for (let i = 0; i < layerState._dots.length; i++) { const dot = layerState._dots[i]; visitor(dot.id, dot.road.key, dot.point, dot.road); } }, /** * Return current layer statistics for UI status chips. * `mode` is the CONFIGURED source โ€” 'live' (a TomTom key is present) or * 'sim' (keyless simulation, which the manager renders as a FALLBACK chip); * `error` carries this instant's health, so a live-configured layer whose * flow feed went down reads DEGRADED with the reason instead of a stale * LIVE coverage number. `flowCoveragePct` is shown dots on matched roads / * all shown dots (0โ€“100 int), excluding closures; `tilesFetched` counts flow-tile requests * issued to the proxy this session (decode-cache hits excluded). * @returns {{count:number, lastUpdate:number|null, loading:boolean, * mode:'live'|'sim', error:string|null, flowCoveragePct:number, * tilesFetched:number}} */ getLoadingDiagnostics() { let movingWaypoints = 0, maxResidualM = 0; for (const road of layerState._roads) { if (!road.heightMoving) continue; for (let i = 0; i < road.waypoints.length; i++) { const a = road.waypoints[i], b = road.targetWaypoints[i]; const residual = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); if (residual >= 0.01) movingWaypoints++; maxResidualM = Math.max(maxResidualM, residual); } } return { fetching: Boolean(layerState._fetching), flowPending: layerState._flowPending, surfaceRefining: Boolean(layerState._surfaceRefining), populationPending: Boolean(layerState._populationPending), heightPending: Boolean(layerState._heightPending), generation: layerState._loadGeneration, surfaceRevision: layerState._surfaceRevision, lastPaintStage: layerState._lastPaintStage || null, movingWaypoints, maxResidualM, }; }, getStats() { // Outstanding flow work counts as loading: the paint race can leave a // TomTom request in flight after the roads have settled, and the shared // loading batch has to stay open long enough to announce its failure. const loading = layerState._fetching || layerState._flowPending > 0 || Boolean(layerState._surfaceRefining) || Boolean(layerState._populationPending) || Boolean(layerState._heightPending); const { free, slow, jam, sim } = layerState._bucketCounts; const matched = free + slow + jam; const flowCoveragePct = matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0; const feed = parts.model.trafficFeedPresentation({ liveMode: layerState._liveMode, fetching: loading, flowError: layerState._flowError, stale: layerState._flowStale, coveragePct: flowCoveragePct, statusUnavailable: layerState._flowStatusUnavailable, roadSource: layerState._roadSource, }); return { count: layerState._count, motion: { ...layerState._motion }, surfaceBudgetOmitted: layerState._surfaceBudgetOmitted || 0, lastUpdate: layerState._lastUpdate, loading, mode: feed.mode, // Cached proxy tiles (TomTom budget spent or upstream failing): the // manager reads this as STALE. Any error (flow, roads, detail) reads // DEGRADED instead. stale: Boolean( layerState._liveMode && layerState._flowStale && !( layerState._roadError || layerState._roadWarning || layerState._detailError || feed.error ), ), oldestTileAt: layerState._flowOldestTileAt ?? null, error: layerState._roadError || layerState._roadWarning || layerState._detailError || feed.error, roadBounds: layerState._lastBounds, surfacePending: layerState._surfacePending || 0, roadWarning: layerState._roadWarning || null, detailError: layerState._detailError || null, detailLimited: Boolean(layerState._detailLimited), flowCoveragePct, tilesFetched: getFlowSessionStats().tilesFetched, ...(TRAFFIC_TIMING_ENABLED ? { trafficTiming: parts.timing.getTrafficTimingDiagnostics() } : {}), // Per-bucket rendered-dot counts (sim = white ambient). Drives the // qa-traffic color assertions and the sync-chip mode label below. flowBuckets: { ...layerState._bucketCounts }, closedRoads: layerState._closedRoads, // Jam-viz prototype diagnostics (additive โ€” harness contract untouched). heatLines: layerState._heatLineCount, jamViz: layerState._jamViz, // Preset-styling diagnostics (additive): active style + profile. stylePreset: layerState._stylePreset, styleProfile: layerState._presetDots === 'on' ? trafficStyleProfile(layerState._stylePreset) : 'normal', // Sync-chip text: shown while busy, and flashed on its own for 1.5 s // after each completed load (ui.js _updateTrafficSyncChip semantics). // The settled flash carries NO progress number beside it โ€” this label's // coverage figure is the chip's only percentage โ€” so a label that ends // 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), roadModeRequested: layerState._roadMode, roadSource: layerState._roadSource, source: layerState._roadSource, loadingLabel: roadStatusLabel(feed), }; }, }; return { methods }; }