mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
190 lines
7.9 KiB
JavaScript
190 lines
7.9 KiB
JavaScript
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 };
|
||
}
|