feat(preview): add traffic signal visualization

This commit is contained in:
2026-08-05 18:09:37 +08:00
parent c8ca009d13
commit 8b3a1d79d9
12 changed files with 438 additions and 7 deletions

View File

@@ -10,6 +10,8 @@
const toggleScene = document.getElementById("toggleScene");
const toggleRoutes = document.getElementById("toggleRoutes");
const toggleVehicles = document.getElementById("toggleVehicles");
const toggleSignals = document.getElementById("toggleSignals");
const signalsControl = document.getElementById("signalsControl");
const toggleFps = document.getElementById("toggleFps");
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
const assetToggles = document.getElementById("assetToggles");
@@ -33,17 +35,19 @@
setLoadingMessage("Loading scene", config.areaId || "");
const metadata = await fetchJson(config.metadataName);
const routeData = await fetchOptionalJson(config.routeName);
const signalData = await fetchOptionalJson(config.trafficSignalsName);
const placement = scenePlacement(metadata);
const viewer = createViewer();
setLoadingMessage("Loading model", config.glbName || "");
const assets = await loadSceneAssets(viewer, metadata, placement);
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName);
const trafficSignals = addTrafficSignals(viewer, signalData);
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
buildAssetToggles(assets);
buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement);
startDiagnostics(viewer, metadata, assets, cruise, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
setStatus(baseStatus);
@@ -52,7 +56,7 @@
document.body.classList.add("scene-ready");
// Handle for the browser console and for headless checks: everything else
// in here is closed over by the IIFE and unreachable from outside.
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras };
}
async function fetchJson(url) {
@@ -272,7 +276,7 @@
}
}
function bindRuntimeControls(viewer, assets, cruise, cameras, placement) {
function bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals) {
const hasVehicles = cruise.vehicles.length > 0;
const hasSemanticAssets = semanticAssets(assets).length > 0;
const sceneLabel = toggleScene.closest("label");
@@ -292,6 +296,14 @@
toggleVehicles.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
});
if (!trafficSignals.entities.length) {
signalsControl.classList.add("hidden");
} else {
toggleSignals.addEventListener("change", () => {
trafficSignals.show = toggleSignals.checked;
setStatus(toggleSignals.checked ? "Signals visible" : "Signals hidden");
});
}
toggleFps.addEventListener("change", () => {
viewer.scene.debugShowFramesPerSecond = toggleFps.checked;
});
@@ -451,6 +463,174 @@
};
}
function addTrafficSignals(viewer, signalData) {
const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude));
const entities = [];
const start = Cesium.JulianDate.now();
for (const signal of anchors) {
const mastReach = Number(signal.mastReachMeters) || 4.5;
const countdownOffset = -1.15;
const polePosition = signalPosition(signal, 0, 0, 3.35);
const headPosition = signalPosition(signal, 0, -mastReach, 6.25);
const frame = signalHeadFrame(signal, headPosition);
const pole = viewer.entities.add({
position: polePosition,
cylinder: { length: 6.7, topRadius: 0.10, bottomRadius: 0.14, material: Cesium.Color.fromCssColorString("#273139") },
});
// The mast arm begins at the curbside pole and reaches above the approach
// lanes. A separate mast at the opposite approach controls oncoming cars.
const arm = viewer.entities.add({
polyline: {
positions: [signalPosition(signal, 0, 0, 6.25), headPosition],
width: 9,
material: Cesium.Color.fromCssColorString("#273139"),
arcType: Cesium.ArcType.NONE,
},
});
const head = viewer.entities.add({
position: headPosition,
orientation: frame.orientation,
box: { dimensions: new Cesium.Cartesian3(0.68, 0.30, 1.62), material: Cesium.Color.fromCssColorString("#182024") },
});
entities.push(pole, arm, head);
for (const [index, state] of ["red", "yellow", "green"].entries()) {
const bulb = viewer.entities.add({
// The lens sits on the explicit approach-facing normal of the head,
// not at an angle inferred from the box's local axes.
position: signalLensPosition(headPosition, frame, 0.18, 0.49 - index * 0.50),
ellipsoid: {
radii: new Cesium.Cartesian3(0.22, 0.22, 0.22),
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalColor(signal.phaseGroup, state, time, start), false)),
},
});
entities.push(bulb);
}
// The countdown board mounts on the mast between the head and pole,
// rather than protruding beyond the signal on the roadway side.
const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0);
const counter = viewer.entities.add({
position: counterPosition,
orientation: frame.orientation,
box: {
dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56),
material: Cesium.Color.fromCssColorString("#251f1c"),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220),
},
});
const countdown = createSevenSegmentCountdown(
viewer,
signal,
start,
headPosition,
frame,
countdownOffset,
);
entities.push(counter, ...countdown);
}
return {
entities,
count: anchors.length,
set show(value) { for (const entity of entities) entity.show = value; },
};
}
function signalPosition(signal, longitudinalMeters, lateralMeters, height) {
const heading = Cesium.Math.toRadians(signal.headingDegrees);
const latitude = signal.latitude + (longitudinalMeters * Math.cos(heading) - lateralMeters * Math.sin(heading)) / 110540;
const longitude = signal.longitude + (longitudinalMeters * Math.sin(heading) + lateralMeters * Math.cos(heading)) /
(111320 * Math.cos(Cesium.Math.toRadians(signal.latitude)));
return Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
}
function signalHeadFrame(signal, position) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
// headingDegrees is the approach's travel direction into the junction.
// The signal's front must point back toward that approaching traffic.
const heading = Cesium.Math.toRadians(signal.headingDegrees);
const localFace = new Cesium.Cartesian3(-Math.sin(heading), -Math.cos(heading), 0);
const face = Cesium.Matrix4.multiplyByPointAsVector(enu, localFace, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(face, face);
const up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3());
const across = Cesium.Cartesian3.cross(face, up, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(across, across);
const rotation = new Cesium.Matrix3(
across.x, face.x, up.x,
across.y, face.y, up.y,
across.z, face.z, up.z,
);
return { across, face, up, orientation: Cesium.Quaternion.fromRotationMatrix(rotation, new Cesium.Quaternion()) };
}
function signalLensPosition(headPosition, frame, faceOffset, verticalOffset) {
const point = Cesium.Cartesian3.multiplyByScalar(frame.face, faceOffset, new Cesium.Cartesian3());
Cesium.Cartesian3.add(headPosition, point, point);
const vertical = Cesium.Cartesian3.multiplyByScalar(frame.up, verticalOffset, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(point, vertical, point);
}
function signalPanelPosition(headPosition, frame, acrossOffset, faceOffset, verticalOffset) {
const point = signalLensPosition(headPosition, frame, faceOffset, verticalOffset);
const across = Cesium.Cartesian3.multiplyByScalar(frame.across, acrossOffset, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(point, across, point);
}
function createSevenSegmentCountdown(viewer, signal, start, headPosition, frame, boardAcross) {
const digitMap = { "0": "abcedf", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd" };
const shape = {
a: [0, 0.18, 0.20, 0.025, 0.035], b: [0.10, 0.085, 0.035, 0.025, 0.15],
c: [0.10, -0.085, 0.035, 0.025, 0.15], d: [0, -0.18, 0.20, 0.025, 0.035],
e: [-0.10, -0.085, 0.035, 0.025, 0.15], f: [-0.10, 0.085, 0.035, 0.025, 0.15],
g: [0, 0, 0.20, 0.025, 0.035],
};
const result = [];
for (const [digitIndex, digitAcross] of [-0.17, 0.17].entries()) {
for (const [name, [x, z, width, depth, height]] of Object.entries(shape)) {
result.push(viewer.entities.add({
// The visible panel x-axis is the inverse of the signal frame's
// across axis. Mirror the LED layout once here to keep digits normal.
position: signalPanelPosition(headPosition, frame, boardAcross - digitAcross - x, 0.18, z),
orientation: frame.orientation,
box: {
dimensions: new Cesium.Cartesian3(width, depth, height),
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalActiveColor(signal.phaseGroup, time, start), false)),
show: new Cesium.CallbackProperty((time) => {
const value = String(signalPhase(signal.phaseGroup, time, start).remaining).padStart(2, "0")[digitIndex];
return (digitMap[value] || "").includes(name);
}, false),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220),
},
}));
}
}
return result;
}
function signalColor(group, state, time, start) {
const active = signalPhase(group, time, start).active;
const color = signalBaseColor(state);
return state === active ? color : Cesium.Color.multiplyByScalar(color, 0.35, new Cesium.Color());
}
function signalActiveColor(group, time, start) {
return signalBaseColor(signalPhase(group, time, start).active);
}
function signalBaseColor(state) {
return Cesium.Color.fromCssColorString({ red: "#ee3f39", yellow: "#f7bf37", green: "#43cf71" }[state]);
}
function signalPhase(group, time, start) {
const second = ((Cesium.JulianDate.secondsDifference(time, start) % 20) + 20) % 20;
if (group === 0) {
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };
return { active: "red", remaining: Math.ceil(20 - second) };
}
if (second < 10) return { active: "red", remaining: Math.ceil(10 - second) };
if (second < 18) return { active: "green", remaining: Math.ceil(18 - second) };
return { active: "yellow", remaining: Math.ceil(20 - second) };
}
function selectedVehicleModelName(modelNames, fallbackModelName) {
const choices = Array.isArray(modelNames) && modelNames.length
? modelNames
@@ -731,7 +911,7 @@
// Camera-dependent readouts have to track the camera, so refresh off the
// render loop rather than a fixed timer, throttled to stay off the hot path.
function startDiagnostics(viewer, metadata, assets, cruise, placement) {
function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) {
const center = placement.position;
const stats = metadata.scene_stats || {};
const failed = assets.filter((asset) => asset.error);
@@ -747,6 +927,7 @@
"Camera range: " + Math.round(distance) + " m",
"Assets: " + liveAssets(assets).length + " model(s)",
"Vehicles: " + cruise.vehicles.length,
"Signals: " + trafficSignals.count,
"Buildings: " + Number(stats.buildings || 0),
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")