refactor: move road compiler core into package
This commit is contained in:
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@osm-asset/road-compiler",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node test/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/compile/compiler.js
Normal file
141
src/compile/compiler.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./native-road");
|
||||||
|
const { loadOrGenerate, runtime } = require("../native-traffic-signals");
|
||||||
|
|
||||||
|
function compileInput(input) {
|
||||||
|
validateInput(input);
|
||||||
|
const area = {
|
||||||
|
id: input.areaId,
|
||||||
|
input: input.osmFile,
|
||||||
|
nativeRoad: input.options,
|
||||||
|
outputs: {
|
||||||
|
nativeRoadOverrides: input.overridesFile,
|
||||||
|
nativeTrafficSignals: input.trafficSignalsFile,
|
||||||
|
nativeRoadDir: input.outDir,
|
||||||
|
pipelineDir: input.stagingDir,
|
||||||
|
geojsonDir: input.comparisonDir || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
||||||
|
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
||||||
|
// Editing the source OSM retires the ids some overrides point at. Those
|
||||||
|
// entries can no longer match anything, so drop them with a diagnostic rather
|
||||||
|
// than aborting the whole compile — otherwise every OSM edit blocks the
|
||||||
|
// pipeline until the file is hand-pruned, one error message at a time.
|
||||||
|
const validated = validateOverrides(overrides, model, { skipStaleTargets: true });
|
||||||
|
for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`);
|
||||||
|
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
|
||||||
|
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates });
|
||||||
|
compiled.diagnostics.push(...validated.stale.map((item) => ({
|
||||||
|
id: `diagnostic:stale-override:${item.id}`,
|
||||||
|
severity: "warning",
|
||||||
|
subjectId: item.id,
|
||||||
|
sourceIds: [],
|
||||||
|
rule: "stale-override-target",
|
||||||
|
message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在(OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`,
|
||||||
|
geometry: null,
|
||||||
|
})));
|
||||||
|
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
|
||||||
|
const signalRuntime = runtime(signalDocument);
|
||||||
|
// Persist validation normalization, including one-time legacy heading migration.
|
||||||
|
writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument);
|
||||||
|
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
|
||||||
|
try {
|
||||||
|
const result = {
|
||||||
|
schema: "native-road-compiled/v1",
|
||||||
|
areaId: area.id,
|
||||||
|
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals },
|
||||||
|
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
|
||||||
|
movements: compiled.movements,
|
||||||
|
trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length },
|
||||||
|
diagnostics: compiled.diagnostics,
|
||||||
|
layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" },
|
||||||
|
};
|
||||||
|
const comparison = compareOsm2Streets(area, result.model, compiled);
|
||||||
|
writeJsonAtomic(path.join(staging, "compiled.json"), result);
|
||||||
|
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
|
||||||
|
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
|
||||||
|
writeJsonAtomic(path.join(staging, "traffic-signal-assemblies.json"), signalDocument.assemblies);
|
||||||
|
writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines);
|
||||||
|
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
|
||||||
|
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||||
|
return { area, result, comparison };
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareOsm2Streets(area, model, compiled) {
|
||||||
|
const source = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null;
|
||||||
|
let featureCount = null;
|
||||||
|
if (source && fs.existsSync(source)) {
|
||||||
|
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
|
||||||
|
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
|
||||||
|
}
|
||||||
|
const diagnosticsBySeverity = {};
|
||||||
|
const diagnosticsByRule = {};
|
||||||
|
for (const item of compiled.diagnostics) {
|
||||||
|
diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1;
|
||||||
|
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
|
||||||
|
}
|
||||||
|
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
|
||||||
|
const junctions = compiled.intersectionSurface.features;
|
||||||
|
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
|
||||||
|
return {
|
||||||
|
schema: "native-road-comparison/v2",
|
||||||
|
nativeRoadCount: model.roads.length,
|
||||||
|
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
|
||||||
|
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
|
||||||
|
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
|
||||||
|
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
|
||||||
|
nativeFallbackJunctions: fallbackJunctions.length,
|
||||||
|
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
|
||||||
|
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
|
||||||
|
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
|
||||||
|
nativeCenterLineFeatures: compiled.centerLines.features.length,
|
||||||
|
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
|
||||||
|
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
|
||||||
|
nativeCrosswalkFeatures: compiled.crosswalks.features.length,
|
||||||
|
nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length,
|
||||||
|
nativeConnectorFeatures: compiled.connectors.features.length,
|
||||||
|
nativeMovementCount: compiled.movements.length,
|
||||||
|
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
|
||||||
|
nativeConnectionCount: model.connections.length,
|
||||||
|
unconnectedInteriorRoadEnds: dangling.length,
|
||||||
|
unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length,
|
||||||
|
diagnosticsBySeverity,
|
||||||
|
diagnosticsByRule,
|
||||||
|
osm2streetsRoadSurfaceFeatures: featureCount,
|
||||||
|
osm2streetsAvailable: featureCount !== null,
|
||||||
|
note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInput(input) {
|
||||||
|
if (!input || typeof input !== "object") throw new Error("RoadCompilerInput must be an object");
|
||||||
|
for (const key of ["areaId", "osmFile", "outDir", "stagingDir", "overridesFile", "trafficSignalsFile"]) {
|
||||||
|
if (typeof input[key] !== "string" || input[key].trim() === "") throw new Error(`RoadCompilerInput.${key} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
if (!input.options || typeof input.options !== "object") throw new Error("RoadCompilerInput.options must be an object");
|
||||||
|
if (typeof input.options.edgeLines !== "boolean") throw new Error("RoadCompilerInput.options.edgeLines must be a boolean");
|
||||||
|
if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== "object") throw new Error("RoadCompilerInput.options.junctionTemplates must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { compileInput, validateInput };
|
||||||
474
src/compile/complex-junction.js
Normal file
474
src/compile/complex-junction.js
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const { convertGeoJson, boundsOf } = require("../reference/gaode");
|
||||||
|
const metricsCache = new WeakMap();
|
||||||
|
const CORNER_FILLET_SEGMENTS = 12;
|
||||||
|
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
|
||||||
|
// lines up with the straight strips it joins.
|
||||||
|
const SIDEWALK_WIDTH_METERS = 2;
|
||||||
|
// The straight strips are trimmed against the cluster boundary using the road
|
||||||
|
// centerline, so they stop a little beyond the carriageway end. Run the corner
|
||||||
|
// past that end and let the two overlap rather than chase an exact seam.
|
||||||
|
const SIDEWALK_CORNER_OVERRUN_METERS = 6;
|
||||||
|
|
||||||
|
function buildComplexJunctionGeometry(model, cluster, helpers) {
|
||||||
|
const nodeIds = new Set(cluster.nodeIds.map(String));
|
||||||
|
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean);
|
||||||
|
if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] };
|
||||||
|
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
|
||||||
|
const approaches = [];
|
||||||
|
const carriageways = [];
|
||||||
|
for (const [nodeId, plan] of helpers.junctionPlans) {
|
||||||
|
if (!nodeIds.has(String(nodeId))) continue;
|
||||||
|
for (const approach of plan.approaches) {
|
||||||
|
const endpoint = approach.line.at(-1);
|
||||||
|
if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue;
|
||||||
|
const heading = helpers.headingAtEndpoint(approach.line);
|
||||||
|
const length = helpers.lineLengthMeters(approach.line);
|
||||||
|
carriageways.push({ nodeId, approach, plan, heading, length });
|
||||||
|
if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue;
|
||||||
|
approaches.push({ nodeId, approach, plan, heading, length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] };
|
||||||
|
const { calibration, coreRadius } = complexJunctionMetrics(cluster);
|
||||||
|
const sorted = [...approaches].sort((a, b) => a.heading - b.heading);
|
||||||
|
const arms = sorted.map((representative) => ({
|
||||||
|
representative,
|
||||||
|
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)),
|
||||||
|
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20),
|
||||||
|
}));
|
||||||
|
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
|
||||||
|
const boundaryParts = [];
|
||||||
|
const crosswalks = [];
|
||||||
|
const stopLines = [];
|
||||||
|
const islands = [];
|
||||||
|
const armCrosswalkRadius = coreRadius * .68;
|
||||||
|
for (const item of carriageways) {
|
||||||
|
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
|
||||||
|
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers);
|
||||||
|
const outerHalf = item.approach.widthMeters / 2;
|
||||||
|
const innerHalf = outerHalf;
|
||||||
|
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf });
|
||||||
|
const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
|
||||||
|
if (incomingRoad) {
|
||||||
|
// Keep the stop bar just outside the road crosswalk. The previous fixed
|
||||||
|
// core-radius offset placed it nearly ten metres beyond the crossing.
|
||||||
|
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
|
||||||
|
const ring = [
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
|
||||||
|
];
|
||||||
|
stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The four support lines provide a common corner frame, but each long
|
||||||
|
// crossing remains clipped to its OSM-derived road envelope. Corner islands
|
||||||
|
// fill the remaining frame gaps; crossings must never do that job.
|
||||||
|
for (const arm of arms) {
|
||||||
|
const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers);
|
||||||
|
if (!envelope) continue;
|
||||||
|
arm.crosswalkFrame = {
|
||||||
|
center: envelope.center,
|
||||||
|
groupDepth: 3.4,
|
||||||
|
supportHeading: normalizeHeading(arm.heading + 90),
|
||||||
|
envelopeWidthMeters: envelope.widthMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const frameCorners = arms.map((arm, index) => {
|
||||||
|
const next = arms[(index + 1) % arms.length];
|
||||||
|
const delta = positiveHeadingDelta(arm.heading, next.heading);
|
||||||
|
if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null;
|
||||||
|
return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center);
|
||||||
|
});
|
||||||
|
for (let index = 0; index < arms.length; index += 1) {
|
||||||
|
const arm = arms[index];
|
||||||
|
if (!arm.crosswalkFrame) continue;
|
||||||
|
const item = arm.representative;
|
||||||
|
const roadEdgeInset = .35;
|
||||||
|
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
|
||||||
|
const endpoints = [
|
||||||
|
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
|
||||||
|
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2),
|
||||||
|
];
|
||||||
|
const groupDepth = arm.crosswalkFrame.groupDepth;
|
||||||
|
const stripeWidth = .42;
|
||||||
|
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1);
|
||||||
|
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
|
||||||
|
arm.crosswalkFrame.center = midpoint(...endpoints);
|
||||||
|
arm.crosswalkFrame.endpoints = endpoints;
|
||||||
|
arm.crosswalkFrame.spanMeters = usableSpan;
|
||||||
|
arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset;
|
||||||
|
for (let stripe = 0; stripe < stripeCount; stripe += 1) {
|
||||||
|
const along = stripeWidth / 2 + stripe * stripeSpacing;
|
||||||
|
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
|
||||||
|
const ring = [
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
||||||
|
];
|
||||||
|
crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The four arm groups are the sides of one pedestrian frame. Each diagonal
|
||||||
|
// group is anchored at the intersection of its adjacent side support lines,
|
||||||
|
// so all eight groups stay one composition when the OSM arms are skewed.
|
||||||
|
for (let index = 0; index < arms.length; index += 1) {
|
||||||
|
const first = arms[index];
|
||||||
|
const second = arms[(index + 1) % arms.length];
|
||||||
|
const delta = positiveHeadingDelta(first.heading, second.heading);
|
||||||
|
if (delta < 45 || delta > 135) continue;
|
||||||
|
if (!first.crosswalkFrame || !second.crosswalkFrame) continue;
|
||||||
|
const bisector = normalizeHeading(first.heading + delta / 2);
|
||||||
|
const frameCorner = frameCorners[index];
|
||||||
|
if (!frameCorner) continue;
|
||||||
|
const cornerStripeSpacing = .62;
|
||||||
|
const cornerStripeWidth = .4;
|
||||||
|
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2;
|
||||||
|
const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0];
|
||||||
|
const outerEdgeAtCorner = (arm) => {
|
||||||
|
const endpoint = endpointForCorner(arm);
|
||||||
|
return [arm.heading, arm.heading + 180]
|
||||||
|
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
|
||||||
|
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0];
|
||||||
|
};
|
||||||
|
const islandBaseGap = .05;
|
||||||
|
const islandApexOffset = 1.5;
|
||||||
|
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap));
|
||||||
|
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
|
||||||
|
const islandCrossingClearance = .2;
|
||||||
|
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
|
||||||
|
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)));
|
||||||
|
const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector);
|
||||||
|
const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
|
||||||
|
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24);
|
||||||
|
islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } });
|
||||||
|
|
||||||
|
let cornerCrossingHalfSpan = .4;
|
||||||
|
for (let stripe = 0; stripe < 6; stripe += 1) {
|
||||||
|
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
|
||||||
|
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
|
||||||
|
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
|
||||||
|
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
|
||||||
|
if (!curbPair) continue;
|
||||||
|
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
|
||||||
|
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
|
||||||
|
const stripePair = [
|
||||||
|
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
|
||||||
|
helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan),
|
||||||
|
];
|
||||||
|
const ring = [
|
||||||
|
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2),
|
||||||
|
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
|
||||||
|
];
|
||||||
|
crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each carriageway ends in its own rectangle, so adjacent arms meet at a
|
||||||
|
// sharp notch instead of a curb. A real corner is one tangent-continuous
|
||||||
|
// sweep between the two outermost road edges, so fit a fixed-radius fillet
|
||||||
|
// into the wedge those edges form and fill the sector behind it.
|
||||||
|
const cornerFills = [];
|
||||||
|
const sidewalkCorners = [];
|
||||||
|
const cornerDiagnostics = [];
|
||||||
|
const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12));
|
||||||
|
for (let index = 0; index < arms.length; index += 1) {
|
||||||
|
const first = arms[index];
|
||||||
|
const second = arms[(index + 1) % arms.length];
|
||||||
|
const delta = positiveHeadingDelta(first.heading, second.heading);
|
||||||
|
if (delta < 45 || delta > 135) continue;
|
||||||
|
const bisector = normalizeHeading(first.heading + delta / 2);
|
||||||
|
const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers));
|
||||||
|
if (!edges.every(Boolean)) continue;
|
||||||
|
const apex = rayIntersection(edges[0], edges[1], center);
|
||||||
|
const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null;
|
||||||
|
// The wedge apex has to sit ahead of the core and inside the arm handoff;
|
||||||
|
// outside that band the two edges are near parallel and any fillet fitted
|
||||||
|
// to them would sweep across the carriageways instead of the corner.
|
||||||
|
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
|
||||||
|
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
|
||||||
|
// opening `delta`, clamped so the tangent points stay on the built arms.
|
||||||
|
const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach));
|
||||||
|
const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
|
||||||
|
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
|
||||||
|
const ring = [...curve, center, curve[0]];
|
||||||
|
if (!ring.every((point) => point.every(Number.isFinite))) continue;
|
||||||
|
cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
|
||||||
|
// The straight pedestrian strips are trimmed at the cluster boundary, so
|
||||||
|
// two arms that both carry a footway still meet as two loose ends across
|
||||||
|
// an empty wedge. Bridge them along the curb the fillet already defines.
|
||||||
|
// The corner faces clockwise from `first` and counter-clockwise from
|
||||||
|
// `second`, so each arm must carry the footway on that facing side.
|
||||||
|
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
|
||||||
|
const curb = [
|
||||||
|
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(),
|
||||||
|
...curve.slice(1, -1),
|
||||||
|
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers),
|
||||||
|
];
|
||||||
|
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
|
||||||
|
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
|
||||||
|
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) {
|
||||||
|
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } });
|
||||||
|
}
|
||||||
|
const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second));
|
||||||
|
const coreRing = roundedPolygonRing(corePoints, .16);
|
||||||
|
const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }];
|
||||||
|
for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) {
|
||||||
|
const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)];
|
||||||
|
features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
}
|
||||||
|
// Corner fills come last so they overlay the rectangular carriageway ends
|
||||||
|
// they are smoothing; they never replace an OSM-derived road surface.
|
||||||
|
features.push(...cornerFills);
|
||||||
|
// `coreRadiusMeters` is only consulted when there is no reference geometry.
|
||||||
|
// Under calibration the radius comes from the reference span, so a configured
|
||||||
|
// value that silently does nothing has to be reported, not swallowed.
|
||||||
|
const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5;
|
||||||
|
const configurationDiagnostics = configuredRadiusIgnored
|
||||||
|
? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)]
|
||||||
|
: [];
|
||||||
|
return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReferenceCalibration(cluster) {
|
||||||
|
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
|
||||||
|
try {
|
||||||
|
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8")));
|
||||||
|
const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) });
|
||||||
|
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180);
|
||||||
|
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 };
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function complexJunctionMetrics(cluster) {
|
||||||
|
if (metricsCache.has(cluster)) return metricsCache.get(cluster);
|
||||||
|
const calibration = readReferenceCalibration(cluster);
|
||||||
|
// Without a reference geometry `coreRadiusMeters` is the only size input the
|
||||||
|
// template has, so honour it literally within the schema's own 12..80 range.
|
||||||
|
// It used to be scaled by .52 and clamped to 17, which silently capped every
|
||||||
|
// unreferenced junction at a core far smaller than its own approach envelope
|
||||||
|
// — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter
|
||||||
|
// what the config asked for. The calibrated branch is unchanged.
|
||||||
|
const coreRadius = calibration
|
||||||
|
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
|
||||||
|
: Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28));
|
||||||
|
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
|
||||||
|
metricsCache.set(cluster, metrics);
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
|
||||||
|
|
||||||
|
// `arm.heading` points outward from the junction, so the corner clockwise from
|
||||||
|
// it sits at heading+90 and the one counter-clockwise at heading-90. A road's
|
||||||
|
// own sidewalk flags are relative to its digitisation direction, so flip them
|
||||||
|
// whenever the arm runs against that direction.
|
||||||
|
function armCarriesSidewalk(arm, model, cornerIsClockwise) {
|
||||||
|
return arm.members.some((member) => member.approach.roadIds
|
||||||
|
.map((roadId) => model.roads.find((road) => road.id === roadId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((road) => {
|
||||||
|
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
|
||||||
|
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
|
||||||
|
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
|
||||||
|
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk outward along a wedge edge from its tangent point until the curb reaches
|
||||||
|
// `targetRadius`, so the corner band overlaps the straight strip it joins.
|
||||||
|
function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) {
|
||||||
|
const points = [];
|
||||||
|
for (let extra = 0; extra <= 40; extra += 2) {
|
||||||
|
const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra);
|
||||||
|
points.push(point);
|
||||||
|
if (helpers.distanceMeters(point, center) >= targetRadius) break;
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset each vertex along the polyline normal that increases distance from the
|
||||||
|
// junction centre. The curb is star-shaped around that centre, so "farther from
|
||||||
|
// the centre" is a reliable stand-in for "on the pedestrian side".
|
||||||
|
function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
|
||||||
|
return points.map((point, index) => {
|
||||||
|
const previous = points[Math.max(0, index - 1)];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
const tangent = previous === next ? 0 : bearing(previous, next);
|
||||||
|
return [tangent + 90, tangent - 90]
|
||||||
|
.map((heading) => helpers.offsetCoordinate(point, heading, meters))
|
||||||
|
.sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ringSelfIntersects(ring) {
|
||||||
|
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||||
|
const straddles = (p1, p2, p3, p4) => {
|
||||||
|
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2);
|
||||||
|
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4);
|
||||||
|
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
||||||
|
};
|
||||||
|
for (let first = 0; first < ring.length - 1; first += 1) {
|
||||||
|
for (let second = first + 2; second < ring.length - 1; second += 1) {
|
||||||
|
if (first === 0 && second === ring.length - 2) continue;
|
||||||
|
if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
|
||||||
|
function signedLateralMeters(origin, point, heading) {
|
||||||
|
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||||
|
const north = (point[1] - origin[1]) * 111320;
|
||||||
|
const radians = (heading + 90) * Math.PI / 180;
|
||||||
|
return east * Math.sin(radians) + north * Math.cos(radians);
|
||||||
|
}
|
||||||
|
function bearing(first, second) {
|
||||||
|
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
|
||||||
|
const north = second[1] - first[1];
|
||||||
|
return Math.atan2(east, north) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; }
|
||||||
|
function averageHeading(headings) {
|
||||||
|
const vector = headings.reduce((sum, heading) => {
|
||||||
|
const radians = heading * Math.PI / 180;
|
||||||
|
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
|
||||||
|
}, [0, 0]);
|
||||||
|
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
|
||||||
|
function pointOnCarriagewayRadius(item, center, radius, helpers) {
|
||||||
|
const start = item.approach.line[0];
|
||||||
|
const startRadius = directionalProjectionMeters(center, start, item.heading);
|
||||||
|
return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius)));
|
||||||
|
}
|
||||||
|
function armEnvelopeAtRadius(arm, radius, center, helpers) {
|
||||||
|
if (!arm.members.length) return null;
|
||||||
|
const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers));
|
||||||
|
const reference = centers[0];
|
||||||
|
let minimum = Infinity;
|
||||||
|
let maximum = -Infinity;
|
||||||
|
centers.forEach((point, index) => {
|
||||||
|
const lateral = signedLateralMeters(reference, point, arm.heading);
|
||||||
|
const halfWidth = arm.members[index].approach.widthMeters / 2;
|
||||||
|
minimum = Math.min(minimum, lateral - halfWidth);
|
||||||
|
maximum = Math.max(maximum, lateral + halfWidth);
|
||||||
|
});
|
||||||
|
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
|
||||||
|
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum };
|
||||||
|
}
|
||||||
|
function supportLineIntersection(first, second, origin) {
|
||||||
|
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||||
|
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
|
||||||
|
const firstPoint = toLocal(first.center);
|
||||||
|
const secondPoint = toLocal(second.center);
|
||||||
|
const direction = (heading) => {
|
||||||
|
const radians = heading * Math.PI / 180;
|
||||||
|
return [Math.sin(radians), Math.cos(radians)];
|
||||||
|
};
|
||||||
|
const firstDirection = direction(first.supportHeading);
|
||||||
|
const secondDirection = direction(second.supportHeading);
|
||||||
|
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
|
||||||
|
if (Math.abs(denominator) < 1e-6) return null;
|
||||||
|
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
|
||||||
|
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
|
||||||
|
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
|
||||||
|
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
|
||||||
|
}
|
||||||
|
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
|
||||||
|
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)];
|
||||||
|
if (!pair.every(Boolean)) return null;
|
||||||
|
const width = helpers.distanceMeters(pair[0], pair[1]);
|
||||||
|
const middle = midpoint(pair[0], pair[1]);
|
||||||
|
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2);
|
||||||
|
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90;
|
||||||
|
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)];
|
||||||
|
}
|
||||||
|
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
|
||||||
|
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
|
||||||
|
}
|
||||||
|
function cornerEdgeAt(arm, radius, bisector, center, helpers) {
|
||||||
|
const candidates = arm.members.flatMap((member) => {
|
||||||
|
const point = pointOnCarriagewayRadius(member, center, radius, helpers);
|
||||||
|
const halfWidth = member.approach.widthMeters / 2;
|
||||||
|
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading }));
|
||||||
|
});
|
||||||
|
return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null;
|
||||||
|
}
|
||||||
|
function rayIntersection(first, second, origin) {
|
||||||
|
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||||
|
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
|
||||||
|
const direction = (heading) => {
|
||||||
|
const radians = heading * Math.PI / 180;
|
||||||
|
return [Math.sin(radians), Math.cos(radians)];
|
||||||
|
};
|
||||||
|
const firstPoint = toLocal(first.point);
|
||||||
|
const secondPoint = toLocal(second.point);
|
||||||
|
const firstDirection = direction(first.heading);
|
||||||
|
const secondDirection = direction(second.heading);
|
||||||
|
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
|
||||||
|
if (Math.abs(denominator) < 1e-4) return null;
|
||||||
|
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
|
||||||
|
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
|
||||||
|
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
|
||||||
|
if (!local.every(Number.isFinite)) return null;
|
||||||
|
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
|
||||||
|
}
|
||||||
|
function quadraticCurve(start, control, end, segments) {
|
||||||
|
const result = [];
|
||||||
|
for (let index = 0; index <= segments; index += 1) {
|
||||||
|
const t = index / segments;
|
||||||
|
const u = 1 - t;
|
||||||
|
result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function directionalProjectionMeters(origin, point, heading) {
|
||||||
|
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||||
|
const north = (point[1] - origin[1]) * 111320;
|
||||||
|
const radians = heading * Math.PI / 180;
|
||||||
|
return east * Math.sin(radians) + north * Math.cos(radians);
|
||||||
|
}
|
||||||
|
function smoothClosedRing(vertices) {
|
||||||
|
// Curb-edge candidates can arrive in opposite winding orders when an OSM
|
||||||
|
// carriageway bends slightly. Sort this local corner only around its own
|
||||||
|
// centroid before rounding, avoiding a self-crossing safety island while
|
||||||
|
// keeping the global junction boundary fully OSM-driven.
|
||||||
|
const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
|
||||||
|
const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0]));
|
||||||
|
const points = ordered.flatMap((point, index) => {
|
||||||
|
const next = ordered[(index + 1) % ordered.length];
|
||||||
|
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)];
|
||||||
|
});
|
||||||
|
return [...points, points[0]];
|
||||||
|
}
|
||||||
|
function roundedPolygonRing(vertices, ratio) {
|
||||||
|
const points = vertices.flatMap((point, index) => {
|
||||||
|
const previous = vertices[(index - 1 + vertices.length) % vertices.length];
|
||||||
|
const next = vertices[(index + 1) % vertices.length];
|
||||||
|
return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)];
|
||||||
|
});
|
||||||
|
return [...points, points[0]];
|
||||||
|
}
|
||||||
|
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; }
|
||||||
|
|
||||||
|
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };
|
||||||
1695
src/compile/native-road.js
Normal file
1695
src/compile/native-road.js
Normal file
File diff suppressed because it is too large
Load Diff
502
src/compile/turn-lane-arrows.js
Normal file
502
src/compile/turn-lane-arrows.js
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { laneCenterline } = require("../geometry/lane-geometry");
|
||||||
|
|
||||||
|
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "..", "..", "assets", "lane-icons", "manifest.json");
|
||||||
|
const LANE_WIDTH_METERS = 3.2;
|
||||||
|
const PLACEMENT_DISTANCE_METERS = 9;
|
||||||
|
const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18;
|
||||||
|
const SPATIAL_MATCH_MIN_ALIGNMENT = Math.cos(Math.PI / 6);
|
||||||
|
|
||||||
|
// Existing osm2streets lane arrows are approximately 1.4 m across. Keep the
|
||||||
|
// 25-unit upstream icon at the same on-road scale rather than at screen scale.
|
||||||
|
const SVG_METERS_PER_UNIT = 0.10;
|
||||||
|
|
||||||
|
function loadManifest(file = ASSET_MANIFEST) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
if (!Array.isArray(manifest.assets)) throw new Error("turn-lane asset manifest has no assets array");
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportedAssets(manifest = loadManifest()) {
|
||||||
|
return new Map(manifest.assets
|
||||||
|
.filter((asset) => asset.supported === true && asset.tested === true)
|
||||||
|
.map((asset) => [asset.id, asset]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomTurnLaneArrows(osm, options = {}) {
|
||||||
|
const enabled = options.enabled === true;
|
||||||
|
const diagnostics = [];
|
||||||
|
if (!enabled) return { features: [], diagnostics: [{ reason: "disabled" }] };
|
||||||
|
const assets = supportedAssets(options.manifest);
|
||||||
|
const endpointRoadCounts = roadCountsByNode(osm);
|
||||||
|
const networkIntersectionNodes = new Set((options.network?.intersections || [])
|
||||||
|
.flatMap(([, intersection]) => intersection.osm_ids || []).map(Number));
|
||||||
|
const features = [];
|
||||||
|
const ways = [...osm.ways.values()].sort((a, b) => a.id - b.id);
|
||||||
|
for (const way of ways) {
|
||||||
|
for (const direction of ["forward", "backward"]) {
|
||||||
|
const tag = way.tags[`turn:lanes:${direction}`];
|
||||||
|
if (!tag) continue;
|
||||||
|
const laneCount = directionalLaneCount(way, direction);
|
||||||
|
if (!laneCount) {
|
||||||
|
diagnostics.push(skip(way, direction, "missing_lane_count"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const endpoint = endpointGeometry(osm, way, direction, endpointRoadCounts, networkIntersectionNodes);
|
||||||
|
if (!endpoint) {
|
||||||
|
diagnostics.push(skip(way, direction, "indeterminate_intersection_endpoint"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value));
|
||||||
|
for (let laneIndex = 0; laneIndex < maneuvers.length; laneIndex += 1) {
|
||||||
|
const maneuver = maneuvers[laneIndex];
|
||||||
|
const asset = assets.get(maneuver);
|
||||||
|
if (!asset) {
|
||||||
|
diagnostics.push(skip(way, direction, "unsupported_or_untested_maneuver", { lane_index: laneIndex, maneuver }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (laneIndex >= laneCount) {
|
||||||
|
diagnostics.push(skip(way, direction, "lane_index_exceeds_lane_count", { lane_index: laneIndex, maneuver }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedPlacement = lanePlacement(way, direction, laneIndex, endpoint, options.lanePolygons, options.crosswalkStripes, options.stopLines);
|
||||||
|
if (resolvedPlacement?.blocked) {
|
||||||
|
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const placement = resolvedPlacement || fallbackLanePlacement(endpoint, direction, laneIndex, options.crosswalkStripes, options.stopLines);
|
||||||
|
if (!placement) {
|
||||||
|
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = templateFor(asset.id, options.manifest);
|
||||||
|
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
|
||||||
|
features.push(makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, parts[partIndex], placement.center, placement));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { features, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManeuver(value) {
|
||||||
|
const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort();
|
||||||
|
const supported = new Map([
|
||||||
|
["through", "through"], ["left", "left"], ["right", "right"],
|
||||||
|
["left;through", "through;left"], ["right;through", "through;right"],
|
||||||
|
["left;right;through", "through;left;right"],
|
||||||
|
]);
|
||||||
|
return supported.get(parts.join(";")) || parts.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionalLaneCount(way, direction) {
|
||||||
|
const specific = Number(way.tags[`lanes:${direction}`]);
|
||||||
|
if (Number.isInteger(specific) && specific > 0) return specific;
|
||||||
|
const total = Number(way.tags.lanes);
|
||||||
|
if (Number.isInteger(total) && total > 0 && total % 2 === 0 && !isOneway(way)) return total / 2;
|
||||||
|
if (Number.isInteger(total) && total > 0 && isOneway(way)) return total;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roadCountsByNode(osm) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const way of osm.ways.values()) {
|
||||||
|
if (!way.tags.highway || way.tags.highway === "service") continue;
|
||||||
|
for (const ref of new Set(way.refs)) out.set(ref, (out.get(ref) || 0) + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointGeometry(osm, way, direction, roadCounts, networkIntersectionNodes) {
|
||||||
|
const forward = direction === "forward";
|
||||||
|
const endpointIndex = forward ? way.refs.length - 1 : 0;
|
||||||
|
const neighborIndex = forward ? endpointIndex - 1 : 1;
|
||||||
|
const node = osm.nodes.get(way.refs[endpointIndex]);
|
||||||
|
const neighbor = osm.nodes.get(way.refs[neighborIndex]);
|
||||||
|
if (!node || !neighbor) return null;
|
||||||
|
const networkSaysIntersection = networkIntersectionNodes && networkIntersectionNodes.size > 0 && networkIntersectionNodes.has(node.id);
|
||||||
|
if (!networkSaysIntersection && (roadCounts.get(node.id) || 0) < 3) return null;
|
||||||
|
const meters = metersForLat(node.lat);
|
||||||
|
// For both directions, point from the adjacent road node to the endpoint.
|
||||||
|
// At a forward endpoint this is the OSM-way direction; at a backward
|
||||||
|
// endpoint it is the reverse OSM-way direction, i.e. the actual travel
|
||||||
|
// direction used by turn:lanes:backward.
|
||||||
|
const raw = [node.lon - neighbor.lon, node.lat - neighbor.lat];
|
||||||
|
const axis = normalizeMetersVector(raw, meters);
|
||||||
|
if (!axis) return null;
|
||||||
|
return { node, axis, right: [axis[1], -axis[0]], meters };
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneCenter(endpoint, direction, laneIndex, meters) {
|
||||||
|
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
|
||||||
|
// The local axis always follows travel, so moving back from either endpoint
|
||||||
|
// places the marking on its approach lane before the intersection.
|
||||||
|
return addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -PLACEMENT_DISTANCE_METERS, endpoint.right, lateral, meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes, stopLines) {
|
||||||
|
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
|
||||||
|
for (const distance of [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]) {
|
||||||
|
const center = addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -distance, endpoint.right, lateral, endpoint.meters);
|
||||||
|
if (!nearIntersectionMarking(center, endpoint.axis, crosswalkStripes, stopLines, endpoint.meters)) {
|
||||||
|
return { center, axis: endpoint.axis, right: endpoint.right, meters: endpoint.meters, placementDistance: distance, placementSource: "osm_way_fallback" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crosswalkStripes, stopLines) {
|
||||||
|
if (!Array.isArray(lanePolygons)) return null;
|
||||||
|
const expectedDirection = direction === "forward" ? "Fwd" : "Back";
|
||||||
|
const directionalCandidates = lanePolygons.filter((feature) =>
|
||||||
|
feature.properties?.type === "Driving" &&
|
||||||
|
feature.properties.direction === expectedDirection
|
||||||
|
);
|
||||||
|
let candidates = directionalCandidates.filter((feature) =>
|
||||||
|
(feature.properties.osm_way_ids || []).map(Number).includes(way.id)
|
||||||
|
);
|
||||||
|
let placementSource = "driving_lane_centerline";
|
||||||
|
let spatialAnchors = null;
|
||||||
|
if (!candidates.length) {
|
||||||
|
const ranked = directionalCandidates
|
||||||
|
.map((feature) => ({ feature, anchor: spatialLaneAnchor(feature, endpoint) }))
|
||||||
|
.filter(({ anchor }) => anchor)
|
||||||
|
.filter(({ anchor }) => anchor.alignment >= SPATIAL_MATCH_MIN_ALIGNMENT && anchor.distance <= SPATIAL_MATCH_MAX_DISTANCE_METERS)
|
||||||
|
.sort((a, b) => a.anchor.distance - b.anchor.distance || a.anchor.lateral - b.anchor.lateral || Number(a.feature.properties.index) - Number(b.feature.properties.index));
|
||||||
|
if (ranked.length) {
|
||||||
|
// JOSM may split a tagged OSM way into temporary negative IDs. Those IDs
|
||||||
|
// are absent from osm2streets' rendered polygons, so associate the full
|
||||||
|
// physical approach by endpoint proximity and road-axis alignment.
|
||||||
|
candidates = ranked.map(({ feature }) => feature);
|
||||||
|
placementSource = "spatial_driving_lane_centerline";
|
||||||
|
spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const lateralA = spatialAnchors?.get(a)?.lateral;
|
||||||
|
const lateralB = spatialAnchors?.get(b)?.lateral;
|
||||||
|
if (Number.isFinite(lateralA) && Number.isFinite(lateralB) && lateralA !== lateralB) return lateralA - lateralB;
|
||||||
|
return Number(a.properties.index) - Number(b.properties.index);
|
||||||
|
});
|
||||||
|
const lane = candidates[laneIndex];
|
||||||
|
const spatialAnchor = spatialAnchors?.get(lane);
|
||||||
|
if (spatialAnchor) {
|
||||||
|
const sampled = placementDistances().map((distance) => ({
|
||||||
|
center: sampleCenterlineAwayFromEndpoint(spatialAnchor, distance, endpoint.meters),
|
||||||
|
distance,
|
||||||
|
})).find(({ center }) => center && !nearIntersectionMarking(center, spatialAnchor.axis, crosswalkStripes, stopLines, endpoint.meters));
|
||||||
|
if (!sampled) return { blocked: true };
|
||||||
|
return { center: sampled.center, axis: spatialAnchor.axis, right: [spatialAnchor.axis[1], -spatialAnchor.axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
|
||||||
|
}
|
||||||
|
const centerline = laneCenterline(lane);
|
||||||
|
if (!centerline) return null;
|
||||||
|
const startsAtEndpoint = direction === "backward";
|
||||||
|
const ordered = startsAtEndpoint ? centerline : [...centerline].reverse();
|
||||||
|
const sampled = [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]
|
||||||
|
.map((distance) => ({ center: samplePolyline(ordered, distance, endpoint.meters), distance }))
|
||||||
|
.find(({ center }) => center && !nearIntersectionMarking(center, axisForLane(ordered, endpoint.meters), crosswalkStripes, stopLines, endpoint.meters));
|
||||||
|
if (!sampled) return { blocked: true };
|
||||||
|
const axis = axisForLane(ordered, endpoint.meters);
|
||||||
|
if (!axis) return null;
|
||||||
|
return { center: sampled.center, axis, right: [axis[1], -axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spatialLaneAnchor(lane, endpoint) {
|
||||||
|
const centerline = laneCenterline(lane);
|
||||||
|
if (!centerline) return null;
|
||||||
|
let best = null;
|
||||||
|
for (let index = 0; index < centerline.length - 1; index += 1) {
|
||||||
|
const start = centerline[index];
|
||||||
|
const end = centerline[index + 1];
|
||||||
|
const point = closestPointOnSegment([endpoint.node.lon, endpoint.node.lat], start, end, endpoint.meters);
|
||||||
|
const distance = Math.hypot((point[0] - endpoint.node.lon) * endpoint.meters.lon, (point[1] - endpoint.node.lat) * endpoint.meters.lat);
|
||||||
|
const tangent = normalizeMetersVector(subtractPoint(end, start), endpoint.meters);
|
||||||
|
if (!tangent || (best && distance >= best.distance)) continue;
|
||||||
|
const dot = tangent[0] * endpoint.axis[0] + tangent[1] * endpoint.axis[1];
|
||||||
|
const axis = dot >= 0 ? tangent : [-tangent[0], -tangent[1]];
|
||||||
|
const offset = subtractPoint(point, [endpoint.node.lon, endpoint.node.lat]);
|
||||||
|
best = {
|
||||||
|
point,
|
||||||
|
distance,
|
||||||
|
axis,
|
||||||
|
alignment: Math.abs(dot),
|
||||||
|
lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat,
|
||||||
|
centerline,
|
||||||
|
segmentIndex: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementDistances() {
|
||||||
|
return [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleCenterlineAwayFromEndpoint(anchor, distanceMeters, meters) {
|
||||||
|
const { centerline, segmentIndex } = anchor;
|
||||||
|
const start = centerline[segmentIndex];
|
||||||
|
const end = centerline[segmentIndex + 1];
|
||||||
|
const tangent = normalizeMetersVector(subtractPoint(end, start), meters);
|
||||||
|
if (!tangent) return null;
|
||||||
|
// Walk away from the junction along the rendered centerline. This preserves
|
||||||
|
// curved or split lane geometry instead of approximating it with a tangent.
|
||||||
|
const towardEnd = tangent[0] * anchor.axis[0] + tangent[1] * anchor.axis[1] < 0;
|
||||||
|
const points = [anchor.point];
|
||||||
|
if (towardEnd) {
|
||||||
|
for (let index = segmentIndex + 1; index < centerline.length; index += 1) points.push(centerline[index]);
|
||||||
|
} else {
|
||||||
|
for (let index = segmentIndex; index >= 0; index -= 1) points.push(centerline[index]);
|
||||||
|
}
|
||||||
|
return samplePolyline(points, distanceMeters, meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestPointOnSegment(point, start, end, meters) {
|
||||||
|
const dx = (end[0] - start[0]) * meters.lon;
|
||||||
|
const dy = (end[1] - start[1]) * meters.lat;
|
||||||
|
const px = (point[0] - start[0]) * meters.lon;
|
||||||
|
const py = (point[1] - start[1]) * meters.lat;
|
||||||
|
const lengthSquared = dx * dx + dy * dy;
|
||||||
|
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
|
||||||
|
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisForLane(ordered, meters) {
|
||||||
|
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearIntersectionMarking(center, axis, stripes, stopLines, meters) {
|
||||||
|
if (!axis) return true;
|
||||||
|
const right = [axis[1], -axis[0]];
|
||||||
|
const samples = [];
|
||||||
|
for (const forward of [-0.2, 0.5, 1.2, 1.9, 2.2]) {
|
||||||
|
for (const lateral of [-1.6, -0.8, 0, 0.8, 1.6]) {
|
||||||
|
samples.push(addMeters(center, axis, forward, right, lateral, meters));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, meters)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearFeature(point, feature, meters) {
|
||||||
|
const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null;
|
||||||
|
if (!ring?.length) return false;
|
||||||
|
const xs = ring.map((coordinate) => coordinate[0]);
|
||||||
|
const ys = ring.map((coordinate) => coordinate[1]);
|
||||||
|
const clearance = 0.7;
|
||||||
|
const dx = Math.max((Math.min(...xs) - point[0]) * meters.lon, 0, (point[0] - Math.max(...xs)) * meters.lon);
|
||||||
|
const dy = Math.max((Math.min(...ys) - point[1]) * meters.lat, 0, (point[1] - Math.max(...ys)) * meters.lat);
|
||||||
|
return Math.hypot(dx, dy) < clearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractPoint([lon, lat], [otherLon, otherLat]) {
|
||||||
|
return [lon - otherLon, lat - otherLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePolyline(points, distanceMeters, meters) {
|
||||||
|
let remaining = distanceMeters;
|
||||||
|
for (let index = 0; index < points.length - 1; index += 1) {
|
||||||
|
const start = points[index];
|
||||||
|
const end = points[index + 1];
|
||||||
|
const vector = normalizeMetersVector(subtractPoint(end, start), meters);
|
||||||
|
const length = Math.hypot((end[0] - start[0]) * meters.lon, (end[1] - start[1]) * meters.lat);
|
||||||
|
if (!vector || !length) continue;
|
||||||
|
if (remaining <= length) return addMeters(start, vector, remaining, [0, 0], 0, meters);
|
||||||
|
remaining -= length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, template, center, endpoint) {
|
||||||
|
const ring = template.map(([rightMeters, forwardMeters]) => addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters));
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
type: "lane arrow",
|
||||||
|
source: "osm_turn_lanes",
|
||||||
|
osm_way_id: way.id,
|
||||||
|
direction,
|
||||||
|
lane_index: laneIndex,
|
||||||
|
maneuver,
|
||||||
|
source_asset: asset.id,
|
||||||
|
source_asset_path: asset.source,
|
||||||
|
arrow_part: partIndex,
|
||||||
|
// SVG strokes and fills are expanded separately for GeoJSON validity.
|
||||||
|
// This stable key lets the QGIS normalizer restore one rendered arrow.
|
||||||
|
custom_arrow_id: `${way.id}:${direction}:${laneIndex}:${maneuver}`,
|
||||||
|
placement_distance_meters: endpoint.placementDistance ?? PLACEMENT_DISTANCE_METERS,
|
||||||
|
placement_source: endpoint.placementSource ?? "osm_way_fallback",
|
||||||
|
},
|
||||||
|
geometry: { type: "Polygon", coordinates: [ring] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(way, direction, reason, extra = {}) {
|
||||||
|
return { source: "osm_turn_lanes", osm_way_id: way.id, direction, reason, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOneway(way) {
|
||||||
|
return ["yes", "true", "1"].includes(String(way.tags.oneway || "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function metersForLat(lat) {
|
||||||
|
return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetersVector([dxLon, dyLat], meters) {
|
||||||
|
const x = dxLon * meters.lon;
|
||||||
|
const y = dyLat * meters.lat;
|
||||||
|
const length = Math.hypot(x, y);
|
||||||
|
return length ? [x / length, y / length] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
|
||||||
|
return [
|
||||||
|
center[0] + (axis[0] * axisDistance + right[0] * rightDistance) / meters.lon,
|
||||||
|
center[1] + (axis[1] * axisDistance + right[1] * rightDistance) / meters.lat,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
|
||||||
|
const normalized = normalizeManeuver(maneuver);
|
||||||
|
if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return [];
|
||||||
|
const meters = metersForLat(center[1]);
|
||||||
|
const length = Math.hypot(axis[0], axis[1]);
|
||||||
|
if (!Number.isFinite(length) || length < 0.001) return [];
|
||||||
|
const forward = [axis[0] / length, axis[1] / length];
|
||||||
|
const right = [forward[1], -forward[0]];
|
||||||
|
return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) =>
|
||||||
|
addMeters(center, forward, forwardMeters, right, rightMeters, meters)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateFor(assetId, manifest = loadManifest()) {
|
||||||
|
const asset = supportedAssets(manifest).get(assetId);
|
||||||
|
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
|
||||||
|
return angularTemplate(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function angularTemplate(assetId) {
|
||||||
|
const shaftWidth = 0.30;
|
||||||
|
const shaftHalf = shaftWidth / 2;
|
||||||
|
const straightBase = 1.18;
|
||||||
|
const straightTip = 1.92;
|
||||||
|
const rectangle = (minX, minY, maxX, maxY) => [
|
||||||
|
[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY], [minX, minY],
|
||||||
|
];
|
||||||
|
const throughHead = () => [[0, straightTip], [-0.42, straightBase], [-shaftHalf, straightBase], [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, straightBase], [0.42, straightBase], [0, straightTip]];
|
||||||
|
const diagonalShaft = (side) => {
|
||||||
|
const start = [0, 0.56];
|
||||||
|
const end = [side * 0.72, 0.96];
|
||||||
|
const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||||
|
const normal = [-(end[1] - start[1]) / length * shaftHalf, (end[0] - start[0]) / length * shaftHalf];
|
||||||
|
return [[start[0] + normal[0], start[1] + normal[1]], [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]], [start[0] - normal[0], start[1] - normal[1]], [start[0] + normal[0], start[1] + normal[1]]];
|
||||||
|
};
|
||||||
|
const diagonalHead = (side) => {
|
||||||
|
const base = [side * 0.60, 0.89];
|
||||||
|
const tip = [side * 1.22, 1.24];
|
||||||
|
const length = Math.hypot(tip[0] - base[0], tip[1] - base[1]);
|
||||||
|
const normal = [-(tip[1] - base[1]) / length * 0.36, (tip[0] - base[0]) / length * 0.36];
|
||||||
|
return [tip, [base[0] + normal[0], base[1] + normal[1]], [base[0] - normal[0], base[1] - normal[1]], tip];
|
||||||
|
};
|
||||||
|
const turnStem = (side) => {
|
||||||
|
const cutMidpoint = 0.73;
|
||||||
|
const cutRise = side * 0.084;
|
||||||
|
return [
|
||||||
|
[-shaftHalf, 0], [shaftHalf, 0],
|
||||||
|
[shaftHalf, cutMidpoint + cutRise], [-shaftHalf, cutMidpoint - cutRise],
|
||||||
|
[-shaftHalf, 0],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
if (assetId === "through") return [throughHead()];
|
||||||
|
if (assetId === "right") return [turnStem(1), diagonalShaft(1), diagonalHead(1)];
|
||||||
|
if (assetId === "left") return [turnStem(-1), diagonalShaft(-1), diagonalHead(-1)];
|
||||||
|
if (assetId === "through;right") return [throughHead(), diagonalShaft(1), diagonalHead(1)];
|
||||||
|
if (assetId === "through;left") return [throughHead(), diagonalShaft(-1), diagonalHead(-1)];
|
||||||
|
if (assetId === "through;left;right") return [throughHead(), diagonalShaft(-1), diagonalHead(-1), diagonalShaft(1), diagonalHead(1)];
|
||||||
|
throw new Error(`No angular turn-lane template: ${assetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceSvgTemplateFor(asset, assetId) {
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, "..", "..", "assets", "lane-icons", asset.source), "utf8");
|
||||||
|
const mirrorX = asset.mirror_x === true;
|
||||||
|
const anchorX = Number(asset.anchor_x);
|
||||||
|
if (!Number.isFinite(anchorX)) throw new Error(`turn-lane asset has invalid anchor_x: ${assetId}`);
|
||||||
|
const shapes = [];
|
||||||
|
for (const match of source.matchAll(/<line\b([^>]*)\/>|<path\b([^>]*)\/>/g)) {
|
||||||
|
const attrs = parseSvgAttrs(match[1] || match[2]);
|
||||||
|
const strokeWidth = Number(attrs["stroke-width"] || 0);
|
||||||
|
if (match[1]) {
|
||||||
|
shapes.push(strokePolygon([[Number(attrs.x1), Number(attrs.y1)], [Number(attrs.x2), Number(attrs.y2)]], strokeWidth));
|
||||||
|
} else {
|
||||||
|
const points = parseSvgPath(attrs.d || "");
|
||||||
|
if (attrs.fill !== "none") shapes.push(points);
|
||||||
|
if (strokeWidth > 0) shapes.push(strokePolygon(points, strokeWidth));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shapes.filter((ring) => ring.length >= 4).map((ring) => ring.map(([x, y]) => [
|
||||||
|
(mirrorX ? anchorX - x : x - anchorX) * SVG_METERS_PER_UNIT,
|
||||||
|
(23 - y) * SVG_METERS_PER_UNIT,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSvgAttrs(text) {
|
||||||
|
const attrs = {};
|
||||||
|
for (const match of text.matchAll(/([\w:-]+)=(['"])(.*?)\2/g)) attrs[match[1]] = match[3];
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSvgPath(value) {
|
||||||
|
const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || [];
|
||||||
|
let index = 0;
|
||||||
|
let command = "";
|
||||||
|
let point = [0, 0];
|
||||||
|
let start = null;
|
||||||
|
const points = [];
|
||||||
|
const number = () => Number(tokens[index++]);
|
||||||
|
const lineTo = (x, y) => { point = [x, y]; points.push(point); };
|
||||||
|
while (index < tokens.length) {
|
||||||
|
if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
|
||||||
|
const relative = command === command.toLowerCase();
|
||||||
|
const op = command.toUpperCase();
|
||||||
|
if (op === "Z") { if (start) points.push(start); command = ""; continue; }
|
||||||
|
if (op === "M" || op === "L") {
|
||||||
|
const x = number(); const y = number();
|
||||||
|
const next = relative ? [point[0] + x, point[1] + y] : [x, y];
|
||||||
|
if (op === "M" && !start) { start = next; point = next; points.push(point); command = relative ? "l" : "L"; } else lineTo(...next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (op === "H") { lineTo(relative ? point[0] + number() : number(), point[1]); continue; }
|
||||||
|
if (op === "V") { lineTo(point[0], relative ? point[1] + number() : number()); continue; }
|
||||||
|
if (op === "C") {
|
||||||
|
const values = [number(), number(), number(), number(), number(), number()];
|
||||||
|
const controls = relative ? values.map((n, i) => n + point[i % 2]) : values;
|
||||||
|
const origin = point;
|
||||||
|
for (let step = 1; step <= 8; step += 1) {
|
||||||
|
const t = step / 8; const u = 1 - t;
|
||||||
|
lineTo(u ** 3 * origin[0] + 3 * u ** 2 * t * controls[0] + 3 * u * t ** 2 * controls[2] + t ** 3 * controls[4], u ** 3 * origin[1] + 3 * u ** 2 * t * controls[1] + 3 * u * t ** 2 * controls[3] + t ** 3 * controls[5]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (op === "A") { number(); number(); number(); number(); number(); const x = number(); const y = number(); lineTo(relative ? point[0] + x : x, relative ? point[1] + y : y); continue; }
|
||||||
|
throw new Error(`Unsupported SVG path command: ${command}`);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokePolygon(points, width) {
|
||||||
|
if (points.length < 2) return [];
|
||||||
|
const half = width / 2;
|
||||||
|
const left = []; const right = [];
|
||||||
|
for (let index = 0; index < points.length; index += 1) {
|
||||||
|
const prev = points[Math.max(0, index - 1)];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const length = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = -dy / length * half; const ny = dx / length * half;
|
||||||
|
left.push([points[index][0] + nx, points[index][1] + ny]);
|
||||||
|
right.unshift([points[index][0] - nx, points[index][1] - ny]);
|
||||||
|
}
|
||||||
|
return [...left, ...right, left[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };
|
||||||
161
src/geometry/lane-geometry.js
Normal file
161
src/geometry/lane-geometry.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
|
|
||||||
|
function laneCenterline(lane) {
|
||||||
|
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
|
||||||
|
if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
|
||||||
|
const vertices = ring.slice(0, -1);
|
||||||
|
if (!vertices.every(validCoordinate)) return null;
|
||||||
|
const half = vertices.length / 2;
|
||||||
|
if (!Number.isInteger(half) || half < 2) return null;
|
||||||
|
const centerline = vertices.slice(0, half).map((point, index) => [
|
||||||
|
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
|
||||||
|
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
|
||||||
|
]);
|
||||||
|
return polylineLength(centerline) > 0.01 ? centerline : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orientPolyline(polyline, reference) {
|
||||||
|
if (!polyline?.length || !reference?.length) return null;
|
||||||
|
const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]);
|
||||||
|
if (Math.abs(forward) < 0.01) return null;
|
||||||
|
return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stitchPolylines(polylines, maxGapMeters) {
|
||||||
|
if (!polylines.length) return null;
|
||||||
|
const result = [];
|
||||||
|
for (const polyline of polylines) {
|
||||||
|
if (!polyline?.length) return null;
|
||||||
|
if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null;
|
||||||
|
appendCoordinates(result, polyline);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedDistanceAlong(polyline, point) {
|
||||||
|
let traversed = 0;
|
||||||
|
let best = { distance: Infinity, along: 0, lateral: 0 };
|
||||||
|
for (let index = 1; index < polyline.length; index += 1) {
|
||||||
|
const start = polyline[index - 1];
|
||||||
|
const end = polyline[index];
|
||||||
|
const meters = metersAt((start[1] + end[1]) / 2);
|
||||||
|
const dx = (end[0] - start[0]) * meters.lon;
|
||||||
|
const dy = (end[1] - start[1]) * meters.lat;
|
||||||
|
const px = (point[0] - start[0]) * meters.lon;
|
||||||
|
const py = (point[1] - start[1]) * meters.lat;
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
if (length < 0.001) continue;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
|
||||||
|
const offsetX = px - dx * ratio;
|
||||||
|
const offsetY = py - dy * ratio;
|
||||||
|
const distance = Math.hypot(offsetX, offsetY);
|
||||||
|
if (distance < best.distance) {
|
||||||
|
const rightX = dy / length;
|
||||||
|
const rightY = -dx / length;
|
||||||
|
best = {
|
||||||
|
distance,
|
||||||
|
along: traversed + length * ratio,
|
||||||
|
lateral: offsetX * rightX + offsetY * rightY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
traversed += length;
|
||||||
|
}
|
||||||
|
return best.along;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lateralOffsetFrom(polyline, point) {
|
||||||
|
let best = null;
|
||||||
|
for (let index = 1; index < polyline.length; index += 1) {
|
||||||
|
const start = polyline[index - 1];
|
||||||
|
const end = polyline[index];
|
||||||
|
const meters = metersAt((start[1] + end[1]) / 2);
|
||||||
|
const dx = (end[0] - start[0]) * meters.lon;
|
||||||
|
const dy = (end[1] - start[1]) * meters.lat;
|
||||||
|
const px = (point[0] - start[0]) * meters.lon;
|
||||||
|
const py = (point[1] - start[1]) * meters.lat;
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
if (length < 0.001) continue;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
|
||||||
|
const offsetX = px - dx * ratio;
|
||||||
|
const offsetY = py - dy * ratio;
|
||||||
|
const distance = Math.hypot(offsetX, offsetY);
|
||||||
|
if (!best || distance < best.distance) {
|
||||||
|
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineMidpoint(polyline) {
|
||||||
|
const target = polylineLength(polyline) / 2;
|
||||||
|
let traversed = 0;
|
||||||
|
for (let index = 1; index < polyline.length; index += 1) {
|
||||||
|
const length = haversineMeters(polyline[index - 1], polyline[index]);
|
||||||
|
if (traversed + length >= target) {
|
||||||
|
const ratio = length ? (target - traversed) / length : 0;
|
||||||
|
return [
|
||||||
|
polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio,
|
||||||
|
polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
traversed += length;
|
||||||
|
}
|
||||||
|
return polyline.length ? copyCoordinate(polyline.at(-1)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineLength(polyline) {
|
||||||
|
let total = 0;
|
||||||
|
for (let index = 1; index < (polyline?.length || 0); index += 1) {
|
||||||
|
total += haversineMeters(polyline[index - 1], polyline[index]);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineMeters(a, b) {
|
||||||
|
const lat1 = degreesToRadians(a[1]);
|
||||||
|
const lat2 = degreesToRadians(b[1]);
|
||||||
|
const dLat = degreesToRadians(b[1] - a[1]);
|
||||||
|
const dLon = degreesToRadians(b[0] - a[0]);
|
||||||
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCoordinates(target, coordinates) {
|
||||||
|
for (const coordinate of coordinates) {
|
||||||
|
if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCoordinate(value) {
|
||||||
|
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCoordinate(a, b) {
|
||||||
|
return Boolean(a && b && a[0] === b[0] && a[1] === b[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCoordinate(coordinate) {
|
||||||
|
return [coordinate[0], coordinate[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function metersAt(latitude) {
|
||||||
|
return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function degreesToRadians(value) {
|
||||||
|
return value * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appendCoordinates,
|
||||||
|
haversineMeters,
|
||||||
|
laneCenterline,
|
||||||
|
lateralOffsetFrom,
|
||||||
|
orientPolyline,
|
||||||
|
polylineLength,
|
||||||
|
polylineMidpoint,
|
||||||
|
projectedDistanceAlong,
|
||||||
|
stitchPolylines,
|
||||||
|
};
|
||||||
13
src/index.js
Normal file
13
src/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
laneGeometry: require("./geometry/lane-geometry"),
|
||||||
|
gaodeReference: require("./reference/gaode"),
|
||||||
|
turnLaneArrows: require("./compile/turn-lane-arrows"),
|
||||||
|
complexJunction: require("./compile/complex-junction"),
|
||||||
|
osm: require("./osm"),
|
||||||
|
trafficSignals: require("./traffic-signals"),
|
||||||
|
nativeTrafficSignals: require("./native-traffic-signals"),
|
||||||
|
nativeRoad: require("./compile/native-road"),
|
||||||
|
compiler: require("./compile/compiler"),
|
||||||
|
};
|
||||||
49
src/native-traffic-signals.js
Normal file
49
src/native-traffic-signals.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const { parseOsm } = require("./osm");
|
||||||
|
const {
|
||||||
|
buildTrafficSignalFeatures,
|
||||||
|
buildTrafficSignalsFromFeatures,
|
||||||
|
validateTrafficSignalFeatures,
|
||||||
|
validateTrafficSignalSourceReferences,
|
||||||
|
} = require("./traffic-signals");
|
||||||
|
|
||||||
|
const SCHEMA = "native-traffic-signals/v1";
|
||||||
|
|
||||||
|
function loadOrGenerate(file, osmText, stopLines, intersections) {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const document = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
try {
|
||||||
|
return validateDocument(document, osmText);
|
||||||
|
} catch (error) {
|
||||||
|
// OSM edits can invalidate the stable identities in a document that was
|
||||||
|
// itself generated from OSM. User-authored documents must remain strict.
|
||||||
|
if (document?.provenance === "generated:osm-controls" && isStaleSourceReferenceError(error)) {
|
||||||
|
return generate(osmText, stopLines, intersections);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generate(osmText, stopLines, intersections);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleSourceReferenceError(error) {
|
||||||
|
return error instanceof Error && /^traffic signal feature \d+: (approach_id .* is not present on OSM control|control_id .* is not present in the current OSM)/.test(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate(osmText, stopLines, intersections) {
|
||||||
|
const controls = parseOsm(osmText).trafficSignalControls;
|
||||||
|
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDocument(value, osmText) {
|
||||||
|
if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`);
|
||||||
|
const assemblies = validateTrafficSignalFeatures(value.assemblies);
|
||||||
|
if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls);
|
||||||
|
return { schema: SCHEMA, provenance: value.provenance || "native", assemblies };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); }
|
||||||
|
|
||||||
|
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };
|
||||||
102
src/osm.js
Normal file
102
src/osm.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
function parseOsm(xml) {
|
||||||
|
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
|
||||||
|
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
|
||||||
|
const candidateBounds = {
|
||||||
|
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
|
||||||
|
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
|
||||||
|
};
|
||||||
|
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
|
||||||
|
const nodes = new Map();
|
||||||
|
const trafficSignalControls = [];
|
||||||
|
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
|
||||||
|
for (const match of xml.matchAll(nodePattern)) {
|
||||||
|
const attrs = xmlAttrs(match[1]);
|
||||||
|
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||||
|
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||||
|
if (!coordinate.every(Number.isFinite)) continue;
|
||||||
|
nodes.set(attrs.id, coordinate);
|
||||||
|
const tags = parseTags(match[2] || "");
|
||||||
|
if (tags.highway === "traffic_signals") {
|
||||||
|
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ways = [];
|
||||||
|
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||||
|
const attrs = xmlAttrs(match[1]);
|
||||||
|
if (attrs.action === "delete") continue;
|
||||||
|
const body = match[2];
|
||||||
|
const refs = [];
|
||||||
|
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const ref = xmlAttrs(ndMatch[1]).ref;
|
||||||
|
if (ref && nodes.has(ref)) refs.push(ref);
|
||||||
|
}
|
||||||
|
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags: parseTags(body) });
|
||||||
|
}
|
||||||
|
for (const control of trafficSignalControls) {
|
||||||
|
const arms = [];
|
||||||
|
for (const way of ways) {
|
||||||
|
if (!isMotorRoad(way.tags)) continue;
|
||||||
|
for (let index = 0; index < way.refs.length; index += 1) {
|
||||||
|
if (way.refs[index] !== control.id) continue;
|
||||||
|
for (const neighborIndex of [index - 1, index + 1]) {
|
||||||
|
const neighbor = way.refs[neighborIndex];
|
||||||
|
if (!neighbor || !nodes.has(neighbor)) continue;
|
||||||
|
const neighborPoint = nodes.get(neighbor);
|
||||||
|
arms.push({
|
||||||
|
headingDegrees: headingBetween(control, neighborPoint),
|
||||||
|
wayId: String(way.id),
|
||||||
|
neighborNodeId: String(neighbor),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control.arms = dedupeHeadings(arms);
|
||||||
|
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other";
|
||||||
|
}
|
||||||
|
return { bounds, nodes, ways, trafficSignalControls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMotorRoad(tags) {
|
||||||
|
const highway = tags.highway || "";
|
||||||
|
return highway && tags.area !== "yes" && !new Set([
|
||||||
|
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
|
||||||
|
"bridleway", "corridor", "elevator", "platform", "construction",
|
||||||
|
]).has(highway);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingBetween(from, to) {
|
||||||
|
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180;
|
||||||
|
return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeHeadings(arms) {
|
||||||
|
const normalized = (value) => ((value % 360) + 360) % 360;
|
||||||
|
const distance = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
|
||||||
|
const result = [];
|
||||||
|
for (const arm of arms) {
|
||||||
|
arm.headingDegrees = normalized(arm.headingDegrees);
|
||||||
|
if (!result.some((other) => distance(other.headingDegrees, arm.headingDegrees) <= 25)) result.push(arm);
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.headingDegrees - b.headingDegrees);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlAttrs(text) {
|
||||||
|
const attrs = {};
|
||||||
|
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||||
|
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(body) {
|
||||||
|
const tags = {};
|
||||||
|
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const tag = xmlAttrs(match[1]);
|
||||||
|
if (tag.k) tags[tag.k] = tag.v || "";
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseOsm };
|
||||||
231
src/reference/gaode.js
Normal file
231
src/reference/gaode.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const PI = Math.PI;
|
||||||
|
const EARTH_A = 6378245.0;
|
||||||
|
const EARTH_EE = 0.00669342162296594323;
|
||||||
|
|
||||||
|
function transformLat(x, y) {
|
||||||
|
let value = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||||
|
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
|
||||||
|
value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
|
||||||
|
value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLon(x, y) {
|
||||||
|
let value = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||||
|
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
|
||||||
|
value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
|
||||||
|
value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the standard local inverse approximation used for GCJ-02 reference
|
||||||
|
// data. It is intentionally kept separate from native road geometry, whose
|
||||||
|
// source coordinates remain WGS84.
|
||||||
|
function gcj02ToWgs84(coordinate) {
|
||||||
|
const [longitude, latitude] = coordinate;
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite");
|
||||||
|
const dLat = transformLat(longitude - 105, latitude - 35);
|
||||||
|
const dLon = transformLon(longitude - 105, latitude - 35);
|
||||||
|
const radLat = latitude / 180 * PI;
|
||||||
|
const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2;
|
||||||
|
const sqrtMagic = Math.sqrt(magic);
|
||||||
|
return [
|
||||||
|
longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI),
|
||||||
|
latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCoordinates(coordinates, mapper) {
|
||||||
|
if (typeof coordinates[0] === "number") return mapper(coordinates);
|
||||||
|
return coordinates.map((value) => mapCoordinates(value, mapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertGeoJson(document) {
|
||||||
|
if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) {
|
||||||
|
throw new Error("Reference must be a GeoJSON FeatureCollection");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
crs: undefined,
|
||||||
|
features: document.features.map((feature) => {
|
||||||
|
if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry");
|
||||||
|
return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinatesOf(document) {
|
||||||
|
const points = [];
|
||||||
|
for (const feature of document.features || []) walkCoordinates(feature.geometry?.coordinates, points);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkCoordinates(value, points) {
|
||||||
|
if (!Array.isArray(value) || !value.length) return;
|
||||||
|
if (typeof value[0] === "number") {
|
||||||
|
points.push(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of value) walkCoordinates(child, points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsOf(document) {
|
||||||
|
const points = coordinatesOf(document);
|
||||||
|
if (!points.length) throw new Error("Reference contains no coordinates");
|
||||||
|
return {
|
||||||
|
minLon: Math.min(...points.map((point) => point[0])),
|
||||||
|
minLat: Math.min(...points.map((point) => point[1])),
|
||||||
|
maxLon: Math.max(...points.map((point) => point[0])),
|
||||||
|
maxLat: Math.max(...points.map((point) => point[1])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerOf(bounds) {
|
||||||
|
return [(bounds.minLon + bounds.maxLon) / 2, (bounds.minLat + bounds.maxLat) / 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceMeters(first, second) {
|
||||||
|
const lonScale = 111320 * Math.cos(first[1] * PI / 180);
|
||||||
|
return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOsmNodes(xml) {
|
||||||
|
const nodes = [];
|
||||||
|
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
||||||
|
const attrs = {};
|
||||||
|
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3];
|
||||||
|
if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue;
|
||||||
|
const tags = {};
|
||||||
|
for (const item of (match[2] || "").matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const tag = {};
|
||||||
|
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3];
|
||||||
|
if (tag.k) tags[tag.k] = tag.v || "";
|
||||||
|
}
|
||||||
|
nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags });
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestNode(nodes, coordinate, nodeId) {
|
||||||
|
if (nodeId) {
|
||||||
|
const exact = nodes.find((node) => node.id === String(nodeId));
|
||||||
|
if (!exact) throw new Error(`OSM node not found: ${nodeId}`);
|
||||||
|
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" };
|
||||||
|
}
|
||||||
|
const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) }));
|
||||||
|
candidates.sort((first, second) => first.distanceMeters - second.distanceMeters);
|
||||||
|
if (!candidates[0]) throw new Error("OSM contains no usable nodes");
|
||||||
|
return { ...candidates[0], match: "nearest-node" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxIntersectionRatio(first, second) {
|
||||||
|
const width = Math.max(0, Math.min(first.maxLon, second.maxLon) - Math.max(first.minLon, second.minLon));
|
||||||
|
const height = Math.max(0, Math.min(first.maxLat, second.maxLat) - Math.max(first.minLat, second.minLat));
|
||||||
|
const intersection = width * height;
|
||||||
|
const firstArea = Math.max(0, first.maxLon - first.minLon) * Math.max(0, first.maxLat - first.minLat);
|
||||||
|
const secondArea = Math.max(0, second.maxLon - second.minLon) * Math.max(0, second.maxLat - second.minLat);
|
||||||
|
return intersection / Math.max(firstArea + secondArea - intersection, Number.EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A complex junction is compiled as one cluster of `complex_part` polygons in
|
||||||
|
// road_surface.geojson, not as a per-node feature in intersection_surface.
|
||||||
|
// Match it by cluster id, or by whichever cluster core sits nearest the node.
|
||||||
|
function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) {
|
||||||
|
if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null;
|
||||||
|
const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, "utf8"));
|
||||||
|
const parts = (surface.features || []).filter((item) => item.properties?.cluster_id && item.properties?.complex_part);
|
||||||
|
const cores = parts.filter((item) => item.properties.complex_part === "core" && Array.isArray(item.properties.center));
|
||||||
|
if (!cores.length) return null;
|
||||||
|
const core = clusterId
|
||||||
|
? cores.find((item) => String(item.properties.cluster_id) === String(clusterId))
|
||||||
|
: [...cores].sort((first, second) => distanceMeters(first.properties.center, node.coordinate) - distanceMeters(second.properties.center, node.coordinate))[0];
|
||||||
|
if (!core) return null;
|
||||||
|
const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id);
|
||||||
|
return { clusterId: core.properties.cluster_id, core, features };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) {
|
||||||
|
const source = JSON.parse(fs.readFileSync(referenceFile, "utf8"));
|
||||||
|
const converted = convertGeoJson(source);
|
||||||
|
const referenceBounds = boundsOf(converted);
|
||||||
|
const referenceCenter = centerOf(referenceBounds);
|
||||||
|
const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8"));
|
||||||
|
const matchedNode = nearestNode(nodes, referenceCenter, nodeId);
|
||||||
|
const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8"));
|
||||||
|
const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id);
|
||||||
|
const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId);
|
||||||
|
const matchedFeatures = feature ? [feature] : cluster?.features || null;
|
||||||
|
const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null;
|
||||||
|
const diagnostics = [];
|
||||||
|
if (!matchedFeatures) diagnostics.push(nativeRoadSurfaceFile ? "No native intersection surface or complex cluster matched the OSM node" : "No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters");
|
||||||
|
return {
|
||||||
|
schema: "gaode-junction-reference-comparison/v2",
|
||||||
|
source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length },
|
||||||
|
conversion: { target: "WGS84", method: "gcj02-inverse-approximation" },
|
||||||
|
reference: { bounds: referenceBounds, center: referenceCenter },
|
||||||
|
matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters },
|
||||||
|
nativeIntersection: nativeBounds ? {
|
||||||
|
kind: feature ? "junction-node" : "complex-cluster",
|
||||||
|
clusterId: cluster?.clusterId || null,
|
||||||
|
featureCount: matchedFeatures.length,
|
||||||
|
bounds: nativeBounds,
|
||||||
|
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds),
|
||||||
|
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)),
|
||||||
|
featureProperties: feature ? feature.properties : cluster.core.properties,
|
||||||
|
} : null,
|
||||||
|
diagnostics,
|
||||||
|
converted,
|
||||||
|
matchedFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
|
||||||
|
const width = 1000;
|
||||||
|
const height = 1000;
|
||||||
|
const lonScale = 111320 * Math.cos(center[1] * PI / 180);
|
||||||
|
const project = (point) => [
|
||||||
|
width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2),
|
||||||
|
height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2),
|
||||||
|
];
|
||||||
|
const pathFor = (coordinates) => {
|
||||||
|
const parts = [];
|
||||||
|
const appendLine = (line, close) => {
|
||||||
|
if (!line?.length) return;
|
||||||
|
const [firstX, firstY] = project(line[0]);
|
||||||
|
parts.push(`M ${firstX.toFixed(1)} ${firstY.toFixed(1)}`);
|
||||||
|
for (const point of line.slice(1)) {
|
||||||
|
const [x, y] = project(point);
|
||||||
|
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
if (close) parts.push("Z");
|
||||||
|
};
|
||||||
|
const visit = (value) => {
|
||||||
|
if (!Array.isArray(value) || !value.length) return;
|
||||||
|
if (typeof value[0] === "number") return;
|
||||||
|
if (typeof value[0][0] === "number") appendLine(value, value.length > 2);
|
||||||
|
else value.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(coordinates);
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
|
||||||
|
const references = converted.features.map((feature) => {
|
||||||
|
const type = feature.properties?.type || "unknown";
|
||||||
|
return `<path d="${pathFor(feature.geometry.coordinates)}" fill="${feature.geometry.type.includes("Polygon") ? `${color[type] || "#334155"}18` : "none"}" stroke="${color[type] || "#334155"}" stroke-width="1.2"/>`;
|
||||||
|
}).join("\n");
|
||||||
|
const nativePaths = (nativeIntersection?.features || []).map((feature) => `<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`).join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<rect width="100%" height="100%" fill="#f8fafc"/>
|
||||||
|
${references}
|
||||||
|
${nativePaths}
|
||||||
|
<circle cx="${width / 2}" cy="${height / 2}" r="5" fill="#111827"/>
|
||||||
|
<text x="20" y="35" font-family="sans-serif" font-size="22" fill="#111827">Gaode reference (type colors) / native intersection (red)</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg };
|
||||||
330
src/traffic-signals.js
Normal file
330
src/traffic-signals.js
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { parseOsm } = require("./osm");
|
||||||
|
|
||||||
|
const EARTH_RADIUS = 6371008.8;
|
||||||
|
const CURB_OFFSET_METERS = 5.2;
|
||||||
|
const MAST_REACH_METERS = 4.5;
|
||||||
|
const SIGNAL_LAYOUT = Object.freeze({
|
||||||
|
poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21,
|
||||||
|
mastHeightMeters: 6.25, headCenterHeightMeters: 6.25,
|
||||||
|
headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62,
|
||||||
|
lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18,
|
||||||
|
lensVerticalOffsetsMeters: [0.49, -0.01, -0.51],
|
||||||
|
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
|
||||||
|
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
|
||||||
|
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildTrafficSignalFeatures(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 clusteredStops = new Map();
|
||||||
|
for (const feature of stopLines.features || []) {
|
||||||
|
const clusterId = feature.properties?.cluster_id;
|
||||||
|
const point = polygonCenter(feature.geometry);
|
||||||
|
if (!clusterId || !point) continue;
|
||||||
|
if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []);
|
||||||
|
clusteredStops.get(clusterId).push(point);
|
||||||
|
}
|
||||||
|
for (const [clusterId, points] of clusteredStops) {
|
||||||
|
if (points.length < 3) continue;
|
||||||
|
const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]);
|
||||||
|
centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) });
|
||||||
|
}
|
||||||
|
const candidates = [];
|
||||||
|
for (const feature of stopLines.features || []) {
|
||||||
|
const center = polygonCenter(feature.geometry);
|
||||||
|
if (!center) continue;
|
||||||
|
const clusterId = feature.properties?.cluster_id;
|
||||||
|
const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers);
|
||||||
|
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
|
||||||
|
const axis = roadAxis(feature.geometry, center, intersection.point);
|
||||||
|
if (!axis) continue;
|
||||||
|
const right = [axis[1], -axis[0]];
|
||||||
|
candidates.push({
|
||||||
|
intersectionId: intersection.id, center, axis,
|
||||||
|
point: intersection.clusterId
|
||||||
|
? moveMeters(center, right, CURB_OFFSET_METERS)
|
||||||
|
: moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS),
|
||||||
|
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
|
||||||
|
matchHeadingDegrees: intersection.clusterId
|
||||||
|
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const features = [];
|
||||||
|
for (const control of controls) {
|
||||||
|
const controlPoint = [Number(control.longitude), Number(control.latitude)];
|
||||||
|
if (!controlPoint.every(Number.isFinite) || !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((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
|
||||||
|
const groups = phaseGroups(arms);
|
||||||
|
arms.forEach((candidate, index) => {
|
||||||
|
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
|
||||||
|
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
|
||||||
|
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
|
||||||
|
const approachId = `${sourceWayId}:${neighborNodeId}`;
|
||||||
|
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: candidate.point.slice() },
|
||||||
|
properties: {
|
||||||
|
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
|
||||||
|
approach_id: approachId, source_way_id: sourceWayId,
|
||||||
|
// These are independent assembly controls. heading_deg remains a
|
||||||
|
// migration hint for older native documents only.
|
||||||
|
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
|
||||||
|
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
|
||||||
|
phase_group: groups[index],
|
||||||
|
mast_reach_m: MAST_REACH_METERS,
|
||||||
|
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
|
||||||
|
enabled: true, z_offset_m: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTrafficSignalFeatures(collection) {
|
||||||
|
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
|
||||||
|
throw new Error("Traffic signal assemblies must be a FeatureCollection");
|
||||||
|
}
|
||||||
|
const uids = new Set();
|
||||||
|
const displayIds = new Set();
|
||||||
|
const features = collection.features.map((feature, index) => {
|
||||||
|
const label = `traffic signal feature ${index + 1}`;
|
||||||
|
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
|
||||||
|
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
|
||||||
|
throw new Error(`${label}: geometry must be a finite Point`);
|
||||||
|
}
|
||||||
|
const input = feature.properties || {};
|
||||||
|
const text = (key, required = true) => {
|
||||||
|
const value = input[key] == null ? "" : String(input[key]).trim();
|
||||||
|
if (required && !value) throw new Error(`${label}: missing ${key}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const number = (key, options = {}) => {
|
||||||
|
if (input[key] === null || input[key] === undefined || input[key] === "") {
|
||||||
|
throw new Error(`${label}: missing ${key}`);
|
||||||
|
}
|
||||||
|
const value = Number(input[key]);
|
||||||
|
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
|
||||||
|
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const signalUid = text("signal_uid");
|
||||||
|
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
|
||||||
|
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
|
||||||
|
uids.add(signalUid);
|
||||||
|
const displayId = text("display_id", false);
|
||||||
|
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
|
||||||
|
if (displayId) displayIds.add(displayId);
|
||||||
|
const phaseGroup = number("phase_group", { min: 0, max: 1 });
|
||||||
|
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
|
||||||
|
const enabled = normalizeBoolean(input.enabled, label);
|
||||||
|
const controlId = text("control_id");
|
||||||
|
const approachId = text("approach_id");
|
||||||
|
const sourceWayId = text("source_way_id");
|
||||||
|
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
|
||||||
|
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
|
||||||
|
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
|
||||||
|
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
|
||||||
|
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
|
||||||
|
throw new Error(`${label}: missing mast_heading_deg`);
|
||||||
|
}
|
||||||
|
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
|
||||||
|
throw new Error(`${label}: missing face_heading_deg`);
|
||||||
|
}
|
||||||
|
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
|
||||||
|
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
|
||||||
|
: normalizeDegrees(number("mast_heading_deg"));
|
||||||
|
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
|
||||||
|
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
|
||||||
|
: normalizeDegrees(number("face_heading_deg"));
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
|
||||||
|
properties: {
|
||||||
|
...input, signal_uid: signalUid, display_id: displayId,
|
||||||
|
control_id: controlId, approach_id: approachId,
|
||||||
|
source_way_id: sourceWayId,
|
||||||
|
// Retain the legacy value only for migration compatibility. Runtime
|
||||||
|
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
|
||||||
|
heading_deg: legacyHeading,
|
||||||
|
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
|
||||||
|
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
|
||||||
|
stop_lon: number("stop_lon", { min: -180, max: 180 }),
|
||||||
|
stop_lat: number("stop_lat", { min: -90, max: 90 }),
|
||||||
|
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { type: "FeatureCollection", features };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrafficSignalsFromFeatures(collection) {
|
||||||
|
const normalized = validateTrafficSignalFeatures(collection);
|
||||||
|
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
|
||||||
|
const p = feature.properties;
|
||||||
|
const point = feature.geometry.coordinates;
|
||||||
|
const mastAxis = headingVector(p.mast_heading_deg);
|
||||||
|
return {
|
||||||
|
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
|
||||||
|
nodeKey: signalNodeKey(p.signal_uid),
|
||||||
|
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
|
||||||
|
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
|
||||||
|
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
|
||||||
|
// Existing Blender readers require headingDegrees. It is a compatibility
|
||||||
|
// alias only; the independent mast/face fields below define all geometry.
|
||||||
|
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
|
||||||
|
mastHeadingDegrees: p.mast_heading_deg,
|
||||||
|
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
|
||||||
|
zOffsetMeters: p.z_offset_m,
|
||||||
|
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalNodeKey(signalUid) {
|
||||||
|
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileTrafficSignalSourceReferences(collection, controls) {
|
||||||
|
const normalized = validateTrafficSignalFeatures(collection);
|
||||||
|
const approachesByControl = new Map((controls || []).map((control) => [
|
||||||
|
String(control.id),
|
||||||
|
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
|
||||||
|
]));
|
||||||
|
const kept = [];
|
||||||
|
const dropped = [];
|
||||||
|
for (const [index, feature] of normalized.features.entries()) {
|
||||||
|
const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
|
||||||
|
const approaches = approachesByControl.get(controlId);
|
||||||
|
if (!approaches) {
|
||||||
|
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!approaches.has(approachId)) {
|
||||||
|
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(feature);
|
||||||
|
}
|
||||||
|
return { collection: { ...normalized, features: kept }, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTrafficSignalSourceReferences(collection, controls) {
|
||||||
|
const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls);
|
||||||
|
if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`);
|
||||||
|
return reconciled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrafficSignals(stopLines, intersections, controls = []) {
|
||||||
|
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
|
||||||
|
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||||
|
return buildTrafficSignalFeatures(
|
||||||
|
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
|
||||||
|
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTrafficSignals(editablePath, osmPath = null) {
|
||||||
|
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
|
||||||
|
if (osmPath) {
|
||||||
|
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||||
|
validateTrafficSignalSourceReferences(collection, controls);
|
||||||
|
}
|
||||||
|
return buildTrafficSignalsFromFeatures(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, label) {
|
||||||
|
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
|
||||||
|
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
|
||||||
|
throw new Error(`${label}: invalid enabled '${value}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
|
||||||
|
return arms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchOsmArms(candidates, controlPoint, osmArms) {
|
||||||
|
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
|
||||||
|
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
|
||||||
|
return osmArms.map((osmArm) => {
|
||||||
|
let bestIndex = -1; let bestDistance = Infinity;
|
||||||
|
remaining.forEach((item, index) => {
|
||||||
|
const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees);
|
||||||
|
const distance = item.matchHeadingDegrees == null
|
||||||
|
? directedDistance
|
||||||
|
: Math.min(
|
||||||
|
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
|
||||||
|
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
|
||||||
|
);
|
||||||
|
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
|
||||||
|
});
|
||||||
|
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
|
||||||
|
return { ...candidate, osmArm };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCandidate(controlPoint, osmArm) {
|
||||||
|
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
|
||||||
|
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
|
||||||
|
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), 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 best = -1;
|
||||||
|
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
|
||||||
|
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
|
||||||
|
const face = headingVector(faceHeadingDegrees);
|
||||||
|
const head = moveMeters(pole, mastAxis, mastReach);
|
||||||
|
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
|
||||||
|
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||||
|
const faceRight = [-face[1], face[0]];
|
||||||
|
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
|
||||||
|
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
|
||||||
|
}
|
||||||
|
|
||||||
|
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((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
|
||||||
|
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
|
||||||
|
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; 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]; }
|
||||||
|
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(degrees) { const radians = degrees * 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,
|
||||||
|
signalNodeKey,
|
||||||
|
buildTrafficSignalFeatures,
|
||||||
|
validateTrafficSignalFeatures,
|
||||||
|
validateTrafficSignalSourceReferences,
|
||||||
|
buildTrafficSignalsFromFeatures,
|
||||||
|
buildTrafficSignals,
|
||||||
|
readTrafficSignalFeatures,
|
||||||
|
readTrafficSignals,
|
||||||
|
};
|
||||||
11091
test/fixtures/fengshu-er-road.osm
vendored
Normal file
11091
test/fixtures/fengshu-er-road.osm
vendored
Normal file
File diff suppressed because it is too large
Load Diff
18
test/index.js
Normal file
18
test/index.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert/strict");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const compiler = require("../src");
|
||||||
|
|
||||||
|
assert.equal(typeof compiler.laneGeometry.laneCenterline, "function");
|
||||||
|
assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function");
|
||||||
|
assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function");
|
||||||
|
assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function");
|
||||||
|
assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function");
|
||||||
|
const fixture = path.join(__dirname, "fixtures", "fengshu-er-road.osm");
|
||||||
|
assert.ok(fs.existsSync(fixture));
|
||||||
|
assert.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/);
|
||||||
|
const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] });
|
||||||
|
assert.equal(model.roads.length, 24);
|
||||||
|
console.log("road compiler package tests passed");
|
||||||
Reference in New Issue
Block a user