338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const { parseOsm } = require("./osm");
|
|
|
|
const MAX_ROUTES = 5;
|
|
const MAX_PATH_EDGES = 7;
|
|
const MIN_ROUTE_EDGES = 3;
|
|
const LANE_OFFSET_METERS = 1.3;
|
|
const JUNCTION_TRIM_METERS = 6.0;
|
|
const ALL_TURNS = new Set(["left", "through", "right"]);
|
|
|
|
function buildVehicleRoute(osmPath) {
|
|
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
|
|
const edges = directedRoadEdges(osm.ways, osm.nodes, osm.bounds);
|
|
const routes = selectRoutes(findReturnRoutes(edges));
|
|
return {
|
|
source: osmPath,
|
|
bounds: osm.bounds,
|
|
generatedAt: new Date().toISOString(),
|
|
speedMetersPerSecond: 8.0,
|
|
loop: true,
|
|
routes,
|
|
// Older previews read `segments`; keep it as an alias while new previews
|
|
// use the more accurate route name.
|
|
segments: routes,
|
|
};
|
|
}
|
|
|
|
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(ways, nodes, bounds) {
|
|
const edges = [];
|
|
for (const way of ways) {
|
|
if (!isCruiseHighway(way.tags)) continue;
|
|
const refs = compactRefs(way.refs);
|
|
if (refs.length < 2) continue;
|
|
const coords = refs.map((ref) => nodes.get(ref));
|
|
if (!routeInsideBounds(coords, bounds) || routeLength(coords) < 12) continue;
|
|
const oneway = String(way.tags.oneway || "").toLowerCase();
|
|
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
|
|
if (!isOneWay(oneway)) {
|
|
edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
|
|
}
|
|
}
|
|
return edges.sort((a, b) => a.id.localeCompare(b.id));
|
|
}
|
|
|
|
function makeEdge(way, refs, coordinates, direction) {
|
|
return {
|
|
id: `${way.id}:${direction}`,
|
|
wayId: way.id,
|
|
direction,
|
|
name: way.tags.name || way.tags.highway || "road",
|
|
highway: way.tags.highway || "",
|
|
oneWay: way.tags.oneway || "",
|
|
startNode: refs[0],
|
|
endNode: refs[refs.length - 1],
|
|
coordinates,
|
|
allowedTurns: allowedTurns(way.tags, direction),
|
|
};
|
|
}
|
|
|
|
function isOneWay(value) {
|
|
return ["yes", "true", "1"].includes(value);
|
|
}
|
|
|
|
function compactRefs(refs) {
|
|
return refs.filter((ref, index) => index === 0 || ref !== refs[index - 1]);
|
|
}
|
|
|
|
function routeInsideBounds(coords, bounds) {
|
|
if (!bounds) return true;
|
|
return coords.some((coord) => insideBounds(coord, bounds));
|
|
}
|
|
|
|
function insideBounds(coord, bounds) {
|
|
const pad = 0.00002;
|
|
return coord[0] >= bounds.minLon - pad && coord[0] <= bounds.maxLon + pad &&
|
|
coord[1] >= bounds.minLat - pad && coord[1] <= bounds.maxLat + pad;
|
|
}
|
|
|
|
function allowedTurns(tags, direction) {
|
|
const value = tags[`turn:lanes:${direction}`] || tags["turn:lanes"];
|
|
if (!value) return ALL_TURNS;
|
|
const turns = new Set();
|
|
for (const lane of String(value).split("|")) {
|
|
for (const maneuver of lane.split(";")) {
|
|
const normalized = maneuver.trim().replace(/^slight_/, "");
|
|
if (ALL_TURNS.has(normalized)) turns.add(normalized);
|
|
}
|
|
}
|
|
return turns.size ? turns : ALL_TURNS;
|
|
}
|
|
|
|
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[path.length - 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.wayId === outgoing.wayId) 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.0;
|
|
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(`${edge.wayId}:${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.wayId).sort().join(">");
|
|
return makeRoute(
|
|
[...path, ...reverse],
|
|
[...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
|
|
signature,
|
|
);
|
|
}
|
|
|
|
function oppositeDirection(direction) {
|
|
return direction === "forward" ? "backward" : "forward";
|
|
}
|
|
|
|
function makeRoute(edges, maneuvers, signature) {
|
|
const coordinates = smoothRoute(edges);
|
|
const route = {
|
|
id: `route-${signature.replace(/[^\w]+/g, "-")}`,
|
|
highway: edges[0].highway,
|
|
oneWay: edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
|
|
edgeIds: edges.map((edge) => edge.id),
|
|
maneuvers,
|
|
lengthMeters: routeLength(coordinates),
|
|
laneOffsetMeters: LANE_OFFSET_METERS,
|
|
coordinates: offsetClosedRouteRight(coordinates, LANE_OFFSET_METERS),
|
|
centerlineCoordinates: coordinates,
|
|
};
|
|
Object.defineProperty(route, "signature", { value: signature });
|
|
return route;
|
|
}
|
|
|
|
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 junction = edges[index].coordinates.at(-1);
|
|
const turn = edges[index].wayId === edges[nextIndex].wayId
|
|
? uTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0])
|
|
: bezierTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0], 6);
|
|
appendCoordinates(route, turn.slice(1));
|
|
}
|
|
if (route.length) route[route.length - 1] = [...route[0]];
|
|
return route;
|
|
}
|
|
|
|
function uTurn(start, junction, end) {
|
|
const tangent = directionVector(start, junction);
|
|
const left = offsetCoordinate(junction, -tangent.y * 3.0, tangent.x * 3.0);
|
|
const right = offsetCoordinate(junction, tangent.y * 3.0, -tangent.x * 3.0);
|
|
return [
|
|
start,
|
|
lerpCoordinate(start, junction, 0.72),
|
|
left,
|
|
right,
|
|
lerpCoordinate(end, junction, 0.72),
|
|
end,
|
|
];
|
|
}
|
|
|
|
function offsetCoordinate(coord, eastMeters, northMeters) {
|
|
const metersPerLat = 111320.0;
|
|
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 bezierTurn(start, junction, end, samples) {
|
|
const controlA = lerpCoordinate(start, junction, 0.72);
|
|
const controlB = lerpCoordinate(end, junction, 0.72);
|
|
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 appendCoordinates(target, coordinates) {
|
|
for (const coord of coordinates) {
|
|
const last = target.at(-1);
|
|
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) target.push([...coord]);
|
|
}
|
|
}
|
|
|
|
function offsetClosedRouteRight(coords, offset) {
|
|
const shifted = offsetPolylineRight(coords, offset);
|
|
if (shifted.length) shifted[shifted.length - 1] = [...shifted[0]];
|
|
return shifted;
|
|
}
|
|
|
|
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 offsetPolylineRight(coords, offsetMeters) {
|
|
if (coords.length < 2 || offsetMeters === 0) return coords.map((coord) => [...coord]);
|
|
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
|
|
const metersPerLat = 111320.0;
|
|
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
|
|
const points = coords.map((coord) => ({ x: coord[0] * metersPerLon, y: coord[1] * metersPerLat, lon: coord[0], lat: coord[1] }));
|
|
return points.map((point, index) => {
|
|
const prev = points[Math.max(0, index - 1)];
|
|
const next = points[Math.min(points.length - 1, index + 1)];
|
|
const length = Math.hypot(next.x - prev.x, next.y - prev.y);
|
|
if (length < 0.001) return [point.lon, point.lat];
|
|
const dx = (next.x - prev.x) / length;
|
|
const dy = (next.y - prev.y) / length;
|
|
return [(point.x + dy * offsetMeters) / metersPerLon, (point.y - dx * offsetMeters) / metersPerLat];
|
|
});
|
|
}
|
|
|
|
function routeLength(coords) {
|
|
let total = 0;
|
|
for (let index = 1; index < coords.length; index += 1) total += haversineMeters(coords[index - 1], coords[index]);
|
|
return total;
|
|
}
|
|
|
|
function haversineMeters(a, b) {
|
|
const radius = 6371008.8;
|
|
const lat1 = degreesToRadians(a[1]);
|
|
const lat2 = degreesToRadians(b[1]);
|
|
const dLat = degreesToRadians(b[1] - a[1]);
|
|
const dLon = degreesToRadians(b[0] - a[0]);
|
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
|
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
}
|
|
|
|
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 = { buildVehicleRoute, classifyConnection, allowedTurns };
|