feat: add native preview traffic simulation

This commit is contained in:
2026-08-18 16:57:27 +08:00
parent 0bc949bc24
commit 5403936ae4
21 changed files with 777 additions and 45 deletions

View File

@@ -555,16 +555,27 @@
.slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = trafficStart;
const assignments = [];
segments.forEach((segment, routeIndex) => {
// Put two vehicles on the first route so the native preview visibly
// exercises leader following and queue formation.
const vehicleCount = routeIndex === 0 ? 2 : 1;
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
assignments.push({ segment, routeIndex, vehicleIndex });
}
});
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = segments.length > 0;
viewer.clock.shouldAnimate = assignments.length > 0;
const vehicles = segments.map((segment, index) => {
const vehicles = assignments.map((assignment, index) => {
const { segment } = assignment;
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
const option = document.createElement("option");
option.value = String(index);
option.textContent = routeLabel(segment, index);
@@ -574,7 +585,8 @@
const cruise = {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
state: { selectedIndex: 0 },
simulation,
};
syncSelectedRouteVisibility(cruise);
return cruise;
@@ -732,10 +744,10 @@
return usable[Math.floor(Math.random() * usable.length)] || "";
}
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
const route = trafficMotion.route;
let record = null;
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
trafficMotion.setMoving(() => record?.status === "normal");
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
@@ -946,6 +958,106 @@
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
const config = {
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
minimumGapMeters: Number(settings.minimumGapMeters || 7),
};
const motions = assignments.map((assignment) => {
const route = prepareRoute(assignment.segment, signalData);
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
const state = {
distance: assignment.vehicleIndex * -initialGap,
speed: 0,
targetSpeed: config.desiredSpeedMetersPerSecond,
stopReason: null,
queueAhead: 0,
lastTime: start.clone(),
};
state.distance = (state.distance % route.length + route.length) % route.length;
return {
route,
state,
setMoving(callback) { state.isMoving = callback; },
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
};
});
const update = (clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
if (!(elapsed > 0) || elapsed > 2) {
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
return;
}
const grouped = new Map();
assignments.forEach((assignment, index) => {
const key = assignment.routeIndex;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(index);
});
for (const indexes of grouped.values()) {
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
}
motions.forEach((motion, index) => {
const state = motion.state;
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (state.isMoving && !state.isMoving()) {
state.speed = 0;
state.stopReason = "incident";
return;
}
const route = motion.route;
let target = config.desiredSpeedMetersPerSecond;
let stopReason = null;
const nextStop = nextRouteStop(route, state.distance);
if (nextStop) {
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
stopReason = "traffic-signal";
}
}
const peers = grouped.get(assignments[index].routeIndex) || [];
const peerPosition = peers.indexOf(index);
if (peerPosition >= 0 && peers.length > 1) {
const leaderIndex = peers[(peerPosition + 1) % peers.length];
if (leaderIndex !== index) {
const leader = motions[leaderIndex].state;
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
if (gap < safeGap + 12) {
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
if (target < 0.2) stopReason = "leader-gap";
state.queueAhead = leader.stopReason ? 1 : 0;
} else {
state.queueAhead = 0;
}
}
}
state.targetSpeed = target;
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
state.stopReason = state.speed < 0.2 ? stopReason : null;
});
};
viewer.clock.onTick.addEventListener(update);
return {
motions,
settings: config,
diagnostics() {
return {
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
};
},
};
}
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
const state = { distance: 0, lastTime: start.clone() };
viewer.clock.onTick.addEventListener((clock) => {
@@ -1230,6 +1342,12 @@
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
];
if (cruise.simulation) {
const simulation = cruise.simulation.diagnostics();
lines.push("Simulation: native-preview-traffic-simulation/v1");
lines.push("Stopped: " + simulation.stoppedVehicles);
lines.push("Queue: " + simulation.queueLength);
}
if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
}