Files
osmWorkflow/scripts/lib/vehicle-route.js

667 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const fs = require("fs");
const { parseOsm } = require("./osm");
const {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
} = require("./lane-geometry");
const MAX_ROUTES = 5;
const MAX_PATH_EDGES = 7;
const MIN_ROUTE_EDGES = 3;
const MAX_LANE_DISTANCE_METERS = 20;
const MIN_LATERAL_SEPARATION_METERS = 0.25;
const JUNCTION_TRIM_METERS = 6.0;
const CONNECTOR_SURFACE_TOLERANCE_METERS = 0.35;
const ALL_TURNS = new Set(["left", "through", "right"]);
function buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) {
if (!lanePolygonsPath || !networkPath || !intersectionSurfacePath) {
throw new Error("Lane polygons, osm2streets network, and intersection surface paths are required for vehicle route generation");
}
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
const lanePolygons = readLanePolygons(lanePolygonsPath);
const network = readJsonObject(networkPath, "osm2streets network");
const intersectionSurfaces = readFeatureCollection(intersectionSurfacePath, "intersection surfaces");
const diagnostics = [];
const laneIndex = indexDrivingLanes(lanePolygons.features, diagnostics);
const intersections = indexIntersections(network, intersectionSurfaces.features);
const edges = directedRoadEdges(network, osm.ways, diagnostics);
const candidates = findReturnRoutes(edges);
const routes = [];
for (const candidate of candidates) {
const route = makeRoute(candidate, laneIndex, intersections, diagnostics);
if (route) routes.push(route);
}
const selected = selectRoutes(routes);
return {
source: osmPath,
laneSource: lanePolygonsPath,
networkSource: networkPath,
intersectionSource: intersectionSurfacePath,
bounds: osm.bounds,
generatedAt: new Date().toISOString(),
speedMetersPerSecond: 8.0,
loop: true,
routes: selected,
diagnostics,
// 旧预览仍读取 segments保持与 routes 为同一个数组引用。
segments: selected,
};
}
function readLanePolygons(file) {
return readFeatureCollection(file, "lane polygons");
}
function readFeatureCollection(file, label) {
const collection = readJsonObject(file, label);
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error(`Invalid ${label} GeoJSON '${file}': expected FeatureCollection`);
}
return collection;
}
function readJsonObject(file, label) {
try {
const value = JSON.parse(fs.readFileSync(file, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected JSON object");
return value;
} catch (error) {
throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`);
}
}
function indexDrivingLanes(features, diagnostics) {
const index = new Map();
features.forEach((feature, featureIndex) => {
if (feature?.properties?.type !== "Driving") return;
const centerline = laneCenterline(feature);
const direction = feature.properties.direction;
const widthMeters = Number(feature.properties.width);
const road = Number(feature.properties.road);
if (!centerline || !["Fwd", "Back"].includes(direction) || !Number.isFinite(widthMeters) || widthMeters <= 0 || !Number.isInteger(road)) {
diagnostics.push({
reason: "invalid_lane_polygon",
featureIndex,
road: feature?.properties?.road ?? null,
laneIndex: feature?.properties?.index ?? null,
});
return;
}
const lane = {
featureIndex,
polygonId: feature.id ?? `${feature.properties.road ?? "road"}:${direction}:${feature.properties.index ?? featureIndex}`,
road,
laneIndex: feature.properties.index,
widthMeters,
allowedTurns: normalizeAllowedTurns(feature.properties.allowed_turns),
centerline,
};
const key = laneKey(road, direction);
if (!index.has(key)) index.set(key, []);
index.get(key).push(lane);
});
return index;
}
function normalizeAllowedTurns(value) {
if (!Array.isArray(value)) return new Set();
return new Set(value.map(normalizeTurn).filter(Boolean));
}
function isCruiseHighway(tags) {
const highway = tags.highway || "";
if (!highway || tags.area === "yes") return false;
return !new Set([
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
"bridleway", "corridor", "elevator", "platform", "construction",
]).has(highway);
}
function directedRoadEdges(network, ways, diagnostics) {
if (!Array.isArray(network.roads) || !network.gps_bounds) {
throw new Error("Invalid osm2streets network: expected roads and gps_bounds");
}
const waysById = new Map(ways.map((way) => [String(way.id), way]));
const edges = [];
for (const entry of network.roads) {
const road = Array.isArray(entry) ? entry[1] : null;
if (!road || !Number.isInteger(Number(road.id)) || !Array.isArray(road.lane_specs_ltr)) continue;
const wayIds = Array.isArray(road.osm_ids) ? road.osm_ids.map(String) : [];
const sourceWays = wayIds.map((id) => waysById.get(id)).filter(Boolean);
const sourceWay = sourceWays[0] || null;
const tags = sourceWay?.tags || { highway: road.highway_type || "" };
if (!isCruiseHighway(tags)) continue;
if (sourceWays.length > 1 && sourceWays.some((way) => JSON.stringify(way.tags) !== JSON.stringify(sourceWay.tags))) {
addDiagnostic(diagnostics, { reason: "ambiguous_internal_road_source", road: road.id, osmWayIds: wayIds });
continue;
}
const coordinates = networkPolylineToGps(road.center_line, network.gps_bounds);
if (coordinates.length < 2 || routeLength(coordinates) < 12) continue;
const directions = new Set(road.lane_specs_ltr
.filter((lane) => lane.lt === "Driving")
.map((lane) => lane.dir));
if (directions.has("Fwd")) edges.push(makeEdge(road, sourceWay, wayIds, coordinates, "forward"));
if (directions.has("Back")) edges.push(makeEdge(road, sourceWay, wayIds, [...coordinates].reverse(), "backward"));
}
return edges.sort((a, b) => a.id.localeCompare(b.id));
}
function makeEdge(road, way, wayIds, coordinates, direction) {
const forward = direction === "forward";
const tags = way?.tags || {};
return {
id: `road-${road.id}:${direction}`,
roadId: Number(road.id),
wayId: wayIds[0] || "",
osmWayIds: wayIds,
direction,
name: road.name || tags.name || road.highway_type || "road",
highway: road.highway_type || tags.highway || "",
oneWay: directionsForRoad(road).size === 1 ? "yes" : "",
startNode: forward ? Number(road.src_i) : Number(road.dst_i),
endNode: forward ? Number(road.dst_i) : Number(road.src_i),
coordinates,
allowedTurns: allowedTurns(tags, direction),
turnLanes: turnLanes(tags, direction),
};
}
function directionsForRoad(road) {
return new Set(road.lane_specs_ltr.filter((lane) => lane.lt === "Driving").map((lane) => lane.dir));
}
function networkPolylineToGps(polyline, bounds) {
const points = Array.isArray(polyline?.pts) ? polyline.pts : [];
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
if (!(widthMeters > 0) || !(heightMeters > 0)) return [];
return points.map((point) => {
const x = Number(point.x) / 10000;
const y = Number(point.y) / 10000;
return [
bounds.min_lon + x / widthMeters * (bounds.max_lon - bounds.min_lon),
bounds.min_lat + (bounds.max_lat - bounds.min_lat) * (heightMeters - y) / heightMeters,
];
}).filter((coordinate) => coordinate.every(Number.isFinite));
}
function isOneWay(value) {
return ["yes", "true", "1"].includes(value);
}
function allowedTurns(tags, direction) {
const lanes = turnLanes(tags, direction);
if (!lanes) return ALL_TURNS;
const turns = new Set(lanes.flatMap((lane) => [...lane]).filter((turn) => ALL_TURNS.has(turn)));
return turns.size ? turns : ALL_TURNS;
}
function turnLanes(tags, direction) {
const value = tags[`turn:lanes:${direction}`] ?? tags["turn:lanes"];
if (value === undefined || value === "") return null;
return String(value).split("|").map((lane) => {
const turns = new Set(String(lane).split(";").map(normalizeTurn).filter(Boolean));
return turns.size ? turns : new Set(ALL_TURNS);
});
}
function normalizeTurn(value) {
const turn = String(value || "").trim().replace(/^slight_/, "");
if (turn === "reverse") return "u_turn";
return [...ALL_TURNS, "u_turn"].includes(turn) ? turn : null;
}
function findReturnRoutes(edges) {
const outgoing = new Map();
const byId = new Map();
for (const edge of edges) {
if (!outgoing.has(edge.startNode)) outgoing.set(edge.startNode, []);
outgoing.get(edge.startNode).push(edge);
byId.set(edge.id, edge);
}
const candidates = [];
const seen = new Set();
for (const first of edges) walkToTerminal([first], [], outgoing, byId, candidates, seen);
return candidates.sort((a, b) => a.signature.localeCompare(b.signature));
}
function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
const current = path.at(-1);
if (path.length >= MIN_ROUTE_EDGES) {
const route = returnRoute(path, maneuvers, byId);
if (route && !seen.has(route.signature)) {
seen.add(route.signature);
candidates.push(route);
}
}
if (path.length >= MAX_PATH_EDGES) return;
const nextSteps = [];
for (const next of outgoing.get(current.endNode) || []) {
if (path.some((edge) => edge.id === next.id)) continue;
const maneuver = classifyConnection(current, next);
if (!maneuver || !current.allowedTurns.has(maneuver)) continue;
nextSteps.push({ edge: next, maneuver });
}
for (const step of nextSteps) {
walkToTerminal([...path, step.edge], [...maneuvers, step.maneuver], outgoing, byId, candidates, seen);
}
}
function classifyConnection(incoming, outgoing) {
if (incoming.roadId === outgoing.roadId) return null;
const inVector = directionVector(incoming.coordinates.at(-2), incoming.coordinates.at(-1));
const outVector = directionVector(outgoing.coordinates[0], outgoing.coordinates[1]);
const dot = inVector.x * outVector.x + inVector.y * outVector.y;
const cross = inVector.x * outVector.y - inVector.y * outVector.x;
const angle = Math.atan2(cross, dot) * 180 / Math.PI;
if (Math.abs(angle) >= 150) return null;
if (Math.abs(angle) <= 35) return "through";
return angle > 0 ? "left" : "right";
}
function directionVector(a, b) {
const scale = 111320;
const x = (b[0] - a[0]) * scale * Math.cos(degreesToRadians((a[1] + b[1]) / 2));
const y = (b[1] - a[1]) * scale;
const length = Math.hypot(x, y) || 1;
return { x: x / length, y: y / length };
}
function returnRoute(path, forwardManeuvers, byId) {
const reverse = path.slice().reverse().map((edge) => byId.get(`road-${edge.roadId}:${oppositeDirection(edge.direction)}`));
if (reverse.some((edge) => !edge)) return null;
const returnManeuvers = [];
for (let index = 1; index < reverse.length; index += 1) {
const maneuver = classifyConnection(reverse[index - 1], reverse[index]);
if (!maneuver) return null;
returnManeuvers.push(maneuver);
}
const signature = path.map((edge) => edge.roadId).join(">");
return {
edges: [...path, ...reverse],
maneuvers: [...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
forwardEdgeCount: path.length,
signature,
};
}
function oppositeDirection(direction) {
return direction === "forward" ? "backward" : "forward";
}
function makeRoute(candidate, laneIndex, intersections, diagnostics) {
const selectedLanes = [];
for (let index = 0; index < candidate.edges.length; index += 1) {
const edge = candidate.edges[index];
const match = selectLaneForEdge(edge, candidate.maneuvers[index], laneIndex, {
// 仅去程中的真实路口受 turn:lanes 严格约束;端点调头与展示返程不能被反向标签否决。
enforceTurnRestrictions: index < candidate.forwardEdgeCount - 1,
});
if (!match.ok) {
addDiagnostic(diagnostics, {
reason: match.reason,
routeSignature: candidate.signature,
edgeId: edge.id,
road: edge.roadId,
osmWayId: edge.wayId,
direction: edge.direction,
maneuver: candidate.maneuvers[index],
detail: match.detail,
});
return null;
}
selectedLanes.push(match.lane);
}
const smoothed = smoothLaneRoute(candidate.edges, selectedLanes, intersections);
if (!smoothed.ok) {
addDiagnostic(diagnostics, { reason: smoothed.reason, routeSignature: candidate.signature, ...smoothed.detail });
return null;
}
const coordinates = smoothed.coordinates;
const centerlineCoordinates = smoothRoute(candidate.edges);
const route = {
id: `route-${candidate.signature.replace(/[^\w]+/g, "-")}`,
highway: candidate.edges[0].highway,
oneWay: candidate.edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
edgeIds: candidate.edges.map((edge) => edge.id),
maneuvers: candidate.maneuvers,
lengthMeters: routeLength(coordinates),
coordinates,
centerlineCoordinates,
laneSegments: selectedLanes.flatMap((lane, edgeIndex) => lane.fragments.map((fragment) => ({
edgeId: candidate.edges[edgeIndex].id,
osmWayId: candidate.edges[edgeIndex].wayId,
direction: candidate.edges[edgeIndex].direction,
laneIndex: lane.laneIndex,
widthMeters: fragment.widthMeters,
centerOffsetMeters: Number(fragment.centerOffsetMeters.toFixed(3)),
maneuver: candidate.maneuvers[edgeIndex],
source: "lane_polygon_centerline",
polygonId: fragment.polygonId,
featureIndex: fragment.featureIndex,
road: fragment.road,
}))),
connectors: smoothed.connectors,
};
Object.defineProperty(route, "signature", { value: candidate.signature });
return route;
}
function selectLaneForEdge(edge, maneuver, laneIndex, options = {}) {
const enforceTurnRestrictions = options.enforceTurnRestrictions !== false;
const expectedDirection = edge.direction === "forward" ? "Fwd" : "Back";
const candidates = laneIndex.get(laneKey(edge.roadId, expectedDirection)) || [];
if (!candidates.length) return { ok: false, reason: "missing_lane_polygon" };
const lanes = [];
for (const fragment of candidates) {
const centerline = orientPolyline(fragment.centerline, edge.coordinates);
if (!centerline) return { ok: false, reason: "invalid_lane_polygon", detail: "direction_alignment" };
const midpoint = polylineMidpoint(centerline);
const offset = lateralOffsetFrom(edge.coordinates, midpoint);
if (!offset || offset.distance > MAX_LANE_DISTANCE_METERS) {
return { ok: false, reason: "missing_lane_polygon", detail: "geometry_too_far_from_internal_road" };
}
lanes.push({
laneIndex: fragment.laneIndex,
centerline,
centerOffsetMeters: offset.lateral,
allowedTurns: fragment.allowedTurns,
fragments: [{ ...fragment, centerline, centerOffsetMeters: offset.lateral }],
});
}
lanes.sort((a, b) => a.centerOffsetMeters - b.centerOffsetMeters || String(a.laneIndex).localeCompare(String(b.laneIndex)));
for (let index = 1; index < lanes.length; index += 1) {
if (lanes[index].centerOffsetMeters - lanes[index - 1].centerOffsetMeters < MIN_LATERAL_SEPARATION_METERS) {
return { ok: false, reason: "ambiguous_lane_order" };
}
}
if (enforceTurnRestrictions && edge.turnLanes && edge.turnLanes.length !== lanes.length) {
return { ok: false, reason: "ambiguous_lane_order", detail: "turn_lane_count_mismatch" };
}
let compatible = lanes.filter((lane, index) => laneSupportsManeuver(lane, edge.turnLanes?.[index], maneuver));
if (!compatible.length && !enforceTurnRestrictions) compatible = lanes;
if (!compatible.length) return { ok: false, reason: "no_compatible_turn_lane" };
const chooseLeft = maneuver === "left" || maneuver === "u_turn";
return { ok: true, lane: chooseLeft ? compatible[0] : compatible.at(-1) };
}
function laneSupportsManeuver(lane, osmTurns, maneuver) {
const expected = maneuver === "u_turn" ? "left" : maneuver;
if (osmTurns && !osmTurns.has(expected) && !(maneuver === "u_turn" && osmTurns.has("u_turn"))) return false;
if (lane.allowedTurns.size && !lane.allowedTurns.has(expected) && !(maneuver === "u_turn" && lane.allowedTurns.has("u_turn"))) return false;
return true;
}
function laneKey(roadId, direction) {
return `${String(roadId)}:${direction}`;
}
function addDiagnostic(diagnostics, entry) {
const key = JSON.stringify(entry);
if (!diagnostics.some((current) => JSON.stringify(current) === key)) diagnostics.push(entry);
}
function indexIntersections(network, features) {
if (!Array.isArray(network.intersections)) throw new Error("Invalid osm2streets network: expected intersections");
const surfaces = new Map(features
.filter((feature) => feature?.geometry?.type === "Polygon" && Number.isInteger(Number(feature.properties?.id)))
.map((feature) => [Number(feature.properties.id), feature.geometry.coordinates[0]]));
const intersections = new Map();
for (const entry of network.intersections) {
const intersection = Array.isArray(entry) ? entry[1] : null;
if (!intersection || !Number.isInteger(Number(intersection.id))) continue;
intersections.set(Number(intersection.id), {
id: Number(intersection.id),
osmNodeIds: Array.isArray(intersection.osm_ids) ? intersection.osm_ids.map(String) : [],
surface: surfaces.get(Number(intersection.id)) || null,
});
}
return intersections;
}
function smoothLaneRoute(edges, selectedLanes, intersections) {
const route = [];
const connectors = [];
for (let index = 0; index < edges.length; index += 1) {
const current = selectedLanes[index].centerline;
appendCoordinates(route, current);
const nextIndex = (index + 1) % edges.length;
const next = selectedLanes[nextIndex].centerline;
const incomingEdge = edges[index];
const outgoingEdge = edges[nextIndex];
if (incomingEdge.endNode !== outgoingEdge.startNode) {
return { ok: false, reason: "disconnected_internal_roads", detail: { fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId } };
}
const intersection = intersections.get(incomingEdge.endNode);
if (!intersection?.surface) {
return { ok: false, reason: "missing_intersection_surface", detail: { intersectionId: incomingEdge.endNode } };
}
const isUTurn = incomingEdge.roadId === outgoingEdge.roadId;
const turn = constrainedConnector(current, next, incomingEdge.coordinates.at(-1), intersection.surface, isUTurn);
if (!turn) {
return {
ok: false,
reason: "connector_outside_intersection",
detail: { intersectionId: intersection.id, fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId },
};
}
appendCoordinates(route, turn.slice(1));
connectors.push({
intersectionId: intersection.id,
osmNodeIds: intersection.osmNodeIds,
fromRoad: incomingEdge.roadId,
toRoad: outgoingEdge.roadId,
maneuver: isUTurn ? "u_turn" : classifyConnection(incomingEdge, outgoingEdge),
source: "intersection_surface_constrained",
coordinates: turn,
});
}
if (route.length) route[route.length - 1] = [...route[0]];
return { ok: true, coordinates: route, connectors };
}
function constrainedConnector(incoming, outgoing, junction, surface, isUTurn) {
const scales = isUTurn ? [1, 0.8, 0.6, 0.4, 0.25] : [1, 0.75, 0.5, 0.3, 0.15];
for (const scale of scales) {
const connector = isUTurn
? uTurnConnector(incoming, outgoing, junction, 20, scale)
: tangentBezierTurn(incoming, outgoing, 16, scale);
if (connector.length && connector.every((point) => pointInPolygonOrNear(point, surface, CONNECTOR_SURFACE_TOLERANCE_METERS))) {
return connector;
}
}
return null;
}
function pointInPolygonOrNear(point, ring, toleranceMeters) {
if (!Array.isArray(ring) || ring.length < 4) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const a = ring[i];
const b = ring[j];
if ((a[1] > point[1]) !== (b[1] > point[1]) &&
point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
if (distanceToSegmentMeters(point, a, b) <= toleranceMeters) return true;
}
return inside;
}
function distanceToSegmentMeters(point, start, end) {
const latitude = (point[1] + start[1] + end[1]) / 3;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const px = (point[0] - start[0]) * metersLon;
const py = (point[1] - start[1]) * 111320;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return Math.hypot(px - dx * ratio, py - dy * ratio);
}
function smoothRoute(edges) {
const trimmed = edges.map((edge) => trimPolyline(edge.coordinates, JUNCTION_TRIM_METERS));
const route = [];
for (let index = 0; index < edges.length; index += 1) {
appendCoordinates(route, trimmed[index]);
const nextIndex = (index + 1) % edges.length;
const turn = edges[index].roadId === edges[nextIndex].roadId
? uTurnConnector(trimmed[index], trimmed[nextIndex], edges[index].coordinates.at(-1))
: tangentBezierTurn(trimmed[index], trimmed[nextIndex]);
appendCoordinates(route, turn.slice(1));
}
if (route.length) route[route.length - 1] = [...route[0]];
return route;
}
function tangentBezierTurn(incoming, outgoing, samples = 16, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const intersection = intersectTangentRays(start, end, incomingTangent, outgoingTangent);
let controlA;
let controlB;
if (intersection && intersection.a >= 0 && intersection.b >= 0) {
// 两条车道切线的前向交点定义了转弯的几何目标Bezier 控制点取三分之一距离。
const maxA = Math.min(8, Math.max(0.75, incomingSpan * 2.4));
const maxB = Math.min(8, Math.max(0.75, outgoingSpan * 2.4));
const distanceA = Math.min(intersection.a, maxA) * scale;
const distanceB = Math.min(intersection.b, maxB) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * distanceA / 3, incomingTangent.y * distanceA / 3);
controlB = offsetCoordinate(end, -outgoingTangent.x * distanceB / 3, -outgoingTangent.y * distanceB / 3);
} else {
// 平行、反向或交点在车道后方时,使用受限 fallback避免生成反向回环。
const chordMeters = haversineMeters(start, end);
const controlMeters = boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, 0.42, 8) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
}
return cubicBezier(start, controlA, controlB, end, samples);
}
function intersectTangentRays(start, end, incomingTangent, outgoingTangent) {
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const qx = (end[0] - start[0]) * metersLon;
const qy = (end[1] - start[1]) * 111320;
const cross = incomingTangent.x * outgoingTangent.y - incomingTangent.y * outgoingTangent.x;
if (Math.abs(cross) < 1e-6) return null;
const crossQOutgoing = qx * outgoingTangent.y - qy * outgoingTangent.x;
const crossQIncoming = qx * incomingTangent.y - qy * incomingTangent.x;
return {
a: crossQOutgoing / cross,
b: crossQIncoming / cross,
};
}
function uTurnConnector(incoming, outgoing, junction, samples = 20, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const chordMeters = haversineMeters(start, end);
const approachMeters = Math.max(haversineMeters(start, junction), haversineMeters(end, junction));
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const availableMeters = Math.max(0.5, Math.min(10, incomingSpan * 0.8, outgoingSpan * 0.8));
const controlMeters = Math.min(availableMeters, Math.max(Math.min(2, availableMeters), chordMeters * 1.1, approachMeters * 0.6)) * scale;
const controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
const controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
return cubicBezier(start, controlA, controlB, end, samples);
}
function boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, ratio, maximumMeters) {
const lowerMeters = Math.min(1.5, chordMeters * 0.35);
const upperMeters = Math.max(0.25, Math.min(maximumMeters, chordMeters * 0.65, incomingSpan * 0.8, outgoingSpan * 0.8));
return Math.min(upperMeters, Math.max(lowerMeters, chordMeters * ratio));
}
function offsetCoordinate(coord, eastMeters, northMeters) {
const metersPerLat = 111320;
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
}
function trimPolyline(coords, distance) {
if (coords.length < 2 || routeLength(coords) <= distance * 2.5) return [...coords];
const start = pointAlong(coords, distance);
const end = pointAlong([...coords].reverse(), distance);
return [start, ...coords.slice(1, -1), end];
}
function pointAlong(coords, distance) {
let remaining = distance;
for (let index = 1; index < coords.length; index += 1) {
const span = haversineMeters(coords[index - 1], coords[index]);
if (span >= remaining) return lerpCoordinate(coords[index - 1], coords[index], remaining / span);
remaining -= span;
}
return [...coords.at(-1)];
}
function cubicBezier(start, controlA, controlB, end, samples) {
const points = [];
for (let index = 0; index <= samples; index += 1) {
const t = index / samples;
const u = 1 - t;
points.push([
u ** 3 * start[0] + 3 * u ** 2 * t * controlA[0] + 3 * u * t ** 2 * controlB[0] + t ** 3 * end[0],
u ** 3 * start[1] + 3 * u ** 2 * t * controlA[1] + 3 * u * t ** 2 * controlB[1] + t ** 3 * end[1],
]);
}
return points;
}
function selectRoutes(candidates) {
const selected = [];
const covered = new Set();
const remaining = [...candidates];
while (selected.length < MAX_ROUTES && remaining.length) {
remaining.sort((a, b) => routeScore(b, covered) - routeScore(a, covered) || a.id.localeCompare(b.id));
const next = remaining.shift();
selected.push(next);
for (const maneuver of next.maneuvers) covered.add(maneuver);
}
return selected;
}
function routeScore(route, covered) {
const novelty = new Set(route.maneuvers.filter((maneuver) => ALL_TURNS.has(maneuver) && !covered.has(maneuver))).size;
return novelty * 100000 + route.lengthMeters;
}
function routeLength(coords) {
return polylineLength(coords);
}
function lerpCoordinate(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = {
allowedTurns,
buildVehicleRoute,
classifyConnection,
readLanePolygons,
selectLaneForEdge,
tangentBezierTurn,
turnLanes,
uTurnConnector,
};