refactor: separate traffic and bikeshare components

This commit is contained in:
Sameh Khamis committed 2026-09-13 14:10:38 -07:00
1 parent 3143d00c90
commit 903fe6ba37
45 files changed
+6159 -4363

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
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: 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 };
}