mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
createTrafficSource({ roadModes }) lets a host offer a subset of the TomTom,
OSM and Hybrid road modes (all three by default). Without OSM the layer never
draws simulated roads, and a single mode shows no road chip.
While flow is live the Street Traffic row offers Lines (every road with flow
as a steady green/amber/red line, from the heat-line renderer) and Dots (the
moving dots), saved in layer state and share links. Defaults: Lines off,
Dots on.
385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
import { DOT_FADE_MS } from './retention.js';
|
||
import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js';
|
||
import { presetDotOutline } from '../../data/trafficPresetStyle.js';
|
||
import * as Cesium from 'cesium';
|
||
import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js';
|
||
import {
|
||
SPEED_MPS,
|
||
MAX_DOTS,
|
||
JAM_DOT_FAR_SCALE,
|
||
CREEP_MOVE_MS,
|
||
CREEP_STOP_MS,
|
||
CREEP_BURST,
|
||
HEAT_JAM_BASE_ALPHA,
|
||
HEAT_JAM_PULSE_ALPHA,
|
||
} from './policy.js';
|
||
|
||
export function createAnimation({
|
||
state: layerState,
|
||
services,
|
||
parts,
|
||
source,
|
||
}) {
|
||
function syncRenderDemand() {
|
||
// Hidden dots (`dots` off) need no frames. Lines need them only while
|
||
// their asynchronous geometry builds (or old lines wait to be retired),
|
||
// and the heat-line prototype's jam batch while it pulses.
|
||
const lines = [
|
||
layerState._heatJamPrim,
|
||
layerState._heatSlowPrim,
|
||
layerState._heatFreePrim,
|
||
].filter(Boolean);
|
||
const needed = Boolean(
|
||
layerState._enabled &&
|
||
((layerState._showDots !== false && layerState._dots.length) ||
|
||
(layerState._heatJamPrim && !layerState._showLines) ||
|
||
layerState._heatRetired?.length ||
|
||
lines.some((primitive) => !primitive.ready)),
|
||
);
|
||
// Height easing only runs in a held frame. Settle it before releasing, so
|
||
// a population emptied mid-correction cannot report loading forever.
|
||
if (!needed && layerState._heightPending) parts.retention.finishHeights();
|
||
if (needed === Boolean(layerState._renderHeld)) return;
|
||
layerState._renderHeld = needed;
|
||
if (needed) {
|
||
layerState._lastAnimTime = 0;
|
||
services.render.holdContinuousRender('traffic');
|
||
layerState._viewer?.scene?.requestRender?.();
|
||
} else services.render.releaseContinuousRender('traffic');
|
||
}
|
||
|
||
/**
|
||
* Spawn animated dot primitives along a single road.
|
||
*
|
||
* Each dot is placed at a random position along the road, assigned a
|
||
* randomized speed (base +/-30%), and given a direction (alternating
|
||
* forward/backward to simulate two-way traffic).
|
||
*
|
||
* @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road
|
||
* Parsed road object with pre-computed waypoints.
|
||
* @param {number} altitude - Camera altitude (used if budgetCount is null).
|
||
* @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back
|
||
* to `computeDotCount` when null.
|
||
*/
|
||
|
||
function spawnDotsForRoad(road, altitude, budgetCount = null) {
|
||
// Live flow styling (`road.flow` only exists in live mode; keyless path is
|
||
// byte-identical): closures spawn nothing, congestion colors/slows dots.
|
||
const flow = layerState._liveMode ? road.flow : null;
|
||
if (flow?.closure) return;
|
||
if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide')
|
||
return;
|
||
|
||
const count = Number.isFinite(budgetCount)
|
||
? Math.max(0, Math.floor(budgetCount))
|
||
: parts.model.computeDotCount(road, altitude);
|
||
const numSegments = road.waypoints.length - 1;
|
||
if (numSegments < 1 || count <= 0) return;
|
||
|
||
const baseMps = SPEED_MPS[road.type] || 5;
|
||
const bucket = flow ? flowBucket(flow.level) : null;
|
||
// Jam dots get +1px: a red queue should read as a queue at a glance.
|
||
// Preset-aware styling adds its own size delta and floors the base (0 /
|
||
// no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT.
|
||
const pixelSize =
|
||
parts.style.baseDotSize(road.type, bucket) +
|
||
(bucket === 'jam' ? 1 : 0) +
|
||
parts.style.activeSizeDelta(bucket);
|
||
const flowColor = bucket ? layerState._activeBucketColors[bucket] : null;
|
||
// Dark-halo outline under styled presets (null = shipped no-outline).
|
||
const outlineSpec =
|
||
bucket && layerState._presetDots === 'on'
|
||
? presetDotOutline(layerState._stylePreset, bucket)
|
||
: null;
|
||
const outlineColor = outlineSpec
|
||
? new Cesium.Color(
|
||
outlineSpec.rgba[0] / 255,
|
||
outlineSpec.rgba[1] / 255,
|
||
outlineSpec.rgba[2] / 255,
|
||
outlineSpec.rgba[3],
|
||
)
|
||
: null;
|
||
const flowSpeed = flow ? flowSpeedScale(flow.level) : 1;
|
||
const now = Date.now();
|
||
|
||
// Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper
|
||
// platoons (one shared direction per queue) instead of uniform scatter.
|
||
// Unreachable in sim mode — `bucket` requires flow.
|
||
let placements = null;
|
||
if (bucket === 'jam' && parts.style.jamDensityOn()) {
|
||
let totalLen = 0;
|
||
for (const d of road.segmentDist) totalLen += d;
|
||
const platoons = queuePlatoons(totalLen, count);
|
||
if (platoons.length) {
|
||
placements = [];
|
||
for (let p = 0; p < platoons.length; p++) {
|
||
const dir = road.oneway ? road.oneway : p % 2 === 0 ? 1 : -1;
|
||
for (const s of platoons[p]) {
|
||
const { segIdx, t } = locateAlongRoad(road.segmentDist, s);
|
||
placements.push({ segIdx, t, direction: dir });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
if (layerState._dots.length >= MAX_DOTS) return;
|
||
|
||
// Random start position: pick a random segment and offset within it —
|
||
// unless this is a queued jam dot with a platoon placement.
|
||
const segIdx = placements
|
||
? placements[i].segIdx
|
||
: Math.floor(Math.random() * numSegments);
|
||
const t = placements ? placements[i].t : Math.random();
|
||
|
||
// Speed noise: base speed +/-30% for organic variation. baseMps (noise
|
||
// included, flow excluded) is kept on the dot so a late-arriving flow
|
||
// match can rescale speed in place (recolorDotsInPlace).
|
||
const noisedMps =
|
||
baseMps * layerState._speedScale * (0.7 + Math.random() * 0.6);
|
||
const mps = noisedMps * flowSpeed;
|
||
|
||
// One-way roads flow only their legal direction; two-way alternates
|
||
// (per platoon in queue mode — a queue moves as one).
|
||
const direction = placements
|
||
? placements[i].direction
|
||
: road.oneway
|
||
? road.oneway
|
||
: i % 2 === 0
|
||
? 1
|
||
: -1;
|
||
|
||
// Compute initial Cartesian3 position via linear interpolation
|
||
Cesium.Cartesian3.lerp(
|
||
road.waypoints[segIdx],
|
||
road.waypoints[segIdx + 1],
|
||
t,
|
||
layerState._scratchLerp,
|
||
);
|
||
|
||
// Grounded traffic respects building/terrain occlusion. The former
|
||
// depth override made distant roads look like vehicles crossing roofs.
|
||
const jamProminent = bucket === 'jam' && parts.style.jamDensityOn();
|
||
const id = layerState._nextDotId++;
|
||
const point = layerState._pointCollection.add({
|
||
position: Cesium.Cartesian3.clone(layerState._scratchLerp),
|
||
pixelSize,
|
||
// No flow data → today's exact simulated white.
|
||
color: flowColor || Cesium.Color.WHITE.withAlpha(0.85),
|
||
scaleByDistance: new Cesium.NearFarScalar(
|
||
100,
|
||
1.5,
|
||
layerState._fadeScaleFar,
|
||
jamProminent ? JAM_DOT_FAR_SCALE : 0.3,
|
||
),
|
||
translucencyByDistance: new Cesium.NearFarScalar(
|
||
100,
|
||
1.0,
|
||
layerState._fadeTransFar,
|
||
0.0,
|
||
),
|
||
// Respect the sampled mesh at every viewing distance.
|
||
disableDepthTestDistance: 0,
|
||
// Preset dark halo (spread only when present — the keyless/normal
|
||
// path passes the exact shipped option set).
|
||
...(outlineSpec
|
||
? { outlineColor, outlineWidth: outlineSpec.width }
|
||
: {}),
|
||
});
|
||
layerState._bucketCounts[bucket || 'sim'] += 1;
|
||
|
||
layerState._motion.added++;
|
||
layerState._dots.push({
|
||
id,
|
||
index: layerState._dots.length,
|
||
born: now,
|
||
retiring: 0,
|
||
nominalSize: pixelSize,
|
||
recycle: false,
|
||
point,
|
||
road,
|
||
bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse
|
||
waypoints: road.waypoints,
|
||
segmentDist: road.segmentDist,
|
||
numSegments,
|
||
segIdx,
|
||
t,
|
||
mps, // meters per second (flow-scaled)
|
||
baseMps: noisedMps, // pre-flow speed, for in-place flow rescale
|
||
direction,
|
||
stoppedUntil: 0,
|
||
// Stop-and-go creep state (jam-viz density prototype): jam dots
|
||
// alternate move-bursts and stops. Null in sim mode and for non-jam.
|
||
creep:
|
||
bucket === 'jam' && parts.style.jamDensityOn()
|
||
? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 }
|
||
: null,
|
||
});
|
||
}
|
||
syncRenderDemand();
|
||
}
|
||
|
||
/**
|
||
* Per-frame animation callback registered on `scene.preRender`.
|
||
*
|
||
* For every active dot:
|
||
* 1. Ease heights and fade arrivals/departures, including paused dots.
|
||
* 2. Convert speed (m/s) to a parametric t-delta relative to the current
|
||
* segment's Cartesian distance.
|
||
* 3. Advance t in the dot's travel direction, handling segment boundary
|
||
* crossings and faded end-of-road departures.
|
||
* 4. Linearly interpolate between the two bounding waypoints and update the
|
||
* point primitive's position.
|
||
*
|
||
* Delta time is capped at 100 ms to prevent large jumps after background tabs.
|
||
*/
|
||
|
||
function animate() {
|
||
parts.rendering.retireHeatLines?.();
|
||
syncRenderDemand();
|
||
if (!layerState._renderHeld) return;
|
||
const now = Date.now();
|
||
// Delta time in seconds, capped to avoid jumps when returning from background tab
|
||
const dt = layerState._lastAnimTime
|
||
? Math.min((now - layerState._lastAnimTime) / 1000, 0.1)
|
||
: 0.016;
|
||
layerState._lastAnimTime = now;
|
||
|
||
parts.retention.easeHeights(dt);
|
||
for (let i = 0; i < layerState._dots.length; i++) {
|
||
const dot = layerState._dots[i];
|
||
if (dot.recycle) {
|
||
// A road-end departure is a new simulated vehicle at the entry, never
|
||
// a teleport attributed to the departed vehicle's stable identity.
|
||
dot.id = layerState._nextDotId++;
|
||
dot.segIdx = dot.direction > 0 ? 0 : dot.numSegments - 1;
|
||
dot.t = dot.direction > 0 ? 0 : 1;
|
||
dot.born = now;
|
||
dot.recycle = false;
|
||
layerState._motion.recycled++;
|
||
}
|
||
const remaining =
|
||
dot.direction > 0
|
||
? dot.segIdx === dot.numSegments - 1
|
||
? (1 - dot.t) * dot.segmentDist[dot.segIdx]
|
||
: Infinity
|
||
: dot.segIdx === 0
|
||
? dot.t * dot.segmentDist[0]
|
||
: Infinity;
|
||
const endpointFade = Math.min(1, remaining / Math.max(1, dot.mps * 0.25));
|
||
dot.point.pixelSize =
|
||
dot.nominalSize *
|
||
Math.max(
|
||
0,
|
||
Math.min(
|
||
1,
|
||
(now - dot.born) / DOT_FADE_MS,
|
||
dot.retiring ? 1 - (now - dot.retiring) / DOT_FADE_MS : 1,
|
||
endpointFade,
|
||
),
|
||
);
|
||
|
||
// Stop-and-go creep (jam-viz density prototype, live jam dots only):
|
||
// alternate short forward bursts with stops. The burst multiplier keeps
|
||
// the long-run average near the honest TomTom crawl speed.
|
||
let burst = now < dot.stoppedUntil ? 0 : 1;
|
||
if (dot.creep) {
|
||
if (now >= dot.creep.until) {
|
||
dot.creep.moving = !dot.creep.moving;
|
||
const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS;
|
||
dot.creep.until = now + lo + Math.random() * (hi - lo);
|
||
}
|
||
if (!dot.creep.moving) burst = 0;
|
||
else burst *= CREEP_BURST;
|
||
}
|
||
|
||
// Carry metres across bends, not a t fraction from a differently sized
|
||
// segment. Short segments cannot turn a slow car into a large jump.
|
||
let travel = dot.mps * burst * dt;
|
||
while (travel > 0) {
|
||
const length = Math.max(0.001, dot.segmentDist[dot.segIdx]);
|
||
const available = (dot.direction > 0 ? 1 - dot.t : dot.t) * length;
|
||
if (travel < available) {
|
||
dot.t += (travel / length) * dot.direction;
|
||
break;
|
||
}
|
||
travel -= available;
|
||
const next = dot.segIdx + dot.direction;
|
||
if (next < 0 || next >= dot.numSegments) {
|
||
dot.t = dot.direction > 0 ? 1 : 0;
|
||
dot.point.pixelSize = 0;
|
||
dot.recycle = true;
|
||
break;
|
||
}
|
||
dot.segIdx = next;
|
||
dot.t = dot.direction > 0 ? 0 : 1;
|
||
maybeStopLight(dot, now);
|
||
if (now < dot.stoppedUntil) break;
|
||
}
|
||
|
||
// Lerp between pre-computed Cartesian3 waypoints (no trig needed).
|
||
// Pass the scratch directly: PointPrimitive's position setter clones the
|
||
// value into its own storage (and skips the VBO dirty flag when equal), so
|
||
// the extra defensive clone here allocated 360–720k Cartesian3/s of pure
|
||
// garbage across 6000 dots. (perf item 5)
|
||
const a = dot.waypoints[dot.segIdx];
|
||
const b = dot.waypoints[dot.segIdx + 1];
|
||
Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp);
|
||
dot.point.position = layerState._scratchLerp;
|
||
}
|
||
|
||
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
|
||
// the whole jam batch, no geometry rebuild. Null outside live heatline
|
||
// mode; the steady flow lines (`lines` toggle) never pulse.
|
||
if (layerState._heatJamPrim?.appearance && !layerState._showLines) {
|
||
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
|
||
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
|
||
}
|
||
|
||
layerState._animFrame++;
|
||
}
|
||
|
||
/**
|
||
* Randomly pause a dot near road endpoints to simulate stop-light behaviour.
|
||
*
|
||
* Only triggers within the first 2 or last 2 segments of the road, and only
|
||
* with a very low per-frame probability (0.8%) to keep traffic flowing.
|
||
*
|
||
* @param {Object} dot - The dot state object.
|
||
* @param {number} now - Current timestamp in milliseconds.
|
||
*/
|
||
|
||
function maybeStopLight(dot, now) {
|
||
const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2;
|
||
if (nearEnd && Math.random() < 0.008) {
|
||
// Pause for 2–6 seconds
|
||
dot.stoppedUntil = now + 2000 + Math.random() * 4000;
|
||
}
|
||
}
|
||
|
||
// ─── Cleanup ───────────────────────────────────────────────
|
||
|
||
/** Remove all point primitives and reset dot/road arrays and counters. */
|
||
|
||
function clearDots() {
|
||
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
|
||
parts.retention.clear();
|
||
if (layerState._dots.length) layerState._motion.rebuilds++;
|
||
if (layerState._pointCollection) layerState._pointCollection.removeAll();
|
||
parts.rendering.removeHeatLines();
|
||
layerState._dots = [];
|
||
layerState._roads = [];
|
||
layerState._count = 0;
|
||
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
|
||
layerState._closedRoads = 0;
|
||
syncRenderDemand();
|
||
}
|
||
return {
|
||
spawnDotsForRoad,
|
||
animate,
|
||
maybeStopLight,
|
||
clearDots,
|
||
syncRenderDemand,
|
||
};
|
||
}
|