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

@@ -0,0 +1,259 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const SCHEMA = "native-preview-traffic-simulation/v1";
const MAX_ROUTES = 5;
const MIN_ROAD_COUNT = 3;
const MAX_ROAD_COUNT = 7;
const MAX_CONNECTION_GAP_METERS = 35;
const DEFAULT_SETTINGS = {
desiredSpeedMetersPerSecond: 8,
accelerationMetersPerSecondSquared: 1.8,
decelerationMetersPerSecondSquared: 3.5,
reactionTimeSeconds: 0.8,
vehicleLengthMeters: 4.6,
minimumGapMeters: 7,
};
function buildNativeTrafficSimulation(area, options = {}) {
const root = area.outputs.nativeRoadDir;
const compiledPath = path.join(root, "compiled.json");
const signalPath = path.join(root, "traffic-signals.json");
const connectorPath = path.join(root, "layers", "connectors.geojson");
const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson");
const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson");
for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) {
if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`);
}
const compiled = readJson(compiledPath, "native compiled roads");
const signals = readJson(signalPath, "native traffic signals");
const connectors = readFeatureCollection(connectorPath, "native connectors");
const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null;
const stopLines = readFeatureCollection(stopLinePath, "native stop lines");
const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) };
validateSettings(settings);
const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines);
return {
schema: SCHEMA,
areaId: area.id,
coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" },
generatedAt: new Date().toISOString(),
speedMetersPerSecond: settings.desiredSpeedMetersPerSecond,
source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]),
settings,
routes: routeBuild.routes,
signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor),
diagnostics: [
...routeBuild.diagnostics,
...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]),
],
rejectedRoutes: routeBuild.rejectedRoutes,
migration: {
schema: SCHEMA,
updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap",
signalIdentity: "signals[].id is the existing traffic runtime signal_uid",
coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest",
legacyDependency: false,
},
};
}
function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) {
const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road]));
const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId));
const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []]));
const laneCenterlineById = new Map();
for (const feature of laneCenterlineCollection?.features || []) {
const properties = feature.properties || {};
const coordinates = feature.geometry?.coordinates || [];
if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates);
if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates);
}
const outgoing = new Map();
for (const movement of movements) {
if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []);
outgoing.get(movement.fromRoadId).push(movement);
}
for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id));
const candidates = [];
const seen = new Set();
for (const start of [...roads.keys()].sort()) {
findCycles(start, start, [], [], outgoing, seen, candidates);
if (candidates.length >= MAX_ROUTES * 3) break;
}
const diagnostics = [];
const rejectedRoutes = [];
const routes = candidates
.map((candidate) => {
const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById);
if (!result.route) {
rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics });
diagnostics.push(...result.diagnostics);
}
return result.route;
})
.filter(Boolean)
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, MAX_ROUTES);
return { routes, diagnostics, rejectedRoutes };
}
function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) {
const nextRoadIds = [...roadIds, current];
if (nextRoadIds.length > MAX_ROAD_COUNT) return;
for (const movement of outgoing.get(current) || []) {
if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) {
if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue;
const signature = [...nextRoadIds, start].join("|");
if (!seen.has(signature)) {
seen.add(signature);
candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] });
}
continue;
}
if (nextRoadIds.includes(movement.toRoadId)) continue;
if (!laneSequenceCompatible(movements.at(-1), movement)) continue;
findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates);
}
}
function laneSequenceCompatible(previous, next) {
if (!previous || !previous.toLaneId || !next?.fromLaneId) return true;
return previous.toLaneId === next.fromLaneId;
}
function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) {
const coordinates = [];
const connectors = [];
const diagnostics = [];
for (let index = 0; index < candidate.roadIds.length; index += 1) {
const road = roads.get(candidate.roadIds[index]);
const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length];
const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId);
const roadLine = incomingLine || road.centerline;
append(coordinates, roadLine);
const movement = candidate.movements[index];
const connector = connectorByMovement.get(movement.id) || [];
if (connector.length < 2) {
diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry."));
return { route: null, diagnostics };
}
const startGap = distanceMeters(coordinates.at(-1), connector[0]);
if (startGap > MAX_CONNECTION_GAP_METERS) {
diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`));
return { route: null, diagnostics };
}
append(coordinates, connector);
if (index < candidate.roadIds.length - 1) {
const nextRoad = roads.get(candidate.roadIds[index + 1]);
const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline;
const endGap = distanceMeters(connector.at(-1), nextLine[0]);
if (endGap > MAX_CONNECTION_GAP_METERS) {
diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`));
return { route: null, diagnostics };
}
}
connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn });
}
if (coordinates.length < 4) return { route: null, diagnostics };
const distances = cumulativeDistances(coordinates);
const lengthMeters = distances.at(-1);
if (!(lengthMeters > 30)) return { route: null, diagnostics };
const stops = matchStops(coordinates, distances, signals, stopLineFeatures);
return { route: {
id: `native-loop:${candidate.roadIds.join("-")}`,
coordinates,
centerlineCoordinates: coordinates,
distances,
lengthMeters: round(lengthMeters),
edgeIds: candidate.roadIds,
laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })),
connectors,
maneuvers: connectors.map((connector) => connector.turn).filter(Boolean),
stops,
}, diagnostics };
}
function selectLaneCenterline(laneCenterlineById, road, laneId) {
if (!laneCenterlineById || !road) return null;
if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId);
const fallback = laneCenterlineById.get(`${road.id}:1`);
return fallback || null;
}
function routeDiagnostic(candidate, movement, reason, message) {
return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId };
}
function matchStops(coordinates, distances, signals, stopLineFeatures) {
const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean));
return signals.map((signal) => {
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null;
let best = { distance: Infinity, routeDistance: 0 };
for (let index = 0; index < coordinates.length; index += 1) {
const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
if (distance < best.distance) best = { distance, routeDistance: distances[index] };
}
if (best.distance > 15 || !stopRoadIds.size) return null;
return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" };
}).filter(Boolean).sort((a, b) => a.distance - b.distance);
}
function signalDescriptor(signal) {
return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true };
}
function sourceRecords(area, files) {
return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) }));
}
function append(target, points) {
for (const point of points || []) {
const coordinate = point.slice(0, 2).map(Number);
if (!coordinate.every(Number.isFinite)) continue;
const previous = target.at(-1);
if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate);
}
}
function cumulativeDistances(coordinates) {
const distances = [0];
for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index]));
return distances;
}
function haversine(a, b) {
const radians = Math.PI / 180;
const dLat = (b[1] - a[1]) * radians;
const dLon = (b[0] - a[0]) * radians;
const lat1 = a[1] * radians;
const lat2 = b[1] * radians;
const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function distanceMeters(a, b) {
return a && b ? haversine(a, b) : Infinity;
}
function readJson(file, label) {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); }
}
function readFeatureCollection(file, label) {
const value = readJson(file, label);
if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`);
return value;
}
function validateSettings(settings) {
for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`);
}
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
function round(value) { return Math.round(value * 100) / 100; }
module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation };