feat: migrate workbench to React

This commit is contained in:
2026-08-26 15:01:07 +08:00
parent f15e69c868
commit 3ddb33e321
48 changed files with 16748 additions and 1509 deletions

View File

@@ -1,27 +1,42 @@
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const fs = require('fs');
const path = require('path');
function checkOutput({ areaId, outDir }) {
if (typeof areaId !== "string" || !areaId) throw new Error("RoadCompilerCheckInput.areaId must be a non-empty string");
if (typeof outDir !== "string" || !outDir) throw new Error("RoadCompilerCheckInput.outDir must be a non-empty string");
const compiledPath = path.join(outDir, "compiled.json");
if (typeof areaId !== 'string' || !areaId)
throw new Error('RoadCompilerCheckInput.areaId must be a non-empty string');
if (typeof outDir !== 'string' || !outDir)
throw new Error('RoadCompilerCheckInput.outDir must be a non-empty string');
const compiledPath = path.join(outDir, 'compiled.json');
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
const compiled = readJson(compiledPath);
const connectors = readJson(path.join(outDir, "layers", "connectors.geojson"));
const connectors = readJson(path.join(outDir, 'layers', 'connectors.geojson'));
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
const failures = [];
for (const movement of compiled.movements || []) {
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`);
if (movement.geometryPublished && !published.has(movement.id))
failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id))
failures.push(`Non-published movement has a connector: ${movement.id}`);
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
}
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error");
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning");
return { schema: "native-road-check/v1", areaId, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures };
const errors = (compiled.diagnostics || []).filter((item) => item.severity === 'error');
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === 'warning');
return {
schema: 'native-road-check/v1',
areaId,
ok: failures.length === 0 && errors.length === 0,
movementCount: (compiled.movements || []).length,
connectorCount: connectors.features.length,
errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })),
warningCount: warnings.length,
failures,
};
}
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
module.exports = { checkOutput };

View File

@@ -1,11 +1,17 @@
#!/usr/bin/env node
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./native-road");
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require("./layer-manifest");
const { loadOrGenerate, runtime } = require("../native-traffic-signals");
const fs = require('fs');
const path = require('path');
const {
compileRoadModel,
compileGeometry,
loadOverrides,
validateOverrides,
writeJsonAtomic,
} = require('./native-road');
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
const { loadOrGenerate, runtime } = require('../native-traffic-signals');
function compileInput(input) {
validateInput(input);
@@ -22,50 +28,73 @@ function compileInput(input) {
},
};
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
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}`);
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 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-"));
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, 'native-road-'));
try {
const result = {
schema: "native-road-compiled/v1",
schema: 'native-road-compiled/v1',
areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals },
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 },
trafficSignals: {
assemblies: 'traffic-signal-assemblies.json',
runtime: 'traffic-signals.json',
count: signalRuntime.signals.length,
},
diagnostics: compiled.diagnostics,
layers: Object.fromEntries(LAYER_REGISTRY.map((layer) => [layer.key, `layers/${layer.source}.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);
for (const layer of LAYER_REGISTRY) writeJsonAtomic(path.join(staging, "layers", `${layer.source}.geojson`), compiled[layer.key]);
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);
for (const layer of LAYER_REGISTRY)
writeJsonAtomic(path.join(staging, 'layers', `${layer.source}.geojson`), compiled[layer.key]);
const manifest = manifestForArea(area.id);
validatePublishedLayers(staging, manifest);
writeJsonAtomic(path.join(staging, "manifest.json"), manifest);
writeJsonAtomic(path.join(staging, 'manifest.json'), manifest);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
return { area, result, comparison };
@@ -76,10 +105,10 @@ function compileInput(input) {
}
function compareOsm2Streets(area, model, compiled) {
const source = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null;
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"));
const collection = JSON.parse(fs.readFileSync(source, 'utf8'));
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
}
const diagnosticsBySeverity = {};
@@ -88,18 +117,23 @@ function compareOsm2Streets(area, model, compiled) {
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 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");
const fallbackJunctions = junctions.filter(
(feature) => feature.properties.boundary_mode === 'connector-convex-fallback',
);
return {
schema: "native-road-comparison/v2",
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),
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,
@@ -117,18 +151,22 @@ function compareOsm2Streets(area, model, compiled) {
diagnosticsByRule,
osm2streetsRoadSurfaceFeatures: featureCount,
osm2streetsAvailable: featureCount !== null,
note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.",
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 || 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");
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 };

View File

@@ -1,7 +1,7 @@
"use strict";
'use strict';
const fs = require("fs");
const { convertGeoJson, boundsOf } = require("../reference/gaode");
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
@@ -15,8 +15,24 @@ 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]);
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) {
@@ -31,39 +47,92 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
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)] };
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),
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;
const armCrosswalkRadius = coreRadius * 0.68;
for (const item of carriageways) {
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers);
const inner = pointOnCarriagewayRadius(item, center, coreRadius * 0.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));
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),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
item.heading,
-0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf),
item.heading,
-0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf),
item.heading,
0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
item.heading,
0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
item.heading,
-0.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] } });
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
@@ -89,15 +158,15 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
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 roadEdgeInset = 0.35;
const usableSpan = Math.max(0.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 stripeWidth = 0.42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / 0.82) + 1);
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
arm.crosswalkFrame.center = midpoint(...endpoints);
arm.crosswalkFrame.endpoints = endpoints;
@@ -107,13 +176,54 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
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),
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] } });
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
@@ -128,36 +238,65 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const bisector = normalizeHeading(first.heading + delta / 2);
const frameCorner = frameCorners[index];
if (!frameCorner) continue;
const cornerStripeSpacing = .62;
const cornerStripeWidth = .4;
const cornerStripeSpacing = 0.62;
const cornerStripeWidth = 0.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 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];
.sort(
(a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector),
)[0];
};
const islandBaseGap = .05;
const islandBaseGap = 0.05;
const islandApexOffset = 1.5;
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap));
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) =>
helpers.offsetCoordinate(point, bisector, islandBaseGap),
);
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
const islandCrossingClearance = .2;
const islandCrossingClearance = 0.2;
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)));
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] } });
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], 0.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: 0.24,
provenance: 'native-road-complex-junction-corner/v7-road-gap-fill',
},
geometry: { type: 'Polygon', coordinates: [islandRing] },
});
let cornerCrossingHalfSpan = .4;
let cornerCrossingHalfSpan = 0.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);
const halfSpan = Math.max(0.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
const stripePair = [
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
@@ -170,7 +309,22 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
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] } });
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
@@ -195,17 +349,46 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// 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));
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 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] } });
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
@@ -214,24 +397,108 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `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(),
...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),
...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));
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] } });
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] } }];
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, 0.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: 0.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] } });
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.
@@ -239,20 +506,56 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `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 configuredRadiusIgnored =
calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > 0.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)]
? [
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)] };
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 };
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;
}
@@ -268,29 +571,37 @@ function complexJunctionMetrics(cluster) {
// — 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(24, calibration.shortSpanMeters * 0.14))
: Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28));
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
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; }
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);
}));
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
@@ -322,8 +633,10 @@ function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
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);
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) {
@@ -335,27 +648,36 @@ function ringSelfIntersects(ring) {
return false;
}
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
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 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;
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 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;
return (Math.atan2(east, north) * 180) / Math.PI;
}
function midpoint(first, second) {
return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2];
}
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;
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 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);
@@ -374,15 +696,18 @@ function armEnvelopeAtRadius(arm, radius, center, helpers) {
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 };
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 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;
const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstDirection = direction(first.supportHeading);
@@ -391,17 +716,26 @@ function supportLineIntersection(first, second, origin) {
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];
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)];
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)];
const halfWidth = Math.max(0.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > 0.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;
@@ -410,15 +744,24 @@ 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 [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;
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 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;
const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstPoint = toLocal(first.point);
@@ -429,7 +772,10 @@ function rayIntersection(first, second, origin) {
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];
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];
}
@@ -438,14 +784,17 @@ function quadraticCurve(start, control, end, segments) {
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]]);
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 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;
const radians = (heading * Math.PI) / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function smoothClosedRing(vertices) {
@@ -453,11 +802,18 @@ function smoothClosedRing(vertices) {
// 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 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 [interpolateCoordinate(point, next, 0.18), interpolateCoordinate(point, next, 0.82)];
});
return [...points, points[0]];
}
@@ -469,6 +825,8 @@ function roundedPolygonRing(vertices, 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]; }
function interpolateCoordinate(first, second, ratio) {
return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio];
}
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };

View File

@@ -1,26 +1,55 @@
"use strict";
'use strict';
// The compiler's layer registry is the single source of truth for published
// GeoJSON files and the Blender-facing manifest. Keep rendering details limited
// to material slot names; the host owns the actual material definitions.
const LAYER_REGISTRY = Object.freeze([
{ key: "roadSurface", source: "road_surface", role: "surface", materialLayer: "road_surface" },
{ key: "edgeLines", source: "edge_lines", role: "marking", materialLayer: "lane_separators" },
{ key: "intersectionSurface", source: "intersection_surface", role: "surface", materialLayer: "intersection_surface" },
{ key: "sidewalkSurface", source: "sidewalk_surface", role: "surface", materialLayer: "sidewalks" },
{ key: "laneSeparators", source: "lane_separators", role: "marking", materialLayer: "lane_separators", splitBy: { prop: "color", cases: [{ match: "yellow", material: "native_lane_separator_yellow" }, { default: true, material: "lane_separators" }] } },
{ key: "centerLines", source: "center_lines", role: "marking", materialLayer: "center_lines", splitBy: { prop: "color", cases: [{ match: "white", material: "native_center_line_white" }, { default: true, material: "center_lines" }] } },
{ key: "directionArrows", source: "direction_arrows", role: "marking", materialLayer: "lane_arrows_webscale" },
{ key: "turnArrows", source: "turn_arrows", role: "marking", materialLayer: "lane_arrows_webscale" },
{ key: "crosswalks", source: "crosswalks", role: "marking", materialLayer: "crosswalks" },
{ key: "vehicleStopLines", source: "vehicle_stop_lines", role: "marking", materialLayer: "vehicle_stop_lines" },
{ key: "laneCenterlines", source: "lane_centerlines", role: "semantic" },
{ key: "connectors", source: "connectors", role: "semantic" },
{ key: 'roadSurface', source: 'road_surface', role: 'surface', materialLayer: 'road_surface' },
{ key: 'edgeLines', source: 'edge_lines', role: 'marking', materialLayer: 'lane_separators' },
{
key: 'intersectionSurface',
source: 'intersection_surface',
role: 'surface',
materialLayer: 'intersection_surface',
},
{ key: 'sidewalkSurface', source: 'sidewalk_surface', role: 'surface', materialLayer: 'sidewalks' },
{
key: 'laneSeparators',
source: 'lane_separators',
role: 'marking',
materialLayer: 'lane_separators',
splitBy: {
prop: 'color',
cases: [
{ match: 'yellow', material: 'native_lane_separator_yellow' },
{ default: true, material: 'lane_separators' },
],
},
},
{
key: 'centerLines',
source: 'center_lines',
role: 'marking',
materialLayer: 'center_lines',
splitBy: {
prop: 'color',
cases: [
{ match: 'white', material: 'native_center_line_white' },
{ default: true, material: 'center_lines' },
],
},
},
{ key: 'directionArrows', source: 'direction_arrows', role: 'marking', materialLayer: 'lane_arrows_webscale' },
{ key: 'turnArrows', source: 'turn_arrows', role: 'marking', materialLayer: 'lane_arrows_webscale' },
{ key: 'crosswalks', source: 'crosswalks', role: 'marking', materialLayer: 'crosswalks' },
{ key: 'vehicleStopLines', source: 'vehicle_stop_lines', role: 'marking', materialLayer: 'vehicle_stop_lines' },
{ key: 'laneCenterlines', source: 'lane_centerlines', role: 'semantic' },
{ key: 'connectors', source: 'connectors', role: 'semantic' },
]);
function manifestForArea(areaId) {
return {
contract: "native-road-package/v1.1",
contract: 'native-road-package/v1.1',
areaId,
layers: LAYER_REGISTRY.map(({ source, role, materialLayer, splitBy }) => ({
source,
@@ -34,16 +63,20 @@ function manifestForArea(areaId) {
function validatePublishedLayers(directory, manifest) {
const declared = new Set();
for (const layer of manifest.layers) {
if (!layer || typeof layer.source !== "string" || declared.has(layer.source)) throw new Error("Manifest has duplicate or invalid source.");
if (!layer || typeof layer.source !== 'string' || declared.has(layer.source))
throw new Error('Manifest has duplicate or invalid source.');
declared.add(layer.source);
const file = require("path").join(directory, "layers", `${layer.source}.geojson`);
if (!require("fs").existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`);
const file = require('path').join(directory, 'layers', `${layer.source}.geojson`);
if (!require('fs').existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`);
}
const files = require("fs").existsSync(require("path").join(directory, "layers"))
? require("fs").readdirSync(require("path").join(directory, "layers")).filter((name) => name.endsWith(".geojson")).map((name) => name.slice(0, -8))
const files = require('fs').existsSync(require('path').join(directory, 'layers'))
? require('fs')
.readdirSync(require('path').join(directory, 'layers'))
.filter((name) => name.endsWith('.geojson'))
.map((name) => name.slice(0, -8))
: [];
const extras = files.filter((source) => !declared.has(source));
if (extras.length) throw new Error(`Unmanifested GeoJSON source: ${extras.join(", ")}`);
if (extras.length) throw new Error(`Unmanifested GeoJSON source: ${extras.join(', ')}`);
}
module.exports = { LAYER_REGISTRY, manifestForArea, validatePublishedLayers };

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const { laneCenterline } = require("../geometry/lane-geometry");
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 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;
@@ -12,69 +12,98 @@ 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;
const SVG_METERS_PER_UNIT = 0.1;
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");
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]));
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" }] };
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 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"]) {
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"));
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"));
diagnostics.push(skip(way, direction, 'indeterminate_intersection_endpoint'));
continue;
}
const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value));
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 }));
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 }));
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);
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 }));
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);
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 }));
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));
features.push(
makeFeature(
way,
direction,
laneIndex,
maneuver,
asset,
partIndex,
parts[partIndex],
placement.center,
placement,
),
);
}
}
}
@@ -83,13 +112,20 @@ function buildCustomTurnLaneArrows(osm, options = {}) {
}
function normalizeManeuver(value) {
const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort();
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"],
['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(";");
return supported.get(parts.join(';')) || parts.join(';');
}
function directionalLaneCount(way, direction) {
@@ -104,20 +140,21 @@ function directionalLaneCount(way, direction) {
function roadCountsByNode(osm) {
const out = new Map();
for (const way of osm.ways.values()) {
if (!way.tags.highway || way.tags.highway === "service") continue;
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 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);
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.
@@ -134,15 +171,36 @@ 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);
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);
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 {
center,
axis: endpoint.axis,
right: endpoint.right,
meters: endpoint.meters,
placementDistance: distance,
placementSource: 'osm_way_fallback',
};
}
}
return null;
@@ -150,28 +208,35 @@ function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes,
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
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)
(feature.properties.osm_way_ids || []).map(Number).includes(way.id),
);
let placementSource = "driving_lane_centerline";
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));
.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";
placementSource = 'spatial_driving_lane_centerline';
spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor]));
}
}
@@ -184,24 +249,53 @@ function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crossw
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));
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 };
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 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));
.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 };
return {
center: sampled.center,
axis,
right: [axis[1], -axis[0]],
meters: endpoint.meters,
placementDistance: sampled.distance,
placementSource,
};
}
function spatialLaneAnchor(lane, endpoint) {
@@ -212,7 +306,10 @@ function spatialLaneAnchor(lane, endpoint) {
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 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];
@@ -223,7 +320,8 @@ function spatialLaneAnchor(lane, endpoint) {
distance,
axis,
alignment: Math.abs(dot),
lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat,
lateral:
offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat,
centerline,
segmentIndex: index,
};
@@ -276,11 +374,13 @@ function nearIntersectionMarking(center, axis, stripes, stopLines, meters) {
samples.push(addMeters(center, axis, forward, right, lateral, meters));
}
}
return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, 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;
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]);
@@ -309,12 +409,14 @@ function samplePolyline(points, distanceMeters, meters) {
}
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));
const ring = template.map(([rightMeters, forwardMeters]) =>
addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters),
);
return {
type: "Feature",
type: 'Feature',
properties: {
type: "lane arrow",
source: "osm_turn_lanes",
type: 'lane arrow',
source: 'osm_turn_lanes',
osm_way_id: way.id,
direction,
lane_index: laneIndex,
@@ -326,18 +428,18 @@ function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, temp
// 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",
placement_source: endpoint.placementSource ?? 'osm_way_fallback',
},
geometry: { type: "Polygon", coordinates: [ring] },
geometry: { type: 'Polygon', coordinates: [ring] },
};
}
function skip(way, direction, reason, extra = {}) {
return { source: "osm_turn_lanes", osm_way_id: way.id, 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());
return ['yes', 'true', '1'].includes(String(way.tags.oneway || '').toLowerCase());
}
function metersForLat(lat) {
@@ -366,8 +468,11 @@ function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
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)));
return templateFor(normalized, manifest).map((template) =>
template.map(([rightMeters, forwardMeters]) =>
addMeters(center, forward, forwardMeters, right, rightMeters, meters),
),
);
}
function templateFor(assetId, manifest = loadManifest()) {
@@ -377,67 +482,101 @@ function templateFor(assetId, manifest = loadManifest()) {
}
function angularTemplate(assetId) {
const shaftWidth = 0.30;
const shaftWidth = 0.3;
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],
[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 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 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 base = [side * 0.6, 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];
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],
[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)];
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 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);
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));
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);
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,
]));
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) {
@@ -449,36 +588,69 @@ function parseSvgAttrs(text) {
function parseSvgPath(value) {
const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || [];
let index = 0;
let command = "";
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); };
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);
if (op === 'Z') {
if (start) points.push(start);
command = '';
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") {
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]);
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; }
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;
@@ -487,16 +659,27 @@ function parseSvgPath(value) {
function strokePolygon(points, width) {
if (points.length < 2) return [];
const half = width / 2;
const left = []; const right = [];
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;
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 };
module.exports = {
arrowRingsAt,
buildCustomTurnLaneArrows,
loadManifest,
normalizeManeuver,
supportedAssets,
templateFor,
};

View File

@@ -1,49 +1,54 @@
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const { zipSync } = require("fflate");
const { LAYER_REGISTRY, validatePublishedLayers } = require("../compile/layer-manifest");
const fs = require('fs');
const path = require('path');
const { zipSync } = require('fflate');
const { LAYER_REGISTRY, validatePublishedLayers } = require('../compile/layer-manifest');
const ROOT_FILES = [
"manifest.json",
"compiled.json",
"diagnostics.json",
"comparison.json",
"traffic-signal-assemblies.json",
"traffic-signals.json",
'manifest.json',
'compiled.json',
'diagnostics.json',
'comparison.json',
'traffic-signal-assemblies.json',
'traffic-signals.json',
];
const GENERATOR = Object.freeze({ name: "road-compiler", version: "0.3.0" });
const GENERATOR = Object.freeze({ name: 'road-compiler', version: '0.3.0' });
function packageEntries(directory) {
const manifestPath = path.join(directory, "manifest.json");
const manifestPath = path.join(directory, 'manifest.json');
if (!fs.existsSync(manifestPath)) throw new Error(`Native road package manifest is missing: ${manifestPath}`);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.contract !== "native-road-package/v1.1") throw new Error("Native road package requires native-road-package/v1.1.");
if (!manifest.areaId || typeof manifest.areaId !== "string") throw new Error("Native road package manifest areaId is required.");
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.contract !== 'native-road-package/v1.1')
throw new Error('Native road package requires native-road-package/v1.1.');
if (!manifest.areaId || typeof manifest.areaId !== 'string')
throw new Error('Native road package manifest areaId is required.');
validatePublishedLayers(directory, manifest);
const files = [];
for (const relative of ROOT_FILES) files.push(relative);
for (const layer of LAYER_REGISTRY) files.push(`layers/${layer.source}.geojson`);
for (const relative of files) {
const file = path.join(directory, relative);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`Native road package file is missing: ${relative}`);
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
throw new Error(`Native road package file is missing: ${relative}`);
}
return { manifest, files: [...new Set(files)].sort() };
}
function exportNativeRoadPackage(directory, destination = null) {
const { manifest, files } = packageEntries(directory);
const entries = Object.fromEntries(files.map((relative) => [relative, fs.readFileSync(path.join(directory, relative))]));
const entries = Object.fromEntries(
files.map((relative) => [relative, fs.readFileSync(path.join(directory, relative))]),
);
// The model/movements remain needed by host preview generation, but absolute
// compiler-workspace paths are not part of the exported package contract.
const compiled = JSON.parse(entries["compiled.json"].toString("utf8"));
const compiled = JSON.parse(entries['compiled.json'].toString('utf8'));
delete compiled.source;
entries["compiled.json"] = Buffer.from(`${JSON.stringify(compiled, null, 2)}\n`);
entries["manifest.json"] = Buffer.from(`${JSON.stringify({ ...manifest, generator: GENERATOR }, null, 2)}\n`);
entries['compiled.json'] = Buffer.from(`${JSON.stringify(compiled, null, 2)}\n`);
entries['manifest.json'] = Buffer.from(`${JSON.stringify({ ...manifest, generator: GENERATOR }, null, 2)}\n`);
// ZIP timestamps start at 1980; a fixed value keeps repeated exports byte-stable.
const bytes = zipSync(entries, { level: 6, mtime: new Date("1980-01-01T00:00:00Z") });
const bytes = zipSync(entries, { level: 6, mtime: new Date('1980-01-01T00:00:00Z') });
if (destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp-${process.pid}`;

View File

@@ -1,18 +1,20 @@
"use strict";
'use strict';
const EARTH_RADIUS_METERS = 6371008.8;
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
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,
]);
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;
}
@@ -82,7 +84,7 @@ function lateralOffsetFrom(polyline, point) {
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 };
best = { distance, lateral: (offsetX * dy) / length - (offsetY * dx) / length };
}
}
return best;
@@ -145,7 +147,7 @@ function metersAt(latitude) {
}
function degreesToRadians(value) {
return value * Math.PI / 180;
return (value * Math.PI) / 180;
}
module.exports = {

View File

@@ -1,16 +1,16 @@
"use strict";
'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"),
layerManifest: require("./compile/layer-manifest"),
nativeRoadPackage: require("./export/native-road-package"),
compiler: require("./compile/compiler"),
check: require("./check"),
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'),
layerManifest: require('./compile/layer-manifest'),
nativeRoadPackage: require('./export/native-road-package'),
compiler: require('./compile/compiler'),
check: require('./check'),
};

