mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +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.
392 lines
13 KiB
JavaScript
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,
|
|
};
|
|
}
|