#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { cesiumPreviewHtml } = require("./lib/area-preview");
const { makeVehicleGltf } = require("./lib/vehicle-model");
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const {
allowedTurns,
buildVehicleRoute,
classifyConnection,
tangentBezierTurn,
uTurnConnector,
} = require("./lib/vehicle-route");
const { buildTrafficSignals } = require("./lib/traffic-signals");
const { haversineMeters, laneCenterline } = require("./lib/lane-geometry");
const { parseOsm } = require("./lib/osm");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
const osmPath = path.join(tempDir, "fixture.osm");
const lanePolygonsPath = path.join(tempDir, "lane_polygons.geojson");
const networkPath = path.join(tempDir, "network.json");
const intersectionSurfacePath = path.join(tempDir, "intersection_surface.geojson");
fs.writeFileSync(osmPath, `
`);
const roadCoordinates = {
"west-road": [[120.001, 30.001], [120.003, 30.001]],
"turn-road": [[120.003, 30.001], [120.005, 30.002]],
"east-road": [[120.005, 30.002], [120.007, 30.002]],
};
const laneFeatures = [
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Back", 3.5, [5.25, 1.75], 0),
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Fwd", 3.5, [1.75, 5.25], 2),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Back", 3.0, [1.5], 0),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Fwd", 3.0, [1.5, 4.5], 1),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Back", 3.5, [1.75], 0),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Fwd", 3.5, [1.75], 1),
{ type: "Feature", properties: { type: "Driving", direction: "Fwd", index: 99, width: 3, road: 99, osm_way_ids: ["broken"] }, geometry: { type: "Polygon", coordinates: [[]] } },
];
fs.writeFileSync(lanePolygonsPath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures })}\n`);
const fixtureBounds = { min_lon: 120, min_lat: 30, max_lon: 120.01, max_lat: 30.01 };
const networkRoads = [
networkRoad(0, "west-road", 0, 1, roadCoordinates["west-road"], [laneSpec("Back", 3.5), laneSpec("Back", 3.5), laneSpec("Fwd", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds, "primary"),
networkRoad(1, "turn-road", 1, 2, roadCoordinates["turn-road"], [laneSpec("Back", 3), laneSpec("Fwd", 3), laneSpec("Fwd", 3)], fixtureBounds),
networkRoad(2, "east-road", 2, 3, roadCoordinates["east-road"], [laneSpec("Back", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds),
networkRoad(3, "oneway-spur", 3, 4, [[120.007, 30.002], [120.009, 30.002]], [laneSpec("Fwd", 3.5)], fixtureBounds),
];
fs.writeFileSync(networkPath, `${JSON.stringify({
roads: networkRoads.map((road) => [road.id, road]),
intersections: [0, 1, 2, 3, 4].map((id) => [id, { id, osm_ids: [String(id + 1)] }]),
gps_bounds: fixtureBounds,
})}\n`);
fs.writeFileSync(intersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 9)),
})}\n`);
const route = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath);
assert.equal(route.source, osmPath);
assert.equal(route.laneSource, lanePolygonsPath);
assert.equal(route.networkSource, networkPath);
assert.equal(route.intersectionSource, intersectionSurfacePath);
assert.deepEqual(route.bounds, {
minLon: 120,
minLat: 30,
maxLon: 120.01,
maxLat: 30.01,
});
assert.equal(route.speedMetersPerSecond, 8);
assert.equal(route.loop, true);
assert.equal(route.segments, route.routes, "legacy segments remains an alias for the new route list");
assert.ok(route.routes.length >= 1 && route.routes.length <= 5);
assert.ok(route.routes.every((segment) => segment.edgeIds.length >= 6));
assert.ok(route.routes.every((segment) => segment.maneuvers.includes("u_turn")));
assert.ok(route.routes.every((segment) => segment.maneuvers.some((value) => ["left", "right", "through"].includes(value))));
assert.ok(route.routes.every((segment) => JSON.stringify(segment.coordinates[0]) === JSON.stringify(segment.coordinates.at(-1))));
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("road-3:backward")));
assert.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
assert.ok(route.routes.every((segment) => !Object.hasOwn(segment, "laneOffsetMeters")));
assert.ok(route.routes.every((segment) => segment.laneSegments.length >= segment.edgeIds.length));
assert.ok(route.routes.every((segment) => segment.connectors.length === segment.edgeIds.length));
assert.ok(route.routes.flatMap((segment) => segment.connectors).every((connector) =>
connector.source === "intersection_surface_constrained" && connector.coordinates.length >= 2
));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3.5));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).every((segment) =>
segment.source === "lane_polygon_centerline" && Number.isFinite(segment.centerOffsetMeters)
));
for (const segment of route.routes) {
for (const lane of segment.laneSegments) {
const polygonCenterline = laneCenterline(laneFeatures[lane.featureIndex]);
assert.ok(polygonCenterline, "selected lane polygon has a valid centerline");
assert.ok(polygonCenterline.every((point) =>
Math.min(...segment.coordinates.map((coordinate) => haversineMeters(point, coordinate))) <= 0.10
), "route coordinates retain every selected lane centerline point within 0.10 m");
}
}
assert.ok(route.diagnostics.some((entry) => entry.reason === "invalid_lane_polygon"));
const westThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "west-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 3
);
assert.ok(westThrough, "through uses the rightmost compatible lane on west-road");
assert.ok(Math.abs(westThrough.centerOffsetMeters - 5.25) <= 0.01, "3.5 m lane geometry produces the 5.25 m outer-lane center");
const turnThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 1
);
assert.ok(turnThrough, "turn lane restrictions override the default rightmost choice");
assert.ok(Math.abs(turnThrough.centerOffsetMeters - 1.5) <= 0.01, "3.0 m lane geometry produces the 1.5 m inner-lane center");
assert.ok(route.routes.some((segment) => segment.laneSegments.some((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "backward" && lane.maneuver === "through"
)), "display return survives an incompatible reverse turn:lanes tag");
const missingLanePath = path.join(tempDir, "missing-lane.geojson");
fs.writeFileSync(missingLanePath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures.filter((feature) =>
!feature.properties.osm_way_ids.includes("east-road")
) })}\n`);
const missingLaneRoute = buildVehicleRoute(osmPath, missingLanePath, networkPath, intersectionSurfacePath);
assert.equal(missingLaneRoute.routes.length, 0);
assert.ok(missingLaneRoute.diagnostics.some((entry) => entry.reason === "missing_lane_polygon"));
const tinyIntersectionSurfacePath = path.join(tempDir, "tiny-intersection-surface.geojson");
fs.writeFileSync(tinyIntersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 0.1)),
})}\n`);
const rejectedConnectors = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, tinyIntersectionSurfacePath);
assert.equal(rejectedConnectors.routes.length, 0);
assert.ok(rejectedConnectors.diagnostics.some((entry) => entry.reason === "connector_outside_intersection"));
assert.throws(() => buildVehicleRoute(osmPath, path.join(tempDir, "absent.geojson"), networkPath, intersectionSurfacePath), /Invalid lane polygons JSON/);
const quad = laneFeatures[0];
assert.equal(laneCenterline(quad).length, 2);
assert.equal(laneCenterline({ geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [0, 0]]] } }), null);
assert.deepEqual(
[...allowedTurns({ "turn:lanes:forward": "left|through;right" }, "forward")].sort(),
["left", "right", "through"],
);
const incoming = { roadId: 1, coordinates: [[120, 30], [120.001, 30]] };
assert.equal(classifyConnection(incoming, { roadId: 2, coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
assert.equal(classifyConnection(incoming, { roadId: 3, coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
assert.equal(classifyConnection(incoming, { roadId: 4, coordinates: [[120.001, 30], [120.002, 30]] }), "through");
const connectorOrigin = [120, 30];
const incomingLane = [metersCoordinate(connectorOrigin, -12, -1.5), metersCoordinate(connectorOrigin, -5, -1.5)];
const leftOutgoingLane = [metersCoordinate(connectorOrigin, 1.5, 5), metersCoordinate(connectorOrigin, 1.5, 12)];
const rightOutgoingLane = [metersCoordinate(connectorOrigin, -1.5, -5), metersCoordinate(connectorOrigin, -1.5, -12)];
for (const [label, outgoingLane] of [["left", leftOutgoingLane], ["right", rightOutgoingLane]]) {
const connector = tangentBezierTurn(incomingLane, outgoingLane);
assert.deepEqual(connector[0], incomingLane.at(-1), `${label} connector retains the incoming lane endpoint`);
assert.deepEqual(connector.at(-1), outgoingLane[0], `${label} connector retains the outgoing lane endpoint`);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), connector[0], connector[1]) < 5,
`${label} connector enters along the incoming lane tangent`);
assert.ok(tangentMismatchDegrees(connector.at(-2), connector.at(-1), outgoingLane[0], outgoingLane[1]) < 5,
`${label} connector exits along the outgoing lane tangent`);
assert.ok(maxStepMeters(connector) < 1.5, `${label} connector sampling has no abnormal position jump`);
}
const uTurnOutgoingLane = [metersCoordinate(connectorOrigin, -5, 1.5), metersCoordinate(connectorOrigin, -12, 1.5)];
const uTurn = uTurnConnector(incomingLane, uTurnOutgoingLane, connectorOrigin);
assert.deepEqual(uTurn[0], incomingLane.at(-1));
assert.deepEqual(uTurn.at(-1), uTurnOutgoingLane[0]);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), uTurn[0], uTurn[1]) < 5,
"U-turn enters along the incoming lane tangent");
assert.ok(tangentMismatchDegrees(uTurn.at(-2), uTurn.at(-1), uTurnOutgoingLane[0], uTurnOutgoingLane[1]) < 5,
"U-turn exits along the outgoing lane tangent");
assert.ok(maxStepMeters(uTurn) < 1, "U-turn sampling has no abnormal position jump");
assert.ok(Math.max(...uTurn.map((coordinate) => eastMeters(connectorOrigin, coordinate))) > -3,
"U-turn forms a forward loop instead of a fixed lateral polyline");
const vehicle = makeVehicleGltf();
assert.equal(vehicle.asset.version, "2.0");
assert.equal(vehicle.asset.generator, "osm-asset-pipeline vehicle preview");
assert.equal(vehicle.scene, 0);
assert.equal(vehicle.meshes.length, vehicle.nodes.length);
assert.equal(vehicle.scenes[0].nodes.length, vehicle.nodes.length);
assert.deepEqual(
vehicle.materials.map((material) => material.name),
["paint red", "dark roof", "glass", "tire", "wheel hub", "headlight", "tail light"],
);
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "body"));
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "wheel_-1.55_-1.02"));
assert.match(vehicle.buffers[0].uri, /^data:application\/octet-stream;base64,/);
assert.equal(
Buffer.from(vehicle.buffers[0].uri.split(",")[1], "base64").length,
vehicle.buffers[0].byteLength,
);
const vehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "custom", "lowpoly_cars");
if (fs.existsSync(vehicleLibraryRoot)) {
const libraryDir = path.join(tempDir, "vehicle-library");
const models = writePreviewVehicleLibrary(libraryDir, "fixture");
assert.deepEqual(models, VEHICLE_IDS.map((id) => `fixture-vehicle-${id}.gltf`));
assert.equal(models.length, 6);
assert.ok(fs.existsSync(path.join(libraryDir, "fixture-vehicle-texture.jpg")));
for (const model of models) {
const gltf = JSON.parse(fs.readFileSync(path.join(libraryDir, model), "utf8"));
assert.deepEqual(gltf.images.map((image) => image.uri), ["fixture-vehicle-texture.jpg"]);
assert.equal(gltf.buffers[0].uri, model.replace(/\.gltf$/, ".bin"));
assert.deepEqual(gltf.materials[0].pbrMetallicRoughness.baseColorFactor, [2.1, 2.1, 2.1, 1]);
assert.deepEqual(gltf.materials[0].emissiveFactor, [0.25, 0.25, 0.25]);
assert.equal(gltf.materials[0].emissiveTexture.index, gltf.materials[0].pbrMetallicRoughness.baseColorTexture.index);
const reversed = model.includes("truck_a03_001");
assert.equal(reversed, REVERSED_MODEL_IDS.has(model.match(/vehicle-(.+)\.gltf$/)[1]));
assert.equal(gltf.nodes.every((node) => JSON.stringify(node.rotation || []) === JSON.stringify([0, 1, 0, 0])), reversed);
}
}
const html = cesiumPreviewHtml(
"scene<&>.glb",
"scene.json",
"route.json",
"vehicle.gltf",
"north<&>\u2028valley",
["car-a.gltf", "truck-a.gltf"],
"traffic-signals.json",
);
assert.match(html, /
north<&>\u2028valley Cesium Preview<\/title>/);
assert.match(html, /Loading scene<&>\.glb/);
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
assert.match(html, /id="toggleSignals"/);
assert.match(html, /id="toggleBuildingGhost"/);
assert.match(html, /id="viewMode"/);
assert.match(html, /data-view-mode="inspect"/);
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
assert.match(previewRuntime, /asset\.category === "dynamic"/);
assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
assert.match(previewRuntime, /setBuildingGhost/);
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);
assert.match(previewRuntime, /syncSelectedRouteVisibility\(cruise\)/);
assert.match(previewRuntime, /buildings\.model\.color = Cesium\.Color\.WHITE\.withAlpha\(0\.22\)/);
assert.match(previewRuntime, /asset\.category === "countdown"/);
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
assert.doesNotMatch(previewRuntime, /digitMap/);
assert.match(previewRuntime, /Do not cache a miss/);
assert.match(previewRuntime, /scene\.requestRender/);
assert.doesNotMatch(previewRuntime, /function addTrafficSignals\(viewer, signalData, start, placement\)/);
assert.doesNotMatch(previewRuntime, /ellipsoid:/);
const trafficIntersection = { type: "FeatureCollection", features: [
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
]] } },
] };
const tStopLines = { type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
] };
const control = {
id: "traffic-t", longitude: 120.0001, latitude: 30.00005,
arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }],
};
const noControlSignals = buildTrafficSignals(tStopLines, trafficIntersection);
assert.equal(noControlSignals.signals.length, 0, "untagged intersections must not create traffic signals");
const tSignals = buildTrafficSignals(tStopLines, trafficIntersection, [control]);
assert.equal(tSignals.version, 3);
assert.equal(tSignals.signals.length, 3, "a tagged T junction has one signal per physical approach");
assert.deepEqual(tSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1]);
assert.ok(tSignals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
assert.equal(tSignals.layout.countdownLateralMeters, 1.15);
assert.equal(tSignals.layout.countdownWidthMeters, 0.82);
assert.ok(tSignals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
const crossSignals = buildTrafficSignals(
{ type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 29.99985, 0.00003, 0.000006),
] },
trafficIntersection,
[{ ...control, arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }, { headingDegrees: 180 }] }],
);
assert.equal(crossSignals.signals.length, 4, "a tagged cross junction retains all four approaches");
assert.deepEqual(crossSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1, 1]);
const parsedSignalControls = parseOsm(`
`).trafficSignalControls;
assert.deepEqual(parsedSignalControls.map((entry) => entry.id), ["active", "directionless"], "only active highway=traffic_signals nodes control vehicle signals");
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Preview asset tests passed.");
function rectangle(lon, lat, halfWidth, halfHeight) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
[lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - halfHeight],
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
]] } };
}
function directionalLanes(osmWayId, roadId, coordinates, direction, widthMeters, offsets, firstIndex) {
const oriented = direction === "Fwd" ? coordinates : [...coordinates].reverse();
return offsets.map((offsetMeters, index) => lanePolygon(
osmWayId, roadId, oriented, direction, firstIndex + index, widthMeters, offsetMeters,
));
}
function lanePolygon(osmWayId, roadId, coordinates, direction, index, widthMeters, offsetMeters) {
const centerline = offsetLineRight(coordinates, offsetMeters);
const left = offsetLineRight(centerline, -widthMeters / 2);
const right = offsetLineRight(centerline, widthMeters / 2);
return {
type: "Feature",
properties: {
type: "Driving",
direction,
index,
width: widthMeters,
road: roadId,
osm_way_ids: [osmWayId],
allowed_turns: [],
},
geometry: { type: "Polygon", coordinates: [[...left, ...right.reverse(), left[0]]] },
};
}
function laneSpec(direction, widthMeters) {
return { lt: "Driving", dir: direction, width: widthMeters * 10000, allowed_turns: 0 };
}
function networkRoad(id, osmWayId, src, dst, coordinates, laneSpecs, bounds, highwayType = "residential") {
return {
id,
osm_ids: [osmWayId],
src_i: src,
dst_i: dst,
highway_type: highwayType,
name: osmWayId,
center_line: { pts: coordinates.map((coordinate) => networkPoint(coordinate, bounds)) },
lane_specs_ltr: laneSpecs,
};
}
function networkPoint([lon, lat], bounds) {
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]);
return {
x: Math.round((lon - bounds.min_lon) / (bounds.max_lon - bounds.min_lon) * widthMeters * 10000),
y: Math.round((heightMeters - (lat - bounds.min_lat) / (bounds.max_lat - bounds.min_lat) * heightMeters) * 10000),
};
}
function intersectionFeature(id, coordinate, halfSizeMeters) {
const west = metersCoordinate(coordinate, -halfSizeMeters, 0)[0];
const east = metersCoordinate(coordinate, halfSizeMeters, 0)[0];
const south = metersCoordinate(coordinate, 0, -halfSizeMeters)[1];
const north = metersCoordinate(coordinate, 0, halfSizeMeters)[1];
return {
type: "Feature",
properties: { id, type: "intersection" },
geometry: { type: "Polygon", coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] },
};
}
function offsetLineRight(coordinates, offsetMeters) {
const [start, end] = coordinates;
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(latitude * Math.PI / 180);
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const length = Math.hypot(dx, dy);
const east = dy / length * offsetMeters;
const north = -dx / length * offsetMeters;
return coordinates.map(([lon, lat]) => [lon + east / metersLon, lat + north / 111320]);
}
function metersCoordinate(origin, east, north) {
const metersLon = 111320 * Math.cos(origin[1] * Math.PI / 180);
return [origin[0] + east / metersLon, origin[1] + north / 111320];
}
function eastMeters(origin, coordinate) {
return (coordinate[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
}
function tangentMismatchDegrees(a, b, c, d) {
const metersLon = 111320 * Math.cos((b[1] + c[1]) / 2 * Math.PI / 180);
const first = [(b[0] - a[0]) * metersLon, (b[1] - a[1]) * 111320];
const second = [(d[0] - c[0]) * metersLon, (d[1] - c[1]) * 111320];
const cosine = (first[0] * second[0] + first[1] * second[1]) / (Math.hypot(...first) * Math.hypot(...second));
return Math.acos(Math.max(-1, Math.min(1, cosine))) * 180 / Math.PI;
}
function maxStepMeters(coordinates) {
return Math.max(...coordinates.slice(1).map((coordinate, index) => haversineMeters(coordinates[index], coordinate)));
}