diff --git a/scripts/lib/cesium-preview.css b/scripts/lib/cesium-preview.css
index 957ca2f..9e5b221 100644
--- a/scripts/lib/cesium-preview.css
+++ b/scripts/lib/cesium-preview.css
@@ -20,7 +20,8 @@ body.scene-ready #cesiumContainer {
#controls,
#diagnostics,
-#status {
+#status,
+#vehicleInfoCard {
position: absolute;
z-index: 1;
border-radius: 4px;
@@ -160,6 +161,96 @@ body.scene-error .loading-bar span {
white-space: pre-line;
}
+#vehicleInfoCard {
+ left: 0;
+ top: 0;
+ width: min(300px, calc(100vw - 24px));
+ padding: 10px 12px;
+ transform: translate(-50%, calc(-100% - 18px));
+ transform-origin: bottom center;
+ pointer-events: auto;
+}
+
+#vehicleInfoCard::after {
+ position: absolute;
+ left: 50%;
+ bottom: -7px;
+ width: 12px;
+ height: 12px;
+ content: "";
+ transform: translateX(-50%) rotate(45deg);
+ background: rgba(20, 24, 28, 0.82);
+}
+
+.vehicle-card-heading,
+.vehicle-card-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.vehicle-card-heading strong {
+ font-size: 13px;
+}
+
+#vehicleInfoCard button {
+ height: 27px;
+ border: 0;
+ border-radius: 4px;
+ padding: 0 8px;
+ background: #f2f5f7;
+ color: #111;
+ cursor: pointer;
+}
+
+#vehicleInfoDetails {
+ display: grid;
+ grid-template-columns: 88px 1fr;
+ gap: 4px 8px;
+ margin: 10px 0;
+}
+
+#vehicleInfoDetails dt {
+ color: rgba(255, 255, 255, 0.62);
+}
+
+#vehicleInfoDetails dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.vehicle-card-field {
+ display: grid;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+#vehicleIncidentNote {
+ box-sizing: border-box;
+ width: 100%;
+ resize: vertical;
+ border: 1px solid rgba(255, 255, 255, 0.30);
+ border-radius: 4px;
+ padding: 6px;
+ background: rgba(255, 255, 255, 0.12);
+ color: #fff;
+ font: inherit;
+}
+
+.vehicle-card-actions {
+ justify-content: flex-start;
+ margin-top: 10px;
+}
+
+.vehicle-card-actions button[data-vehicle-status="breakdown"] {
+ background: #f3c84b;
+}
+
+.vehicle-card-actions button[data-vehicle-status="accident"] {
+ background: #e95950;
+}
+
#controls button,
#controls select {
height: 28px;
@@ -285,4 +376,5 @@ body.scene-error .loading-bar span {
bottom: 10px;
max-width: none;
}
+
}
diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js
index 153b2da..ded3754 100644
--- a/scripts/lib/cesium-preview.js
+++ b/scripts/lib/cesium-preview.js
@@ -16,6 +16,12 @@
const toggleFps = document.getElementById("toggleFps");
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
const assetToggles = document.getElementById("assetToggles");
+ const vehicleInfoCard = document.getElementById("vehicleInfoCard");
+ const vehicleInfoTitle = document.getElementById("vehicleInfoTitle");
+ const vehicleInfoDetails = document.getElementById("vehicleInfoDetails");
+ const vehicleIncidentNote = document.getElementById("vehicleIncidentNote");
+ const closeVehicleInfo = document.getElementById("closeVehicleInfo");
+ const vehicleStatusButtons = Array.from(document.querySelectorAll("[data-vehicle-status]"));
// The exported road surface sits at the 0.35m scene anchor plus 0.03m.
// Vehicle models have their wheels at local Y=0, so keep them just clear
// of the asphalt instead of using the old visibly floating 1.15m height.
@@ -56,6 +62,7 @@
buildAssetToggles(assets);
buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
+ bindVehicleInfoCard(viewer, cruise);
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
@@ -726,7 +733,8 @@
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
- const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed);
+ let record = null;
+ const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
@@ -761,14 +769,41 @@
minimumPixelSize: 0
}
});
- return {
+ record = {
+ id: "vehicle-" + (index + 1),
entity: vehicle,
routeEntity,
positions,
route,
segment,
label: routeLabel(segment, index),
+ modelName: vehicleModelLabel(vehicleModelName),
+ status: "normal",
+ incidentNote: "",
+ motion: trafficMotion,
+ routeColor,
+ markerEntity: null,
};
+ vehicle.properties = new Cesium.PropertyBag({ vehicleId: record.id });
+ record.markerEntity = viewer.entities.add({
+ name: "Vehicle incident marker " + (index + 1),
+ position: positions,
+ billboard: {
+ image: "",
+ show: false,
+ width: 36,
+ height: 44,
+ pixelOffset: new Cesium.Cartesian2(0, -40),
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
+ },
+ });
+ return record;
+ }
+
+ function vehicleModelLabel(name) {
+ const file = String(name || "").split("/").pop().replace(/\.gltf$/i, "");
+ return file.replace(/^.*-vehicle-/, "").replaceAll("_", " ");
}
function routeLabel(route, index) {
@@ -789,6 +824,96 @@
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
}
+ function vehicleById(cruise, id) {
+ return cruise.vehicles.find((vehicle) => vehicle.id === id) || null;
+ }
+
+ function setVehicleStatus(viewer, vehicle, status) {
+ if (!vehicle || !["normal", "breakdown", "accident"].includes(status)) return;
+ vehicle.status = status;
+ const incident = status !== "normal";
+ const accident = status === "accident";
+ vehicle.markerEntity.billboard.image = status === "breakdown"
+ ? "vehicle-breakdown.png"
+ : accident ? "vehicle-accident.png" : "";
+ vehicle.markerEntity.billboard.show = incident;
+ vehicle.routeEntity.polyline.material = accident ? Cesium.Color.RED : vehicle.routeColor;
+ if (viewer.scene.requestRender) viewer.scene.requestRender();
+ }
+
+ function bindVehicleInfoCard(viewer, cruise) {
+ if (!vehicleInfoCard || !cruise.vehicles.length) return;
+ let activeVehicle = null;
+ const render = () => {
+ if (!activeVehicle) return;
+ vehicleInfoTitle.textContent = activeVehicle.label;
+ vehicleIncidentNote.value = activeVehicle.incidentNote;
+ const details = [
+ ["Vehicle", activeVehicle.id],
+ ["Model", activeVehicle.modelName],
+ ["Route", activeVehicle.label],
+ ["Speed", cruise.baseSpeed + " m/s"],
+ ["Status", activeVehicle.status],
+ ];
+ vehicleInfoDetails.replaceChildren(...details.flatMap(([term, value]) => {
+ const dt = document.createElement("dt");
+ const dd = document.createElement("dd");
+ dt.textContent = term;
+ dd.textContent = value;
+ return [dt, dd];
+ }));
+ for (const button of vehicleStatusButtons) {
+ button.disabled = button.dataset.vehicleStatus === activeVehicle.status;
+ }
+ };
+ const open = (vehicle) => {
+ activeVehicle = vehicle;
+ const index = cruise.vehicles.indexOf(vehicle);
+ cruise.state.selectedIndex = index;
+ vehicleSelect.value = String(index);
+ syncSelectedRouteVisibility(cruise);
+ vehicleInfoCard.classList.remove("hidden");
+ render();
+ };
+ const positionCard = () => {
+ if (!activeVehicle || vehicleInfoCard.classList.contains("hidden")) return;
+ const position = activeVehicle.entity.position?.getValue(viewer.clock.currentTime);
+ const windowPosition = position && Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, position);
+ if (!windowPosition) {
+ vehicleInfoCard.classList.add("hidden");
+ return;
+ }
+ const margin = 12;
+ const width = vehicleInfoCard.offsetWidth || 300;
+ const x = Cesium.Math.clamp(windowPosition.x, margin + width / 2, window.innerWidth - margin - width / 2);
+ vehicleInfoCard.style.left = x + "px";
+ vehicleInfoCard.style.top = Math.max(windowPosition.y, margin + 20) + "px";
+ };
+ closeVehicleInfo.addEventListener("click", () => {
+ activeVehicle = null;
+ vehicleInfoCard.classList.add("hidden");
+ });
+ vehicleIncidentNote.addEventListener("input", () => {
+ if (activeVehicle) activeVehicle.incidentNote = vehicleIncidentNote.value.trim();
+ });
+ for (const button of vehicleStatusButtons) {
+ button.addEventListener("click", () => {
+ if (!activeVehicle) return;
+ setVehicleStatus(viewer, activeVehicle, button.dataset.vehicleStatus);
+ render();
+ setStatus(activeVehicle.label + " " + activeVehicle.status);
+ });
+ }
+ const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
+ handler.setInputAction((movement) => {
+ const picked = viewer.scene.pick(movement.position);
+ const id = picked?.id?.properties?.vehicleId?.getValue?.();
+ const vehicle = vehicleById(cruise, id);
+ if (vehicle) open(vehicle);
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+ viewer.scene.postRender.addEventListener(positionCard);
+ }
+
function prepareRoute(segment, signalData) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
@@ -820,12 +945,13 @@
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
- function createTrafficAwarePositions(viewer, route, start, speed) {
+ function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
const state = { distance: 0, lastTime: start.clone() };
viewer.clock.onTick.addEventListener((clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime);
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (elapsed <= 0) return;
+ if (!isMoving()) return;
const next = nextRouteStop(route, state.distance);
const advance = elapsed * speed;
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {
diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js
index 3a656e9..24b1156 100644
--- a/scripts/test-preview-assets.js
+++ b/scripts/test-preview-assets.js
@@ -251,6 +251,10 @@ assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
assert.match(html, /id="toggleSignals"/);
+assert.match(html, /id="vehicleInfoCard" class="hidden"/);
+assert.match(html, /id="vehicleIncidentNote"/);
+assert.match(html, /data-vehicle-status="breakdown"/);
+assert.match(html, /data-vehicle-status="accident"/);
assert.match(html, /id="toggleBuildingGhost"/);
assert.match(html, /id="viewMode"/);
assert.match(html, /data-view-mode="inspect"/);
@@ -264,6 +268,14 @@ assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned w
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
assert.match(previewRuntime, /asset\.category === "dynamic"/);
+assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/);
+assert.match(previewRuntime, /vehicleId: record\.id/);
+assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/);
+assert.match(previewRuntime, /accident \? "vehicle-accident\.png"/);
+assert.match(previewRuntime, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
+assert.match(previewRuntime, /routeEntity\.polyline\.material = accident \? Cesium\.Color\.RED : vehicle\.routeColor/);
+assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
+assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);