feat(preview): add vehicle incident cards

This commit is contained in:
2026-08-12 11:29:13 +08:00
parent f13d890ffb
commit 761a52646c
7 changed files with 271 additions and 6 deletions

View File

@@ -4,8 +4,13 @@ const fs = require("fs");
const path = require("path");
function writeCesiumPreviewSupportFiles(outDir) {
for (const file of ["cesium-preview.css", "cesium-preview.js"]) {
const source = path.join(__dirname, file);
const files = [
[path.join(__dirname, "cesium-preview.css"), "cesium-preview.css"],
[path.join(__dirname, "cesium-preview.js"), "cesium-preview.js"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-breakdown.png"), "vehicle-breakdown.png"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-accident.png"), "vehicle-accident.png"],
];
for (const [source, file] of files) {
if (!fs.existsSync(source)) {
throw new Error(`Cesium preview support file '${file}' not found: ${source}`);
}
@@ -69,6 +74,21 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
</div>
<div id="diagnostics">Loading diagnostics...</div>
<div id="status">Loading ${escapeHtml(glbName)}...</div>
<aside id="vehicleInfoCard" class="hidden" aria-live="polite">
<div class="vehicle-card-heading">
<strong id="vehicleInfoTitle">Vehicle</strong>
<button id="closeVehicleInfo" type="button" aria-label="Close vehicle information">Close</button>
</div>
<dl id="vehicleInfoDetails"></dl>
<label class="vehicle-card-field">Event note
<textarea id="vehicleIncidentNote" rows="2" maxlength="160" placeholder="Optional note"></textarea>
</label>
<div class="vehicle-card-actions" role="group" aria-label="Vehicle status">
<button type="button" data-vehicle-status="normal">Normal</button>
<button type="button" data-vehicle-status="breakdown">Breakdown</button>
<button type="button" data-vehicle-status="accident">Accident</button>
</div>
</aside>
<div id="loadingOverlay">
<div class="loading-card">
<div class="loading-spinner" aria-hidden="true"></div>

View File

@@ -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;
}
}

View File

@@ -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") {