Files
gods-eye-view/src/layers/traffic/model.js
T
quanticsoul4772andClaude Opus 5.5 7981a179ea fix(traffic): the traffic layer reads STALE, not LIVE, on cached flow tiles
Once the TomTom tile budget is spent, or TomTom fails, the proxy serves
cached tiles marked STALE-BUDGET / STALE-ERROR. flowSource works out
`stale`, but the layer fetched flow through fetchFlowForBounds, which
returns only the segments, so getStats() and the sync chip still read
"LIVE · Roads: …". #1239 fixed the get_traffic_flow tool the same way; the
layer now uses fetchFlowDetail too, reports stale and oldestTileAt in its
stats, and the chip reads "STALE · Roads: <source> · Flow: cached TomTom
tiles". A source without fetchFlowDetail keeps the previous behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:19 -07:00

424 lines
15 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 * 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,
};
}