View File

@@ -1,25 +1,25 @@
"use strict";
'use strict';
const fs = require("fs");
const { parseOsm } = require("./osm");
const fs = require('fs');
const { parseOsm } = require('./osm');
const {
buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("./traffic-signals");
} = require('./traffic-signals');
const SCHEMA = "native-traffic-signals/v1";
const SCHEMA = 'native-traffic-signals/v1';
function loadOrGenerate(file, osmText, stopLines, intersections) {
if (fs.existsSync(file)) {
const document = JSON.parse(fs.readFileSync(file, "utf8"));
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)) {
if (document?.provenance === 'generated:osm-controls' && isStaleSourceReferenceError(error)) {
return generate(osmText, stopLines, intersections);
}
throw error;
@@ -29,21 +29,32 @@ function loadOrGenerate(file, 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);
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) };
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 };
return { schema: SCHEMA, provenance: value.provenance || 'native', assemblies };
}
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); }
function runtime(document) {
return buildTrafficSignalsFromFeatures(document.assemblies);
}
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };

View File

@@ -1,11 +1,13 @@
"use strict";
'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),
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();
@@ -13,19 +15,19 @@ function parseOsm(xml) {
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;
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") {
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;
if (attrs.action === 'delete') continue;
const body = match[2];
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
@@ -53,22 +55,36 @@ function parseOsm(xml) {
}
}
control.arms = dedupeHeadings(arms);
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other";
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);
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;
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) {
@@ -94,7 +110,7 @@ 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 || "";
if (tag.k) tags[tag.k] = tag.v || '';
}
return tags;
}

