Geo.res
1type point = (float, float)
2type lngLat = {lng: float, lat: float}
3let lngLatToPoint = (a: lngLat): point => (a.lng, a.lat)
4
5module GeoJSON = {
6 // opaque type for GeoJSON, referred to as Geo.GeoJSON.t elsewhere
7 type t
8}
9
10// return value from nearestPointOnLine, all values in km
11// Turf.js v7.4+ returns a GeoJSON Feature with these properties:
12// lineStringIndex = closest point was found on the nth LineString (MultiLineString only)
13// segmentIndex = closest point was found on nth segment of the LineString
14// totalDistance = distance from start of overall MultiLineString
15// lineDistance = distance from start of the relevant LineString
16// segmentDistance = distance from start of the relevant segment
17// pointDistance = distance to the input point
18type nearest = {
19 lineStringIndex: int,
20 lineDistance: float,
21 pointDistance: float,
22}
23
24// Turf.js nearestPointOnLine returns a GeoJSON Feature with properties nested inside
25// We wrap it to expose the properties directly as our `nearest` type
26type nearestFeature = {
27 properties: nearest,
28}
29
30@module("@turf/nearest-point-on-line")
31external nearestPointOnLineRaw: (GeoJSON.t, point) => nearestFeature = "nearestPointOnLine"
32
33let nearestPointOnLine = (geojson: GeoJSON.t, point: point): nearest => {
34 let feature = nearestPointOnLineRaw(geojson, point)
35 feature.properties
36}
37
38// Result type for getDistance and getTime
39type result = {
40 previousID: option<string>,
41 nextID: option<string>,
42 fromPreviousWaypoint: float,
43 toNextWaypoint: float,
44 fromStart: Map.t<array<string>, float>,
45}
46
47// GeoJSON Feature properties from routing engine
48// These are stored in the GeoJSON feature's properties field
49type routeProperties = {
50 distance: float,
51 time: float,
52}
53
54// Extract route properties (distance and time) from GeoJSON Feature
55// GeoJSON structure: { type: "Feature", geometry: {...}, properties: {distance, time, ...} }
56let extractRouteProperties = (geojson: GeoJSON.t): option<routeProperties> => {
57 // Convert Geo.GeoJSON.t to global JSON.t for decoding
58 let json = geojson->Obj.magic
59 switch JSON.Decode.object(json) {
60 | Some(dict) => switch dict->Dict.get("properties")->Option.flatMap(JSON.Decode.object) {
61 | Some(props) => {
62 let distance = props->Dict.get("distance")->Option.flatMap(JSON.Decode.float)
63 let time = props->Dict.get("time")->Option.flatMap(JSON.Decode.float)
64 switch (distance, time) {
65 | (Some(distance), Some(time)) => Some({distance, time})
66 | _ => None
67 }
68 }
69 | None => None
70 }
71 | None => None
72 }
73}
74
75// Helper to convert Map entries to array (must be at module level for use in relativeDistances)
76let mapEntries: Map.t<'k, 'v> => array<('k, 'v)> = %raw(`(map) => Array.from(map.entries())`)
77
78// Extract source waypoint ID from route key
79// Route keys can have constraint suffixes like "wp1:saa" or "wp1:adv(80):max(100)"
80// Returns the base waypoint ID (e.g., "wp1")
81let parseRouteKey = (key: string): string => {
82 switch key->String.split(":")->Array.get(0) {
83 | Some(id) => id
84 | None => key
85 }
86}
87
88// Find a waypoint by its ID
89let findWaypointById = (waypoints: array<Waypoint.attributes>, id: string): option<
90 Waypoint.attributes,
91> => {
92 waypoints->Array.find(wp => wp.id == id)
93}
94
95// Find the destination waypoint and source waypoint ID for a given route hash.
96// Returns Some((destWaypoint, sourceWaypointId)) where destWaypoint.from[sourceKey].id == layer
97// Note: sourceKey may include constraint suffixes (e.g., "wp1:saa"), but sourceWaypointId is just "wp1"
98let findRouteEndpoints = (waypoints: array<Waypoint.attributes>, layer: string): option<(
99 Waypoint.attributes,
100 string,
101)> => {
102 waypoints->Array.findMap((wp: Waypoint.attributes) => {
103 switch wp.from {
104 | Some(routeDict) =>
105 routeDict
106 ->Dict.toArray
107 ->Array.findMap(((sourceKey, route)) => {
108 switch route.id {
109 | Some(id) if id == layer => {
110 // Extract base waypoint ID from route key (e.g., "wp1:saa" -> "wp1")
111 let sourceWaypointId = parseRouteKey(sourceKey)
112 Some((wp, sourceWaypointId))
113 }
114 | _ => None
115 }
116 })
117 | None => None
118 }
119 })
120}
121
122// Check if a waypoint is the trip origin (first waypoint with empty from dict)
123let isOrigin = (wp: Waypoint.attributes): bool => {
124 if wp.nonroutable {
125 false
126 } else {
127 switch wp.from {
128 | Some(dict) => Dict.toArray(dict)->Array.length == 0
129 | None => true
130 }
131 }
132}
133
134/**
135 * Calculate distance along the route at any point.
136 *
137 * @param point The hover point (lon, lat)
138 * @param layer The route hash (ID field of the Route in the waypoint's from dict)
139 * @param waypoints Array of all waypoints in the trip
140 * @param geojson GeoJSON of the current route segment (caller fetches from service worker cache)
141 * @returns {fromPreviousWaypoint: distance from start of current segment, fromStart: Map of paths to total distances}
142 */
143let getDistance = (
144 point: point,
145 layer: string,
146 waypoints: array<Waypoint.attributes>,
147 geojson: GeoJSON.t,
148): result => {
149 switch findRouteEndpoints(waypoints, layer) {
150 | None => {
151 previousID: None,
152 nextID: None,
153 fromPreviousWaypoint: 0.0,
154 toNextWaypoint: 0.0,
155 fromStart: Map.make(),
156 }
157 | Some((destWp, sourceId)) => {
158 // Calculate distance along the current segment from its start
159 let nearest = nearestPointOnLine(geojson, point)
160 let fromPreviousWaypoint = nearest.lineDistance
161
162 // Calculate remaining distance to next waypoint
163 let segmentTotalDistance = switch extractRouteProperties(geojson) {
164 | Some(props) => props.distance
165 | None => 0.0
166 }
167 let toNextWaypoint = segmentTotalDistance -. fromPreviousWaypoint
168
169 // Build O(1) waypoint lookup map to avoid linear search in DFS
170 let waypointMap = waypoints->Array.map(wp => (wp.id, wp))->Dict.fromArray
171 let findWaypoint = (id: string): option<Waypoint.attributes> => {
172 waypointMap->Dict.get(id)
173 }
174
175 let fromStart = Map.make()
176
177 // Recursive DFS backward through the waypoint DAG.
178 // accumulatedPath: forward-order array of waypoint IDs from origin to currentId
179 // accumulatedDistance: sum of segment distances from origin to currentId
180 // visited: set of waypoint IDs in the current branch (for cycle detection)
181 let rec traverse = (
182 currentId: string,
183 accumulatedPath: array<string>,
184 accumulatedDistance: float,
185 visited: Set.t<string>,
186 ) => {
187 switch findWaypoint(currentId) {
188 | None => ()
189 | Some(wp) => {
190 let newPath = [currentId]->Array.concat(accumulatedPath)
191
192 if isOrigin(wp) {
193 // Reached the origin — store the complete path
194 let totalDistance = accumulatedDistance +. fromPreviousWaypoint
195 fromStart->Map.set(newPath, totalDistance)
196 } else {
197 // Continue to all predecessors, sorted by segment distance (shortest first)
198 switch wp.from {
199 | Some(routeDict) => {
200 let predecessors =
201 routeDict
202 ->Dict.toArray
203 ->Array.filter(((prevId, _)) => !(visited->Set.has(prevId)))
204 ->Array.toSorted((a, b) => {
205 let (_, routeA) = a
206 let (_, routeB) = b
207 let distA = routeA.d->Option.getOr(0.0)
208 let distB = routeB.d->Option.getOr(0.0)
209 if distA < distB {
210 -1.0
211 } else if distA > distB {
212 1.0
213 } else {
214 0.0
215 }
216 })
217
218 predecessors->Array.forEach(((prevKey, route)) => {
219 // Extract base waypoint ID from route key (e.g., "wp1:saa" -> "wp1")
220 let prevId = parseRouteKey(prevKey)
221 let segmentDist = route.d->Option.getOr(0.0)
222 let newVisited = Set.fromArray(visited->Set.toArray)
223 newVisited->Set.add(currentId)
224 traverse(prevId, newPath, accumulatedDistance +. segmentDist, newVisited)
225 })
226 }
227 | None => ()
228 }
229 }
230 }
231 }
232 }
233
234 // Seed traversal from the source of the current route segment
235 let visited = Set.make()
236 visited->Set.add(destWp.id)
237 traverse(sourceId, [], 0.0, visited)
238
239 {
240 previousID: Some(sourceId),
241 nextID: Some(destWp.id),
242 fromPreviousWaypoint,
243 toNextWaypoint,
244 fromStart,
245 }
246 }
247 }
248}
249
250/**
251 * Calculate time along the route at any point.
252 *
253 * @param point The hover point (lon, lat)
254 * @param layer The route hash (ID field of the Route in the waypoint's from dict)
255 * @param waypoints Array of all waypoints in the trip
256 * @param geojson GeoJSON of the current route segment (caller fetches from service worker cache)
257 * @returns {fromPreviousWaypoint: estimated time from start of current segment, fromStart: Map of paths to total times}
258 */
259let getTime = (
260 point: point,
261 layer: string,
262 waypoints: array<Waypoint.attributes>,
263 geojson: GeoJSON.t,
264): result => {
265 // Extract route properties from GeoJSON
266 let (routeTotalDistance, routeTotalTime) = switch extractRouteProperties(geojson) {
267 | Some(props) => (props.distance, props.time)
268 | None => (0.0, 0.0)
269 }
270
271 switch findRouteEndpoints(waypoints, layer) {
272 | None => {
273 previousID: None,
274 nextID: None,
275 fromPreviousWaypoint: 0.0,
276 toNextWaypoint: 0.0,
277 fromStart: Map.make(),
278 }
279 | Some((destWp, sourceId)) => {
280 // Calculate distance along the current segment from its start
281 let nearest = nearestPointOnLine(geojson, point)
282 let lineDistance = nearest.lineDistance
283
284 // Linear interpolation: estimate time based on percentage of distance traveled
285 // Avoid division by zero
286 let fromPreviousWaypoint = if routeTotalDistance > 0.0 {
287 lineDistance /. routeTotalDistance *. routeTotalTime
288 } else {
289 0.0
290 }
291
292 // Calculate remaining time to next waypoint
293 let toNextWaypoint = routeTotalTime -. fromPreviousWaypoint
294
295 // Build O(1) waypoint lookup map to avoid linear search in DFS
296 let waypointMap = waypoints->Array.map(wp => (wp.id, wp))->Dict.fromArray
297 let findWaypoint = (id: string): option<Waypoint.attributes> => {
298 waypointMap->Dict.get(id)
299 }
300
301 let fromStart = Map.make()
302
303 // Recursive DFS backward through the waypoint DAG.
304 // accumulatedPath: forward-order array of waypoint IDs from origin to currentId
305 // accumulatedTime: sum of segment times from origin to currentId
306 // visited: set of waypoint IDs in the current branch (for cycle detection)
307 let rec traverse = (
308 currentId: string,
309 accumulatedPath: array<string>,
310 accumulatedTime: float,
311 visited: Set.t<string>,
312 ) => {
313 switch findWaypoint(currentId) {
314 | None => ()
315 | Some(wp) => {
316 let newPath = [currentId]->Array.concat(accumulatedPath)
317
318 if isOrigin(wp) {
319 // Reached the origin — store the complete path
320 let totalTime = accumulatedTime +. fromPreviousWaypoint
321 fromStart->Map.set(newPath, totalTime)
322 } else {
323 // Continue to all predecessors, sorted by segment time (shortest first)
324 switch wp.from {
325 | Some(routeDict) => {
326 let predecessors =
327 routeDict
328 ->Dict.toArray
329 ->Array.filter(((prevId, _)) => !(visited->Set.has(prevId)))
330 ->Array.toSorted((a, b) => {
331 let (_, routeA) = a
332 let (_, routeB) = b
333 let timeA = routeA.t->Option.getOr(0.0)
334 let timeB = routeB.t->Option.getOr(0.0)
335 if timeA < timeB {
336 -1.0
337 } else if timeA > timeB {
338 1.0
339 } else {
340 0.0
341 }
342 })
343
344 predecessors->Array.forEach(((prevKey, route)) => {
345 // Extract base waypoint ID from route key (e.g., "wp1:saa" -> "wp1")
346 let prevId = parseRouteKey(prevKey)
347 let segmentTime = route.t->Option.getOr(0.0)
348 let newVisited = Set.fromArray(visited->Set.toArray)
349 newVisited->Set.add(currentId)
350 traverse(prevId, newPath, accumulatedTime +. segmentTime, newVisited)
351 })
352 }
353 | None => ()
354 }
355 }
356 }
357 }
358 }
359
360 // Seed traversal from the source of the current route segment
361 let visited = Set.make()
362 visited->Set.add(destWp.id)
363 traverse(sourceId, [], 0.0, visited)
364
365 {
366 previousID: Some(sourceId),
367 nextID: Some(destWp.id),
368 fromPreviousWaypoint,
369 toNextWaypoint,
370 fromStart,
371 }
372 }
373 }
374}
375
376// Stats result type containing both distance and time
377type stats = {
378 dist: result,
379 time: result,
380}
381
382/**
383 * Calculate both distance and time stats along the route at any point.
384 *
385 * @param point The hover point (lon, lat)
386 * @param layer The route hash (ID field of the Route in the waypoint's from dict)
387 * @param waypoints Array of all waypoints in the trip
388 * @param geojson GeoJSON of the current route segment (caller fetches from service worker cache)
389 * @returns {dist: distance result, time: time result}
390 */
391let getStats = (
392 point: point,
393 layer: string,
394 waypoints: array<Waypoint.attributes>,
395 geojson: GeoJSON.t,
396): stats => {
397 {
398 dist: getDistance(point, layer, waypoints, geojson),
399 time: getTime(point, layer, waypoints, geojson),
400 }
401}
402
403/**
404 * Build a distance matrix from origin to all reachable waypoints.
405 *
406 * Traverses forward from the origin through the waypoint DAG, accumulating
407 * distances along all possible paths. Non-routable waypoints are excluded.
408 * When multiple paths exist to the same waypoint, all are included in the matrix.
409 *
410 * @param waypoints Array of all waypoints in the trip
411 * @returns Map of path arrays (waypoint IDs from origin to destination) to cumulative distance
412 */
413let getDistanceMatrix = (waypoints: array<Waypoint.attributes>): Map.t<array<string>, float> => {
414 // Build O(1) waypoint lookup map
415 let waypointMap = waypoints->Array.map(wp => (wp.id, wp))->Dict.fromArray
416 let findWaypoint = (id: string): option<Waypoint.attributes> => {
417 waypointMap->Dict.get(id)
418 }
419
420 // Find the origin waypoint (nonroutable=false and empty from dict)
421 let origin = waypoints->Array.find(wp => isOrigin(wp))
422
423 switch origin {
424 | None => Map.make()
425 | Some(originWp) => {
426 // Build forward adjacency list: from a waypoint, where can we go?
427 // Invert the 'from' relationships: if B.from has {"A": route}, then A can reach B
428 let adjacencyList = Map.make()
429
430 waypoints->Array.forEach((wp: Waypoint.attributes) => {
431 // Skip non-routable waypoints - they can't be reached or traversed
432 if !wp.nonroutable {
433 switch wp.from {
434 | Some(routeDict) => {
435 routeDict
436 ->Dict.toArray
437 ->Array.forEach(((sourceKey, route)) => {
438 // Extract base waypoint ID from route key (e.g., "A:saa" -> "A")
439 let sourceId = parseRouteKey(sourceKey)
440 let distance = route.d->Option.getOr(0.0)
441
442 // Add this waypoint as a destination from the source
443 switch adjacencyList->Map.get(sourceId) {
444 | Some(existing) => {
445 adjacencyList->Map.set(sourceId, Array.concat(existing, [(wp.id, distance)]))
446 }
447 | None => {
448 adjacencyList->Map.set(sourceId, [(wp.id, distance)])
449 }
450 }
451 })
452 }
453 | None => ()
454 }
455 }
456 })
457
458 // Result map: path -> cumulative distance
459 let result = Map.make()
460
461 // DFS forward from origin, accumulating paths and distances
462 // currentId: current waypoint being visited
463 // currentPath: array of waypoint IDs from origin to current (excluding current)
464 // accumulatedDistance: sum of distances from origin to start of current waypoint
465 // visited: set of waypoint IDs in current path (for cycle detection)
466 let rec traverse = (
467 currentId: string,
468 currentPath: array<string>,
469 accumulatedDistance: float,
470 visited: Set.t<string>,
471 ) => {
472 // Check if this waypoint exists and is routable
473 switch findWaypoint(currentId) {
474 | None => ()
475 | Some(wp) => {
476 if wp.nonroutable {
477 ()
478 } else {
479 // Build the complete path including current waypoint
480 let completePath = Array.concat(currentPath, [currentId])
481
482 // Store this path with its cumulative distance
483 // Don't store the origin-only path (just ["A"])
484 if Array.length(completePath) > 1 {
485 result->Map.set(completePath, accumulatedDistance)
486 }
487
488 // Continue to all destinations from this waypoint
489 switch adjacencyList->Map.get(currentId) {
490 | Some(destinations) => {
491 destinations->Array.forEach(((destId, segmentDist)) => {
492 // Check for cycles - don't visit if already in path
493 if !(visited->Set.has(destId)) {
494 let newVisited = Set.fromArray(visited->Set.toArray)
495 newVisited->Set.add(currentId)
496 traverse(destId, completePath, accumulatedDistance +. segmentDist, newVisited)
497 }
498 })
499 }
500 | None => ()
501 }
502 }
503 }
504 }
505 }
506
507 // Start traversal from origin with empty path and zero distance
508 let visited = Set.make()
509 traverse(originWp.id, [], 0.0, visited)
510
511 result
512 }
513 }
514}
515
516/**
517 * Extract prefix of a path up to (and including) a specific waypoint.
518 * Returns the array of waypoint IDs from start up to and including targetId.
519 */
520let extractPrefix = (path: array<string>, targetId: string): option<array<string>> => {
521 let rec findIndex = (idx: int) => {
522 if idx >= Array.length(path) {
523 None
524 } else if path->Array.getUnsafe(idx) == targetId {
525 Some(idx)
526 } else {
527 findIndex(idx + 1)
528 }
529 }
530
531 switch findIndex(0) {
532 | Some(endIdx) => Some(path->Array.slice(~start=0, ~end=endIdx + 1))
533 | None => None
534 }
535}
536
537/**
538 * Calculate relative distances from a reference waypoint to all reachable waypoints.
539 *
540 * Given a distance matrix from getDistanceMatrix, computes distances relative to
541 * the specified waypoint. Waypoints before the reference have negative distances,
542 * waypoints after have positive distances, and the reference itself is 0.
543 *
544 * When multiple paths exist to reach a waypoint, the shortest absolute distance is used.
545 *
546 * @param id The reference waypoint ID
547 * @param matrix Distance matrix from getDistanceMatrix
548 * @returns Map of waypoint ID to relative distance from reference
549 */
550let relativeDistances = (id: string, matrix: Map.t<array<string>, float>): Map.t<string, float> => {
551 // Collect all matrix entries using raw JS
552 let entries = %raw(`
553 (function(matrix) {
554 const result = [];
555 matrix.forEach((value, key) => {
556 result.push([key, value]);
557 });
558 return result;
559 })
560 `)(matrix)
561
562 // Find all paths containing the reference waypoint
563 let pathsWithReference = entries->Array.filter(((path, _)) => {
564 path->Array.some(wpId => wpId == id)
565 })
566
567 if pathsWithReference->Array.length == 0 {
568 Map.make()
569 } else {
570 // Result map: waypoint ID -> relative distance
571 let result = Map.make()
572
573 // For each path containing the reference, calculate relative distances
574 pathsWithReference->Array.forEach(((fullPath, _fullPathDistance)) => {
575 // Find the index of the reference waypoint in this path
576 let refIndex = fullPath->Array.findIndex(wpId => wpId == id)
577
578 if refIndex >= 0 {
579 // Get the reference distance
580 // If refIndex is 0, the reference is the origin, so distance is 0
581 // Otherwise, look it up in the matrix
582 let refDistance = if refIndex == 0 {
583 0.0
584 } else {
585 // Find the matrix entry for the prefix ending at the reference
586 let refPathFromMatrix = entries->Array.find(((path, _)) => {
587 // Check if this path matches the prefix of fullPath up to refIndex
588 if Array.length(path) != refIndex + 1 {
589 false
590 } else {
591 path->Array.everyWithIndex((wpId, idx) => {
592 fullPath->Array.getUnsafe(idx) == wpId
593 })
594 }
595 })
596
597 switch refPathFromMatrix {
598 | None => 0.0
599 | Some((_, dist)) => dist
600 }
601 }
602
603 // Calculate relative distance for each waypoint in the full path
604 fullPath->Array.forEachWithIndex((wpId, idx) => {
605 // Find the matrix entry for this waypoint's prefix
606 let wpPathFromMatrix = entries->Array.find(((path, _)) => {
607 if Array.length(path) != idx + 1 {
608 false
609 } else {
610 path->Array.everyWithIndex((pId, pIdx) => {
611 fullPath->Array.getUnsafe(pIdx) == pId
612 })
613 }
614 })
615
616 // Get the distance for this waypoint
617 // If idx is 0, it's the origin, so distance is 0
618 // Otherwise, look it up in the matrix
619 let wpDistance = if idx == 0 {
620 0.0
621 } else {
622 switch wpPathFromMatrix {
623 | None => 0.0
624 | Some((_, dist)) => dist
625 }
626 }
627
628 // Calculate relative distance: waypoint dist - reference dist
629 let relativeDist = wpDistance -. refDistance
630
631 // Store if this is the first time seeing this waypoint,
632 // or if this path gives a shorter absolute distance
633 switch result->Map.get(wpId) {
634 | None => {
635 result->Map.set(wpId, relativeDist)
636 }
637 | Some(existingDist) => {
638 if Math.abs(relativeDist) < Math.abs(existingDist) {
639 result->Map.set(wpId, relativeDist)
640 }
641 }
642 }
643 })
644 }
645 })
646
647 result
648 }
649}