mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
refactor: separate traffic and bikeshare components
This commit is contained in:
1 parent
3143d00c90
commit
903fe6ba37
45 files changed
+6159
-4363
No files matched your search
@@ -0,0 +1,417 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
flowDensityMult,
|
||||
flowBucket,
|
||||
flowSpeedScale,
|
||||
} from '../../data/trafficFlowStyle.js';
|
||||
import {
|
||||
MAX_WAYPOINTS_PER_ROAD,
|
||||
DOT_HEIGHT_OFFSET,
|
||||
DENSITY_MULT,
|
||||
JAM_DOT_FAR_SCALE,
|
||||
JAM_DOT_DEPTH_PUNCH,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
// ─── Overpass API ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build an Overpass QL query string to fetch road ways within a bounding box.
|
||||
*
|
||||
* The query uses a highway tag regex filter. When `majorOnly` is true, only
|
||||
* motorway/trunk/primary/secondary are included; otherwise all drivable road
|
||||
* classes are fetched. The `out geom qt;` suffix returns inline geometry
|
||||
* (lat/lon per node) sorted by quadtile for faster server response.
|
||||
*
|
||||
* @param {number} south - Southern latitude bound (degrees).
|
||||
* @param {number} west - Western longitude bound (degrees).
|
||||
* @param {number} north - Northern latitude bound (degrees).
|
||||
* @param {number} east - Eastern longitude bound (degrees).
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only.
|
||||
* @param {number} [opts.timeoutSec=25] - Overpass server-side timeout.
|
||||
* @returns {string} Overpass QL query body.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse an Overpass `out geom;` JSON response into internal road objects.
|
||||
*
|
||||
* Each OSM `way` element carries an inline `geometry` array of `{lat, lon}`
|
||||
* objects, so no separate node look-up or osmtogeojson conversion is needed.
|
||||
*
|
||||
* Processing per way:
|
||||
* 1. Extract [lon, lat] coordinate pairs.
|
||||
* 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices.
|
||||
* 3. Sample terrain height once at the first vertex (avoids per-vertex cost).
|
||||
* 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances.
|
||||
*
|
||||
* @param {Object} overpassData - Raw JSON response from the Overpass API.
|
||||
* @param {Array} overpassData.elements - Array of OSM elements.
|
||||
* @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>}
|
||||
* Parsed road objects ready for dot spawning.
|
||||
*/
|
||||
|
||||
function parseRoads(overpassData) {
|
||||
if (!overpassData || !overpassData.elements) return [];
|
||||
|
||||
const roads = [];
|
||||
for (const el of overpassData.elements) {
|
||||
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
|
||||
|
||||
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
|
||||
|
||||
// Sub-sample long polylines: keep every Nth vertex to stay within budget
|
||||
const simplifyStep =
|
||||
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
|
||||
? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD)
|
||||
: 1;
|
||||
const coords = [];
|
||||
for (let i = 0; i < rawCoords.length; i += simplifyStep) {
|
||||
coords.push(rawCoords[i]);
|
||||
}
|
||||
|
||||
// Ensure the original endpoint is always preserved
|
||||
const last = rawCoords[rawCoords.length - 1];
|
||||
const tail = coords[coords.length - 1];
|
||||
if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) {
|
||||
coords.push(last);
|
||||
}
|
||||
|
||||
if (coords.length < 2) continue;
|
||||
|
||||
const type = el.tags?.highway || 'unclassified';
|
||||
// One-way capture (field-test round 1: "a car would never go in
|
||||
// reverse"): dots on one-way roads all travel the legal direction.
|
||||
// OSM: oneway=yes/1/true → digitization order; '-1' → reversed;
|
||||
// roundabouts are one-way by definition. 0 = two-way (alternate).
|
||||
const onewayTag = el.tags?.oneway;
|
||||
const oneway =
|
||||
onewayTag === 'yes' ||
|
||||
onewayTag === '1' ||
|
||||
onewayTag === 'true' ||
|
||||
el.tags?.junction === 'roundabout'
|
||||
? 1
|
||||
: onewayTag === '-1'
|
||||
? -1
|
||||
: 0;
|
||||
|
||||
// Sample terrain height once at the road start to avoid per-vertex cost
|
||||
let baseHeight = 0;
|
||||
const firstCoord = coords[0];
|
||||
if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) {
|
||||
const carto = Cesium.Cartographic.fromDegrees(
|
||||
firstCoord[0],
|
||||
firstCoord[1],
|
||||
);
|
||||
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
||||
if (Number.isFinite(sampled)) baseHeight = sampled;
|
||||
}
|
||||
|
||||
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
|
||||
const waypoints = coords.map(([lng, lat]) => {
|
||||
const h = baseHeight + 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]),
|
||||
);
|
||||
}
|
||||
|
||||
roads.push({ coords, type, oneway, waypoints, segmentDist });
|
||||
}
|
||||
|
||||
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
|
||||
let spacing;
|
||||
if (altitude < 1000) spacing = 30;
|
||||
else if (altitude < 3000) spacing = 80;
|
||||
else if (altitude < 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {number} [input.coveragePct] - Matched-road coverage, 0–100.
|
||||
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
|
||||
* @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}}
|
||||
*/
|
||||
|
||||
function trafficFeedPresentation({
|
||||
liveMode = false,
|
||||
fetching = false,
|
||||
flowError = null,
|
||||
coveragePct = 0,
|
||||
statusUnavailable = false,
|
||||
} = {}) {
|
||||
// `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.
|
||||
const degraded = `SIMULATED — ${flowError}`;
|
||||
return { mode, error: degraded, loadingLabel: degraded };
|
||||
}
|
||||
if (liveMode) {
|
||||
return {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? 'syncing LIVE traffic flow'
|
||||
: `LIVE · TomTom flow · ${coveragePct}% cov`,
|
||||
};
|
||||
}
|
||||
// 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
|
||||
? 'SIMULATED — traffic service unreachable'
|
||||
: '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 ? dot.road.flow : null;
|
||||
if (flow?.closure) {
|
||||
dot.point.show = false;
|
||||
closedDots += 1;
|
||||
continue;
|
||||
}
|
||||
const bucket = flow ? flowBucket(flow.level) : null;
|
||||
dot.bucket = bucket;
|
||||
dot.point.color = bucket
|
||||
? layerState._activeBucketColors[bucket]
|
||||
: Cesium.Color.WHITE.withAlpha(0.85);
|
||||
if (bucket === 'jam') {
|
||||
dot.point.pixelSize =
|
||||
parts.style.baseDotSize(dot.road?.type, bucket) +
|
||||
1 +
|
||||
parts.style.activeSizeDelta('jam');
|
||||
} else if (bucket && parts.style.presetProfileActive()) {
|
||||
// Preset profiles size-floor every bucket; the shipped normal path
|
||||
// keeps its jam-only size touch (byte-identical behavior).
|
||||
dot.point.pixelSize =
|
||||
parts.style.baseDotSize(dot.road?.type, bucket) +
|
||||
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,
|
||||
);
|
||||
dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH;
|
||||
} else {
|
||||
dot.creep = null;
|
||||
}
|
||||
layerState._bucketCounts[bucket || 'sim'] += 1;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user