fix(preview): derive signals from OSM controls

This commit is contained in:
2026-08-07 09:05:27 +08:00
parent 950cd1c4cd
commit 1c077a312e
7 changed files with 315 additions and 73 deletions

View File

@@ -1,6 +1,7 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("./osm");
const EARTH_RADIUS = 6371008.8;
const CURB_OFFSET_METERS = 5.2;
@@ -32,12 +33,12 @@ const SIGNAL_LAYOUT = Object.freeze({
countdownVerticalOffsetMeters: 0.0,
});
function buildTrafficSignals(stopLines, intersections) {
function buildTrafficSignals(stopLines, intersections, controls = []) {
const centers = (intersections.features || []).map((feature, index) => {
const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
}).filter((entry) => entry.point);
const signals = [];
const candidates = [];
for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry);
if (!center) continue;
@@ -53,22 +54,116 @@ function buildTrafficSignals(stopLines, intersections) {
// The pole is on the far-side sidewalk, not at the stop line or inside
// the intersection. Its mast then reaches back above the approach lanes.
const point = moveMeters(farSide, right, CURB_OFFSET_METERS);
signals.push({
id: `signal-${signals.length + 1}`,
candidates.push({
intersectionId: intersection.id,
phaseGroup: signals.length % 2,
longitude: point[0],
latitude: point[1],
stopLongitude: center[0],
stopLatitude: center[1],
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
mastReachMeters: MAST_REACH_METERS,
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
center,
axis,
point,
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
});
}
const signals = [];
for (const control of controls) {
const controlPoint = [Number(control.longitude), Number(control.latitude)];
if (!controlPoint.every(Number.isFinite)) continue;
// A traffic-signal node on a through road is not a controlled vehicle
// junction. Its connected motor-road arms are the source of truth.
if (!Array.isArray(control.arms) || control.arms.length < 3) continue;
const intersection = nearestCenter(controlPoint, centers);
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
const arms = matchOsmArms(candidates.filter((candidate) => candidate.intersectionId === intersection.id), controlPoint, control.arms);
const groups = phaseGroups(arms);
for (const [index, candidate] of arms.entries()) {
signals.push({
id: `signal-${signals.length + 1}`,
controlId: String(control.id || ""),
intersectionId: intersection.id,
phaseGroup: groups[index],
longitude: candidate.point[0],
latitude: candidate.point[1],
stopLongitude: candidate.center[0],
stopLatitude: candidate.center[1],
headingDegrees: candidate.headingDegrees,
mastReachMeters: MAST_REACH_METERS,
pose: buildSignalPose(candidate.point, candidate.axis, MAST_REACH_METERS),
});
}
}
return { version: 3, layout: SIGNAL_LAYOUT, signals };
}
function uniqueApproachArms(candidates, controlPoint) {
const sorted = candidates.map((candidate) => ({
...candidate,
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
controlDistance: metersBetween(controlPoint, candidate.center),
})).sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
const arms = [];
for (const candidate of sorted) {
const duplicate = arms.find((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25);
if (!duplicate) arms.push(candidate);
}
return arms;
}
function matchOsmArms(candidates, controlPoint, osmArms) {
const withHeadings = candidates.map((candidate) => ({
...candidate,
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
}));
if (!Array.isArray(osmArms) || !osmArms.length) return uniqueApproachArms(withHeadings, controlPoint);
const remaining = withHeadings.slice();
const matched = [];
for (const osmArm of osmArms) {
let bestIndex = -1;
let bestDistance = Infinity;
for (let index = 0; index < remaining.length; index += 1) {
const distance = angularDistance(remaining[index].armHeading, osmArm.headingDegrees);
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
}
if (bestIndex >= 0 && bestDistance <= 45) {
matched.push(remaining.splice(bestIndex, 1)[0]);
} else {
matched.push(fallbackCandidate(controlPoint, osmArm));
}
}
return matched;
}
function fallbackCandidate(controlPoint, osmArm) {
const outward = headingVector(osmArm.headingDegrees);
const axis = [-outward[0], -outward[1]];
const stopDistance = 8.0;
const stop = moveMeters(controlPoint, outward, stopDistance);
const farSide = moveMeters(controlPoint, axis, 3.2);
return {
center: stop,
axis,
point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS),
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
fallback: true,
};
}
function phaseGroups(arms) {
const groups = Array(arms.length).fill(1);
if (arms.length < 2) return groups;
let main = [0, 1];
let bestOpposition = -1;
for (let left = 0; left < arms.length; left += 1) {
for (let right = left + 1; right < arms.length; right += 1) {
const opposition = angularDistance(arms[left].armHeading, arms[right].armHeading);
if (opposition > bestOpposition) {
bestOpposition = opposition;
main = [left, right];
}
}
}
groups[main[0]] = 0;
groups[main[1]] = 0;
return groups;
}
function buildSignalPose(pole, axis, mastReach) {
const lateral = [axis[1], -axis[0]];
const face = [-axis[0], -axis[1]];
@@ -95,8 +190,9 @@ function buildSignalPose(pole, axis, mastReach) {
};
}
function readTrafficSignals(stopLinePath, intersectionPath) {
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")));
function readTrafficSignals(stopLinePath, intersectionPath, osmPath) {
const controls = osmPath ? parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls : [];
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls);
}
function polygonCenter(geometry) {
@@ -143,4 +239,22 @@ function moveMeters(point, vector, meters) {
return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale];
}
function headingBetween(from, to) {
const latitude = (from[1] + to[1]) / 2 * Math.PI / 180;
return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI;
}
function headingVector(headingDegrees) {
const radians = headingDegrees * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
}
function normalizeDegrees(value) {
return ((value % 360) + 360) % 360;
}
function angularDistance(a, b) {
return Math.abs(((a - b + 540) % 360) - 180);
}
module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals };