Files
gods-eye-view/src/layers/traffic/animation.js
T
Sameh Khamis 9c8bb206ef feat(traffic): Lines and Dots toggles; hosts choose which road modes they offer
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.
2026-11-06 11:37:57 -08:00

385 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 { 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,
};
}