Files
gods-eye-view/src/layers/traffic/controls.js
T

190 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
trafficBucketTier,
trafficStyleProfile,
} from '../../data/trafficPresetStyle.js';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const { getFlowSessionStats } = source;
const methods = {
id: 'traffic',
name: 'Street Traffic',
icon: '🚗',
source: 'OpenStreetMap',
/** @type {number} Zero — layer is self-managed via camera listener + preRender */
updateInterval: 0,
/**
* Update user-adjustable parameters (density and speed scaling).
*
* @param {Object} [params]
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
*/
setParams(params = {}) {
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),
);
}
// 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 {
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
jamViz: layerState._jamViz,
presetDots: layerState._presetDots,
};
},
/**
* 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(i).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;
},
/**
* 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 matched roads / roads with any
* flow candidates (0–100 int); `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}}
*/
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;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
coveragePct: layerState._flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
});
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
error: layerState._roadError || feed.error,
flowCoveragePct: layerState._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.
loadingLabel: feed.loadingLabel,
};
},
};
return { methods };
}