mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
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]>
424 lines
15 KiB
JavaScript
424 lines
15 KiB
JavaScript
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,
|
||
};
|
||
}
|