View File

@@ -1,6 +1,6 @@
"use strict";
'use strict';
const fs = require("fs");
const fs = require('fs');
const PI = Math.PI;
const EARTH_A = 6378245.0;
@@ -8,17 +8,17 @@ 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;
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;
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;
}
@@ -27,33 +27,37 @@ function transformLon(x, y) {
// 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");
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 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),
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);
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");
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) } };
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) },
};
}),
};
}
@@ -66,7 +70,7 @@ function coordinatesOf(document) {
function walkCoordinates(value, points) {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") {
if (typeof value[0] === 'number') {
points.push(value);
return;
}
@@ -75,7 +79,7 @@ function walkCoordinates(value, points) {
function boundsOf(document) {
const points = coordinatesOf(document);
if (!points.length) throw new Error("Reference contains no coordinates");
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])),
@@ -89,7 +93,7 @@ function centerOf(bounds) {
}
function distanceMeters(first, second) {
const lonScale = 111320 * Math.cos(first[1] * PI / 180);
const lonScale = 111320 * Math.cos((first[1] * PI) / 180);
return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320);
}
@@ -97,13 +101,15 @@ 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];
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)) {
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 || "";
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 });
}
@@ -114,12 +120,12 @@ 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" };
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" };
if (!candidates[0]) throw new Error('OSM contains no usable nodes');
return { ...candidates[0], match: 'nearest-node' };
}
function bboxIntersectionRatio(first, second) {
@@ -136,47 +142,73 @@ function bboxIntersectionRatio(first, second) {
// 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 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));
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];
: [...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"));
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 nodes = parseOsmNodes(fs.readFileSync(osmFile, 'utf8'));
const matchedNode = nearestNode(nodes, referenceCenter, nodeId);
const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8"));
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");
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" },
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,
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,
@@ -186,10 +218,10 @@ function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nati
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
const width = 1000;
const height = 1000;
const lonScale = 111320 * Math.cos(center[1] * PI / 180);
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),
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 = [];
@@ -201,23 +233,30 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
const [x, y] = project(point);
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
}
if (close) parts.push("Z");
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);
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(" ");
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");
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"/>
@@ -228,4 +267,12 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
</svg>`;
}
module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg };
module.exports = {
gcj02ToWgs84,
convertGeoJson,
boundsOf,
parseOsmNodes,
nearestNode,
inspectReference,
localReferenceSvg,
};

View File

@@ -1,28 +1,40 @@
"use strict";
'use strict';
const fs = require("fs");
const crypto = require("crypto");
const { parseOsm } = require("./osm");
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,
poleHeightMeters: 6.7,
poleRadiusMeters: 0.13,
armWidthMeters: 0.21,
mastHeightMeters: 6.25,
headCenterHeightMeters: 6.25,
headWidthMeters: 0.68,
headDepthMeters: 0.3,
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,
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 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;
@@ -33,27 +45,39 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
}
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 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);
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,
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),
headingDegrees: normalizeDegrees((Math.atan2(axis[0], axis[1]) * 180) / Math.PI),
matchHeadingDegrees: intersection.clusterId
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
? normalizeDegrees((Math.atan2(-axis[0], -axis[1]) * 180) / Math.PI)
: null,
});
}
@@ -63,158 +87,212 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
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 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 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() },
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,
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,
stop_lon: candidate.center[0],
stop_lat: candidate.center[1],
enabled: true,
z_offset_m: 0,
},
});
});
}
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
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");
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)) {
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();
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] === "") {
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)) {
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");
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);
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 });
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 === "")) {
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 === "")) {
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"));
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) },
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,
...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 }),
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 };
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),
};
});
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)}`;
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 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` });
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}'` });
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);
@@ -233,67 +311,119 @@ function buildTrafficSignals(stopLines, intersections, controls = []) {
}
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
const controls = parseOsm(fs.readFileSync(osmPath, 'utf8')).trafficSignalControls;
return buildTrafficSignalFeatures(
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
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"));
const collection = JSON.parse(fs.readFileSync(editablePath, 'utf8'));
if (osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
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;
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) }))
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);
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)) }));
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;
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 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);
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 };
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;
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) {
@@ -302,20 +432,81 @@ function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset
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 } };
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); }
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,