Files
gods-eye-view/src/layers/traffic/rendering.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

392 lines
13 KiB
JavaScript

import * as Cesium from 'cesium';
import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
FLOW_BUCKET_COLORS,
FLOW_LINE_ALPHA,
FLOW_LINE_CAP,
FLOW_LINE_WIDTH,
HEAT_LINE_CAP,
HEAT_JAM_COLOR,
HEAT_SLOW_COLOR,
HEAT_LINE_JAM_WIDTH,
HEAT_JAM_BASE_ALPHA,
HEAT_LINE_SLOW_WIDTH,
TRAFFIC_TIMING_ENABLED,
} from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
/**
* At high altitude only major roads render — shared by the render pass and
* the late-flow heat-line rebuild so lines never mark roads without dots.
* @param {Array} roads - Parsed road objects.
* @param {number} altitude - Camera altitude in meters.
* @returns {Array} The roads visible at this altitude.
*/
function visibleRoadsForAltitude(roads, altitude) {
return altitude > 5000
? roads.filter(
(r) =>
r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary',
)
: roads;
}
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
/** Detach the current line primitives, leaving them in the scene. */
function takeHeatLines() {
const taken = [];
for (const key of LINE_KEYS) {
if (layerState[key]) taken.push(layerState[key]);
layerState[key] = null;
}
layerState._heatLineCount = 0;
return taken;
}
function removePrimitives(primitives) {
for (const primitive of primitives)
layerState._viewer?.scene.groundPrimitives.remove(primitive);
}
/** Remove the heat-line / flow-line ground primitives from the scene. */
function removeHeatLines() {
removePrimitives(takeHeatLines());
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
parts.animation.syncRenderDemand?.();
}
/**
* Replaced lines stay drawn until their successors finish building
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
* blinks. Called every held frame.
*/
function retireHeatLines() {
if (!layerState._heatRetired?.length) return;
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
return;
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
}
/**
* Rebuild the road-line underlay drawn from live flow, in one of two looks:
*
* - Flow lines (the `lines` row toggle): every road with flow drapes a
* steady line in its bucket color — green free-flow, amber slow, red jam.
* Capped at FLOW_LINE_CAP.
* - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a
* glowing pulsing red for jam and a faint flat amber for slow. Capped at
* HEAT_LINE_CAP.
*
* One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch
* pulses through one shared material. Kept roads are jam first, then slow,
* then free, longest first; overflow is logged. Lines drape on 3D tiles when
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
* sim mode, when both looks are off, or without ground-primitive support.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
const previous = takeHeatLines();
const finish = () => {
// Nothing new to wait for: the old lines go now.
if (LINE_KEYS.some((key) => layerState[key]))
(layerState._heatRetired ||= []).push(...previous);
else removePrimitives(previous);
parts.animation.syncRenderDemand?.();
};
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return finish();
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
);
if (!layerState._heatSupported)
console.warn(
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return finish();
const flowLines = layerState._showLines;
const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP;
const rank = { jam: 0, slow: 1, free: 2 };
const candidates = [];
for (const road of roads) {
const flow = road.flow;
if (!flow || flow.closure) continue;
const bucket = flowBucket(flow.level);
if (bucket === 'free' && !flowLines) continue;
let len = 0;
for (const d of road.segmentDist) len += d;
candidates.push({ road, bucket, len });
}
candidates.sort((a, b) =>
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
);
const kept = candidates.slice(0, cap);
const instancesFor = (bucket, width) =>
kept
.filter((c) => c.bucket === bucket)
.map(
(c) =>
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions: c.road.waypoints,
width,
}),
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
// luminance like the dots: jam = white, slow = gray, free = dim gray.
const mono =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha);
const classificationType =
layerState._viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: flowLines
? Cesium.ClassificationType.BOTH
: Cesium.ClassificationType.CESIUM_3D_TILE;
const add = (instances, material) =>
instances.length
? layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
classificationType,
appearance: new Cesium.PolylineMaterialAppearance({ material }),
}),
)
: null;
// Lines take the dots' active (preset-aware) colors.
const dotColor = (bucket) =>
layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket];
if (flowLines) {
const lineColor = {
jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'),
slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'),
free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'),
};
for (const [bucket, key] of [
['free', '_heatFreePrim'],
['slow', '_heatSlowPrim'],
['jam', '_heatJamPrim'],
])
layerState[key] = add(
instancesFor(bucket, FLOW_LINE_WIDTH),
Cesium.Material.fromType('Color', {
color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA),
}),
);
} else {
const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = mono
? gray(0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
layerState._heatJamPrim = add(
instancesFor('jam', HEAT_LINE_JAM_WIDTH),
Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
);
layerState._heatSlowPrim = add(
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
Cesium.Material.fromType('Color', { color: slowLineColor }),
);
}
layerState._heatLineCount = kept.length;
finish();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
);
}
}
/**
* Reconcile road populations without respawning retained dots.
*
* When zoomed out (>5 km), only major road types are rendered to reduce clutter.
* Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`.
*
* @param {Array} roads - Parsed road objects to render.
* @param {number} altitude - Camera altitude in meters.
* @param {string} label - Logging label (e.g. "Cache full", "Loaded major").
* @param {Object|null} [trace=null] - Development-only correlated load trace.
*/
function renderRoadsForAltitude(
roads,
altitude,
label,
trace = null,
replace = true,
) {
const state =
TRAFFIC_TIMING_ENABLED && trace
? parts.timing.trafficTimingRenderState(trace, label)
: null;
const renderId = state ? ++trace.renderSequence : null;
layerState._lastPaintStage = label;
layerState._lastRenderAltitude = altitude;
// At high altitude, drop minor roads to reduce visual noise
const filteredRoads = visibleRoadsForAltitude(roads, altitude);
// Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count
// them here so the closure signal is visible in stats even at zero dots.
layerState._closedRoads = layerState._liveMode
? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0)
: 0;
// Fade distances must track the camera-to-AREA distance, not assume a
// nadir view: oblique pitches put the loaded roads many km away even at
// low altitude. Probe three roads and stretch the curves accordingly.
let areaDist = altitude;
if (layerState._viewer && filteredRoads.length) {
const probes = [
filteredRoads[0],
filteredRoads[Math.floor(filteredRoads.length / 2)],
filteredRoads[filteredRoads.length - 1],
];
for (const probe of probes) {
const wp = probe?.waypoints?.[0];
if (wp)
areaDist = Math.max(
areaDist,
Cesium.Cartesian3.distance(
layerState._viewer.camera.positionWC,
wp,
),
);
}
}
layerState._fadeScaleFar = Math.max(8000, areaDist * 1.5);
layerState._fadeTransFar = Math.max(10000, areaDist * 1.8);
const dotStart = state
? parts.timing.trafficTimingMark(state, 'dot-construction-start', {
renderId,
renderLabel: label,
roadCount: roads.length,
visibleRoadCount: filteredRoads.length,
})
: null;
parts.retention.reconcile(filteredRoads, altitude, replace);
if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow))
services.credits?.showOsmCredit?.(layerState._viewer, 'traffic', {
openMapTiles: true,
});
else services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
parts.model.recolorDotsInPlace(label);
const renderMetrics = state
? {
renderId,
renderLabel: label,
roadCount: roads.length,
visibleRoadCount: filteredRoads.length,
dotCount: layerState._dots.length,
}
: null;
if (state) {
const dotEnd = parts.timing.trafficTimingMark(
state,
'dot-construction-end',
renderMetrics,
);
parts.timing.trafficTimingMeasure(
'dot-construction',
state,
dotStart,
dotEnd,
renderMetrics,
);
}
const heatStart = state
? parts.timing.trafficTimingMark(
state,
'rebuild-heat-lines-start',
renderMetrics,
)
: null;
// The flow recolor above already rebuilt the lines from every retained
// road; without dots to recolor, rebuild them here.
if (
!label.includes('tile') &&
!(layerState._liveMode && layerState._dots.length)
)
rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude));
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
'rebuild-heat-lines-end',
{
...renderMetrics,
heatLineCount: layerState._heatLineCount,
},
);
parts.timing.trafficTimingMeasure(
'rebuild-heat-lines',
state,
heatStart,
heatEnd,
{
...renderMetrics,
heatLineCount: layerState._heatLineCount,
},
);
}
parts.retention.refreshCounts();
layerState._lastUpdate = Date.now();
console.log(
`[Data:Traffic] ${label}: ${layerState._count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`,
);
if (state) {
const renderEnd = parts.timing.trafficTimingMark(
state,
'render-return',
renderMetrics,
);
parts.timing.scheduleTrafficTimingPostRender(
state,
renderEnd,
renderId,
renderMetrics,
);
}
}
return {
visibleRoadsForAltitude,
removeHeatLines,
retireHeatLines,
rebuildHeatLines,
renderRoadsForAltitude,
};
}