import * as Cesium from 'cesium'; import { roadSurfaceChunks } from './surface.js'; import { t, translateKnown } from '../../i18n/index.js'; import { flowDensityMult, flowBucket, flowSpeedScale, } from '../../data/trafficFlowStyle.js'; import { DOT_HEIGHT_OFFSET, DENSITY_MULT, JAM_DOT_FAR_SCALE, } from './policy.js'; export function createModel({ state: layerState, services, parts, source }) { /** Build scene waypoints from source records; thinning and terrain remain rendering policy. */ function parseRoads(roadData) { if (!roadData || !roadData.roads) { return []; } const roads = []; for (const road of roadData.roads) { if (!road.coordinates || road.coordinates.length < 2) continue; for (const coords of roadSurfaceChunks(road.coordinates)) { const type = road.type; const oneway = road.oneway; // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation const waypoints = coords.map(([lng, lat]) => { const floor = services.ground?.cachedGroundFloor?.(lat, lng); const h = (Number.isFinite(floor) && Math.abs(floor) <= 9000 ? floor : layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET; return Cesium.Cartesian3.fromDegrees(lng, lat, h); }); // Pre-compute segment distances in meters for speed-to-t conversion const segmentDist = []; for (let i = 0; i < waypoints.length - 1; i++) { segmentDist.push( Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), ); } for (const direction of oneway ? [oneway] : [1, -1]) roads.push({ densityWeight: road.densityWeight ?? (oneway ? 1 : 0.5), drivable: road.drivable, roadClass: road.roadClass, roadProperties: road.roadProperties, directFlow: road.directFlow, simulatedOnly: road.simulatedOnly, coords, type, oneway: direction, waypoints, segmentDist, flow: road.flow || null, }); } } return roads; } // ─── Road Length Estimation ──────────────────────────────── /** * Estimate the total length of a road in meters from its degree-based coordinates. * * Uses Euclidean distance in degree-space then multiplies by the equatorial * approximation of 111 km per degree. Accurate enough for dot density spacing * but not for navigation. * * @param {number[][]} coords - Array of [lon, lat] pairs. * @returns {number} Approximate road length in meters. */ function estimateRoadLengthDeg(coords) { let len = 0; for (let i = 0; i < coords.length - 1; i++) { const dx = coords[i + 1][0] - coords[i][0]; const dy = coords[i + 1][1] - coords[i][1]; len += Math.sqrt(dx * dx + dy * dy); } // Rough conversion: 1 degree ~ 111,000 meters at equator return len * 111000; } // ─── Dot Spawning ────────────────────────────────────────── /** * Compute the ideal number of dots for a single road at a given camera altitude. * * Spacing increases with altitude so fewer dots are rendered when zoomed out. * The result is further scaled by the road-type density multiplier and the * user-adjustable `_densityScale`. * * @param {{coords:number[][], type:string}} road - Parsed road object. * @param {number} altitude - Current camera altitude in meters. * @returns {number} Ideal dot count (minimum 1). */ function computeDotCount(road, altitude) { // Live flow: closed roads carry zero traffic; congestion packs more dots. // `road.flow` is only ever set in live mode, so the keyless path is // untouched (flow stays undefined → multiplier 1, identical output). const flow = layerState._liveMode ? road.flow : null; if (flow?.closure) return 0; // Strict data-integrity view: uncovered roads spawn nothing when hidden. if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide') return 0; const lengthM = estimateRoadLengthDeg(road.coords); // Altitude-adaptive spacing: closer camera = denser dots const clearance = Math.max( 0, altitude - (layerState._viewHeightEstimate || 0), ); let spacing; if (clearance < 1000) spacing = 30; else if (clearance < 3000) spacing = 80; else if (clearance < 5000) spacing = 150; else spacing = 250; const mult = (DENSITY_MULT[road.type] || 1) * layerState._densityScale * (flow ? flowDensityMult(flow.level, { jamBoost: parts.style.jamDensityOn() }) : 1); return Math.max( 1, Math.floor((lengthM / spacing) * mult * (road.densityWeight || 1)), ); } /** * Distribute a fixed dot budget fairly across all visible roads. * * Algorithm: * 1. Compute ideal dot count per road via `computeDotCount`. * 2. Seed one dot to every road that wants at least one (fairness pass). * 3. Distribute remaining budget proportionally to each road's ideal count. * 4. Assign leftover dots (from floor rounding) to roads with the highest * fractional residuals (largest-remainder method). * * This prevents high-density motorways from starving smaller residential roads * when the global MAX_DOTS cap is reached. * * @param {Array} roads - Parsed road objects. * @param {number} altitude - Camera altitude in meters (affects spacing). * @param {number} dotCap - Maximum total dots to allocate. * @returns {number[]} Per-road dot budgets, same length as `roads`. */ function allocateRoadDotBudgets(roads, altitude, dotCap) { const planned = roads.map((road) => computeDotCount(road, altitude)); const budgets = new Array(roads.length).fill(0); let remaining = Math.max(0, dotCap); // Pass 1 — fairness seed: give one dot to every road (highest-demand first) const firstPassOrder = planned .map((count, index) => ({ count, index })) .sort((a, b) => b.count - a.count); for (const entry of firstPassOrder) { if (remaining <= 0) break; if (entry.count <= 0) continue; budgets[entry.index] = 1; remaining -= 1; } if (remaining <= 0) return budgets; // Pass 2 — proportional distribution of the remaining budget let totalRemainder = 0; for (let i = 0; i < planned.length; i++) { totalRemainder += Math.max(0, planned[i] - budgets[i]); } if (totalRemainder <= 0) return budgets; const residuals = []; let assigned = 0; for (let i = 0; i < planned.length; i++) { const cap = Math.max(0, planned[i] - budgets[i]); if (cap <= 0) continue; const ideal = (cap / totalRemainder) * remaining; const add = Math.min(cap, Math.floor(ideal)); budgets[i] += add; assigned += add; residuals.push({ index: i, residual: ideal - add }); } // Pass 3 — largest-remainder: hand out leftover dots from floor rounding let leftover = remaining - assigned; if (leftover > 0 && residuals.length > 0) { residuals.sort((a, b) => b.residual - a.residual); let cursor = 0; while (leftover > 0 && residuals.length > 0) { const idx = residuals[cursor % residuals.length].index; if (budgets[idx] < planned[idx]) { budgets[idx] += 1; leftover -= 1; } cursor += 1; // Safety valve: avoid infinite loop if all roads are already at their ideal if (cursor > residuals.length * 3 && leftover > 0) break; } } return budgets; } /** * Derive the layer's honest feed presentation from its live-flow state. * * The three states a user can be in, and what each must read as: * - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a * label that never claims live data; * - live and healthy → LIVE with real coverage; * - live but flow-down → an `error` string, so the chip degrades and says * the colors on screen are simulated. Never a stale "LIVE · N% cov". * * @param {Object} [input] * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. * @param {boolean} [input.fetching] - A viewport load is in flight. * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. * @param {boolean} [input.stale] - The flow came from cached proxy tiles. * @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100. * @param {boolean} [input.statusUnavailable] - The status probe itself failed. * @param {string} [input.roadSource] - Name of the geometry being drawn. * @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}} */ function trafficFeedPresentation({ liveMode = false, fetching = false, flowError = null, stale = false, coveragePct = 0, statusUnavailable = false, roadSource = 'OpenStreetMap', } = {}) { // `mode` is the CONFIGURED source (live key present vs keyless), not this // instant's health — health rides on `error`. The qa-traffic harness pins // that meaning. const mode = liveMode ? 'live' : 'sim'; if (liveMode && flowError) { // One string for both fields. The manager's meta line renders `error` and // drops `loadingLabel` in its error branch, so the owner's SIMULATED copy // has to BE the error text or the steady state reverts to a bare // "TomTom daily budget reached" that never says what is on screen. // The reason is ours (flow.js marks it) or carries an HTTP code. const degraded = t('SIMULATED — {reason}', { reason: translateKnown(flowError), }); return { mode, error: degraded, loadingLabel: degraded }; } // Cached proxy tiles (TomTom budget spent or upstream failing) are not live. if (liveMode && stale && !fetching) { return { mode, error: null, loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', { source: roadSource, }), }; } // TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy. if (liveMode && roadSource === 'TomTom') { return { mode, error: null, loadingLabel: fetching ? t('Syncing flow · Roads: {source}', { source: roadSource }) : coveragePct > 0 ? t('LIVE · Roads: {source} · Roads without flow hidden', { source: roadSource, }) : t('LIVE · Roads: {source} · No flow roads in view', { source: roadSource, }), }; } if (liveMode && roadSource === 'TomTom + OpenStreetMap') { return { mode, error: null, loadingLabel: fetching ? t('Syncing flow · Roads: {source}', { source: roadSource }) : coveragePct > 0 ? t('LIVE · Roads: {source} · Flow {percent}%', { source: roadSource, percent: coveragePct, }) : t('SIMULATED · Roads: {source} · Flow {percent}%', { source: roadSource, percent: coveragePct, }), }; } if (liveMode) { return { mode, error: null, loadingLabel: fetching ? t( 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated', ) : coveragePct > 0 ? coveragePct < 100 ? t( 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated', { percent: coveragePct, }, ) : t( 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov', { percent: coveragePct, }, ) : t('SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)'), }; } // Keyless simulation — one terse line that names the mode and the remedy // (owner's copy shape). The chip's own progress text carries "working"; // this line must never imply a live feed. return { mode, error: null, loadingLabel: statusUnavailable ? t('SIMULATED — traffic service unreachable') : t('SIMULATED — add TomTom key for live'), }; } /** * Apply late-arriving flow data to already-rendered dots without a respawn: * color, jam size, and speed update in place; closed roads' dots hide. * Density bunching intentionally waits for the next natural re-render — * color and speed are the live signal, dot count is a refinement. * @param {string} label - Render log label (for the console trace). */ function recolorDotsInPlace(label) { if (!layerState._liveMode || !layerState._dots.length) return; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; let closedDots = 0; const now = Date.now(); for (const dot of layerState._dots) { const flow = dot.road?.source ? dot.road.source.flow : dot.road?.flow; dot.road.flow = flow; if (flow?.closure || (!flow && layerState._uncoveredMode === 'hide')) { dot.point.show = false; dot.mps = 0; closedDots += 1; continue; } dot.point.show = true; const bucket = flow ? flowBucket(flow.level) : null; dot.bucket = bucket; dot.point.color = bucket ? layerState._activeBucketColors[bucket] : Cesium.Color.WHITE.withAlpha(0.85); dot.nominalSize = parts.style.baseDotSize(dot.road?.type, bucket) + (bucket === 'jam' ? 1 : 0) + parts.style.activeSizeDelta(bucket); // Late flow can move a dot between buckets — keep the preset halo in // step (no-op writes under the normal profile, whose dots have none). if (parts.style.presetProfileActive()) parts.style.applyOutline(dot.point, bucket); dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1); // Late flow tags/untags stop-and-go creep + city-scale prominence the // same way it rescales speed. Queue *positions* wait for the next // natural re-render, like density bunching. if (bucket === 'jam' && parts.style.jamDensityOn()) { if (!dot.creep) dot.creep = { moving: Math.random() < 0.4, until: now + Math.random() * 2000, }; dot.point.scaleByDistance = new Cesium.NearFarScalar( 100, 1.5, layerState._fadeScaleFar, JAM_DOT_FAR_SCALE, ); } else { dot.creep = null; } layerState._bucketCounts[bucket || 'sim'] += 1; } layerState._count = layerState._dots.length - closedDots; layerState._closedRoads = layerState._roads.reduce( (n, r) => n + (r.flow?.closure ? 1 : 0), 0, ); parts.rendering.rebuildHeatLines( parts.rendering.visibleRoadsForAltitude( layerState._roads, layerState._lastRenderAltitude, ), ); console.log( `[Data:Traffic] Flow recolor (${label}): ${layerState._dots.length} dots, closedDots=${closedDots}`, ); } return { parseRoads, estimateRoadLengthDeg, computeDotCount, allocateRoadDotBudgets, trafficFeedPresentation, recolorDotsInPlace, }; }