"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 };