feat(preview): add traffic signal visualization
This commit is contained in:
91
scripts/lib/traffic-signals.js
Normal file
91
scripts/lib/traffic-signals.js
Normal file
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
const MAST_REACH_METERS = 4.5;
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections) {
|
||||
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 = [];
|
||||
for (const feature of stopLines.features || []) {
|
||||
const center = polygonCenter(feature.geometry);
|
||||
if (!center) continue;
|
||||
const intersection = nearestCenter(center, centers);
|
||||
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
|
||||
const axis = roadAxis(feature.geometry, center, intersection.point);
|
||||
if (!axis) continue;
|
||||
// A vehicle signal belongs beyond the junction, facing back toward the
|
||||
// approaching stop line. Use the far edge of the intersection, never the
|
||||
// near-side stop-line area where it would read as a pedestrian signal.
|
||||
const right = [axis[1], -axis[0]];
|
||||
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
|
||||
// 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}`,
|
||||
intersectionId: intersection.id,
|
||||
phaseGroup: signals.length % 2,
|
||||
longitude: point[0],
|
||||
latitude: point[1],
|
||||
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
});
|
||||
}
|
||||
return { version: 1, signals };
|
||||
}
|
||||
|
||||
function readTrafficSignals(stopLinePath, intersectionPath) {
|
||||
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")));
|
||||
}
|
||||
|
||||
function polygonCenter(geometry) {
|
||||
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
|
||||
if (!ring || ring.length < 4) return null;
|
||||
const points = ring.slice(0, -1);
|
||||
return [points.reduce((sum, point) => sum + point[0], 0) / points.length, points.reduce((sum, point) => sum + point[1], 0) / points.length];
|
||||
}
|
||||
|
||||
function polygonRadius(geometry, center) {
|
||||
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
|
||||
if (!ring || !center) return 0;
|
||||
return Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0);
|
||||
}
|
||||
|
||||
function roadAxis(geometry, center, target) {
|
||||
const ring = geometry?.coordinates?.[0];
|
||||
if (!ring || ring.length < 3) return null;
|
||||
let longest = null;
|
||||
for (let i = 0; i < ring.length - 1; i += 1) {
|
||||
const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180);
|
||||
const dy = ring[i + 1][1] - ring[i][1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!longest || length > longest.length) longest = { dx, dy, length };
|
||||
}
|
||||
if (!longest?.length) return null;
|
||||
let axis = [-longest.dy / longest.length, longest.dx / longest.length];
|
||||
const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]];
|
||||
if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]];
|
||||
return axis;
|
||||
}
|
||||
|
||||
function nearestCenter(point, centers) {
|
||||
return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null;
|
||||
}
|
||||
|
||||
function metersBetween(a, b) {
|
||||
const lat = (a[1] + b[1]) / 2 * Math.PI / 180;
|
||||
return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS;
|
||||
}
|
||||
|
||||
function moveMeters(point, vector, meters) {
|
||||
const scale = 180 / Math.PI / EARTH_RADIUS;
|
||||
return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale];
|
||||
}
|
||||
|
||||
module.exports = { buildTrafficSignals, readTrafficSignals };
|
||||
Reference in New Issue
Block a user