MVUM Layer 3a: multi-modal Auto via MVUM trailhead transitions (#26)

Auto now also considers "drive in to a trailhead, switch vehicles, continue
on foot/2w/4w" trips and picks one when it is meaningfully faster than the
single-mode winner. Implicit — no new chip; Auto just returns the fastest plan.

Backend:
- mvum_transitions.py: TrailheadIndex (STRtree over trail_entry_points), built
  once per process via load_trailheads() (mirrors the MVUMSpatialIndex singleton).
  query_trailheads_near_line(coords, buffer_m=2000) with a precise distance filter.
- router.py: _route_auto, after the single-mode probe and only when the winner is
  ok AND total_distance_km >= MIN_HYBRID_DISTANCE_KM (8.0), tries hybrids. For each
  candidate trailhead near the winning polyline (closest first, capped at 20) and
  each (drive, offroad) pair in HYBRID_PAIRS, it routes both legs (annotate_mvum
  off) and sums leg times with NO transition cost. A hybrid wins only if it beats
  the single-mode winner by >= HYBRID_MIN_TIME_SAVINGS_MIN (15 min); trivial
  offroad detours (< HYBRID_MIN_OFFROAD_KM = 0.8 km) are skipped. The winner is
  combined into a new "multi" scenario: leg1 features + a kind=transition marker
  + leg2 features; summary carries total_*, per-leg legs[], summed MVUM counts;
  selected_mode="hybrid". Each leg is annotated separately.
- app.py / offroute_route.py: load + inject the trailhead index singleton.

Frontend (additive — no api.js signature change):
- DirectionsPanel: per-leg breakdown row for hybrid/multi ("Drive X mi (Ymin)
  -> 4W X mi (Zmin) - total Wmin", lucide Repeat between legs); existing Auto
  badge still shows.
- MapView: network polylines colored by network_mode (vehicle/auto blue, 4w
  orange, 2w green, foot red); transition points rendered as a circle marker with
  the lucide Repeat icon + "Switch to <mode>" tooltip; bounds fit skips Points.

Tests: test_mvum_transitions.py — index load, near-line close-only query, short
trip stays single-mode, big-savings hybrid wins, trivial-detour + below-threshold
+ no-trailheads all fall back. 7 new tests; full offroute suite 71 passed.

Note: the DB column is trail_entry_points.highway_class; surfaced as record
"road_class" per the spec.

Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-25 22:49:13 -06:00 committed by GitHub
commit 5ba9527c02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 561 additions and 7 deletions

View file

@ -11,6 +11,7 @@ from shared.git_sha import git_short_sha
from . import offroute_route, admin
from .mvum import MVUMSpatialIndex
from .mvum_transitions import load_trailheads
# Process-wide singleton: build the MVUM spatial index once per process (per gunicorn
# worker in prod; once across create_app() calls in tests), not once per app instance.
@ -54,6 +55,13 @@ def create_app():
app.logger.warning("MVUM spatial index failed to load: %s", e)
app.config['MVUM_SPATIAL_INDEX'] = None
# Layer 3a: trailhead transition index (process-wide singleton, logs its own line).
try:
app.config['MVUM_TRAILHEAD_INDEX'] = load_trailheads()
except Exception as e:
app.logger.warning("MVUM trailhead index failed to load: %s", e)
app.config['MVUM_TRAILHEAD_INDEX'] = None
app.register_blueprint(offroute_route.bp)
app.register_blueprint(admin.bp)
return app

View file

@ -0,0 +1,98 @@
"""
MVUM Layer 3a: trailhead transition index for multi-modal Auto routing.
Loads the ``trail_entry_points`` table from navi.db into a shapely STRtree of
trailhead points and supports finding the trailheads near a route polyline. This
is pure spatial lookup no routing logic mirroring the MVUMSpatialIndex
(Layer 0) pattern: built once per process as a singleton via load_trailheads().
The router (``_route_auto``) uses these points as drive->offroad transition
candidates: a hybrid "drive to a trailhead, switch vehicles, continue offroad"
plan is considered when it beats the single-mode winner by a comfortable margin.
"""
import logging
import sqlite3
import time as _time
from pathlib import Path
from shapely.geometry import Point, LineString
from shapely.strtree import STRtree
from .mvum import navi_db_path, _buffer_degrees_for_meters
logger = logging.getLogger("navi_offroute.mvum_transitions")
class TrailheadIndex:
"""In-memory STRtree over ``trail_entry_points`` (trailhead/road access points).
Keeps the STRtree of point geometries plus a parallel ``records`` list of
``{lat, lon, name, road_class}`` dicts aligned with the tree's geometries.
(The DB column is ``highway_class``; it is surfaced here as ``road_class`` for
consistency with the entry-point records the router already emits.)
"""
def __init__(self, db_path=None):
t0 = _time.perf_counter()
self.db_path = Path(db_path) if db_path else navi_db_path()
self.records = [] # aligned with self._points
self._points = []
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(
"SELECT lat, lon, name, highway_class FROM trail_entry_points "
"WHERE lat IS NOT NULL AND lon IS NOT NULL"
)
for row in cur:
lat = float(row["lat"])
lon = float(row["lon"])
self.records.append({
"lat": lat,
"lon": lon,
"name": row["name"] or "",
"road_class": row["highway_class"] or "",
})
self._points.append(Point(lon, lat))
finally:
conn.close()
self._tree = STRtree(self._points) if self._points else None
self.count = len(self.records)
self.build_time_seconds = _time.perf_counter() - t0
logger.info(
"Trailhead index loaded: %d entry points in %.2f seconds",
self.count, self.build_time_seconds,
)
def query_trailheads_near_line(self, coords, buffer_m=2000):
"""Trailhead records within ~``buffer_m`` of a (lat, lon) polyline.
Coarse STRtree bbox prefilter followed by a precise degree-distance check
so only points genuinely close to the line are returned (the bbox alone
would admit corner points up to ~1.4x buffer away).
"""
if not coords or self._tree is None:
return []
pts = [(lon, lat) for (lat, lon) in coords]
geom = LineString(pts) if len(pts) >= 2 else Point(pts[0])
avg_lat = sum(lat for (lat, lon) in coords) / len(coords)
buffer_deg = _buffer_degrees_for_meters(buffer_m, avg_lat)
out = []
for i in self._tree.query(geom.buffer(buffer_deg)):
if geom.distance(self._points[i]) <= buffer_deg:
out.append(self.records[i])
return out
# Process-wide singleton, mirroring app.py's _MVUM_INDEX handling.
_TRAILHEAD_INDEX = None
def load_trailheads(db_path=None):
"""Return the process-wide TrailheadIndex singleton, building it on first call."""
global _TRAILHEAD_INDEX
if _TRAILHEAD_INDEX is None:
_TRAILHEAD_INDEX = TrailheadIndex(db_path)
return _TRAILHEAD_INDEX

View file

@ -73,6 +73,8 @@ def api_offroute():
router = OffrouteRouter()
# Inject the Layer-0 MVUM spatial index singleton for Layer-1 annotation.
router.spatial_index = current_app.config.get('MVUM_SPATIAL_INDEX')
# Inject the Layer-3a trailhead index for multi-modal Auto transitions.
router.trailhead_index = current_app.config.get('MVUM_TRAILHEAD_INDEX')
try:
result = router.route(
start_lat=start_lat, start_lon=start_lon,

View file

@ -32,7 +32,7 @@ import psutil
import requests
import psycopg2
import psycopg2.extras
from shapely.geometry import LineString
from shapely.geometry import LineString, Point
from .astar import astar_multigoal, inflate_cost_multiplier
from shared.dem import DEMReader, dem_path
@ -98,6 +98,18 @@ MODE_TO_COSTING = {
# demanding terrain) and uses the first that yields a usable route.
AUTO_MODE_PRIORITY = ["vehicle", "4w", "2w", "foot"]
# MVUM Layer 3a: implicit multi-modal Auto. On long trips, "drive in to a trailhead,
# switch vehicles, continue offroad" can beat the single-mode winner; Auto picks that
# hybrid plan when it does. Leg times are summed with NO transition penalty, and the
# minimums below keep the suggestions sensible (no short trips / trivial detours).
MIN_HYBRID_DISTANCE_KM = 8.0 # ~5 mi: shorter single-mode wins stay as-is
HYBRID_MIN_TIME_SAVINGS_MIN = 15.0 # a hybrid must beat the winner by at least this
HYBRID_MIN_OFFROAD_KM = 0.8 # ~0.5 mi: reject trivial offroad detours
HYBRID_MAX_TRAILHEADS = 20 # cap candidates (closest to the route first)
HYBRID_TRAILHEAD_BUFFER_M = 2000 # candidate trailheads within 2 km of the route
# (drive_mode, offroad_mode) transition pairs, tried at each candidate trailhead.
HYBRID_PAIRS = [("vehicle", "4w"), ("vehicle", "2w"), ("vehicle", "foot"), ("4w", "foot")]
# Per-endpoint travel-mode eligibility from an OSM-style "key:value" category hint.
# Looked up exact first, then "key:*" wildcard (see _eligible_modes_from_category).
_MODES_ALL = frozenset({"vehicle", "4w", "2w", "foot"})
@ -536,6 +548,7 @@ class OffrouteRouter:
self.spatial_index = None # MVUMSpatialIndex (Layer 0), injected by the handler
self.mvum_on_date = None # optional datetime for seasonal MVUM checks
self._exclude_polygons = None # MVUM Layer 2c, set per route() call
self.trailhead_index = None # TrailheadIndex (Layer 3a), injected by the handler
def _init_readers(self):
"""Lazy init readers."""
@ -863,6 +876,14 @@ class OffrouteRouter:
last_error = result
if best_result is not None:
# MVUM Layer 3a: a "drive to a trailhead, switch, continue offroad" plan may
# beat the single-mode winner on long trips. If so, return it instead.
hybrid = self._try_hybrid_auto(
start_lat, start_lon, end_lat, end_lon, boundary_mode,
best_result, best_minutes, intersection)
if hybrid is not None:
hybrid["selected_mode_set"] = mode_set
return hybrid
best_result["selected_mode_set"] = mode_set
self._annotate_network_segments(best_result, best_result["selected_mode"])
return best_result
@ -876,6 +897,176 @@ class OffrouteRouter:
"selected_mode_set": mode_set,
}
def _route_coords_latlon(self, result):
"""Flatten a route response's polyline to [(lat, lon), ...]. Prefers the
single "combined" full-path feature; otherwise concatenates LineStrings."""
feats = (result.get("route") or {}).get("features", [])
for f in feats:
if (f.get("properties") or {}).get("segment_type") == "combined":
cs = (f.get("geometry") or {}).get("coordinates") or []
return [(c[1], c[0]) for c in cs]
out = []
for f in feats:
if (f.get("geometry") or {}).get("type") != "LineString":
continue
out.extend((c[1], c[0]) for c in (f["geometry"].get("coordinates") or []))
return out
def _try_hybrid_auto(self, start_lat, start_lon, end_lat, end_lon,
boundary_mode, best_result, best_minutes, intersection):
"""MVUM Layer 3a: consider drive->trailhead->offroad hybrid plans.
Returns a combined "multi" response if some trailhead transition beats the
single-mode winner by HYBRID_MIN_TIME_SAVINGS_MIN, else None (caller keeps
the single-mode winner). Leg times are summed with no transition penalty.
"""
idx = getattr(self, "trailhead_index", None)
if idx is None:
return None
best_summary = best_result.get("summary") or {}
if best_summary.get("total_distance_km", 0.0) < MIN_HYBRID_DISTANCE_KM:
return None
coords = self._route_coords_latlon(best_result)
if len(coords) < 2:
return None
candidates = idx.query_trailheads_near_line(
coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M)
if not candidates:
return None
# Closest-to-route first, then cap.
line = LineString([(lon, lat) for (lat, lon) in coords])
candidates.sort(key=lambda th: line.distance(Point(th["lon"], th["lat"])))
candidates = candidates[:HYBRID_MAX_TRAILHEADS]
# Per trailhead, leg1 depends only on drive_mode and leg2 only on offroad_mode,
# so route each distinct mode once and recombine across pairs.
drive_modes = sorted({d for d, _ in HYBRID_PAIRS})
offroad_modes = sorted({o for _, o in HYBRID_PAIRS})
threshold = best_minutes - HYBRID_MIN_TIME_SAVINGS_MIN
winner = None
winner_minutes = None
for th in candidates:
leg1_by_mode = {}
for dm in drive_modes:
r = self.route(start_lat, start_lon, th["lat"], th["lon"],
mode=dm, boundary_mode=boundary_mode, annotate_mvum=False)
if r.get("status") == "ok":
leg1_by_mode[dm] = r
leg2_by_mode = {}
for om in offroad_modes:
r = self.route(th["lat"], th["lon"], end_lat, end_lon,
mode=om, boundary_mode=boundary_mode, annotate_mvum=False)
if r.get("status") != "ok":
continue
if (r.get("summary") or {}).get("total_distance_km", 0.0) < HYBRID_MIN_OFFROAD_KM:
continue # no trivial offroad detours
leg2_by_mode[om] = r
for dm, om in HYBRID_PAIRS:
leg1 = leg1_by_mode.get(dm)
leg2 = leg2_by_mode.get(om)
if leg1 is None or leg2 is None:
continue
total = ((leg1.get("summary") or {}).get("total_effort_minutes", float("inf"))
+ (leg2.get("summary") or {}).get("total_effort_minutes", float("inf")))
if total < threshold and (winner_minutes is None or total < winner_minutes):
winner_minutes = total
winner = (leg1, leg2, dm, om, th)
if winner is None:
return None
leg1, leg2, drive_mode, offroad_mode, th = winner
return self._build_hybrid_response(leg1, leg2, drive_mode, offroad_mode, th)
def _build_hybrid_response(self, leg1, leg2, drive_mode, offroad_mode, trailhead):
"""Combine two route legs into one "multi" scenario response with a transition
marker at the trailhead. Each leg is annotated separately (probing ran with
annotate_mvum=False); summary fields are summed across legs."""
self._annotate_network_segments(leg1, drive_mode)
self._annotate_network_segments(leg2, offroad_mode)
def leg_features(leg, leg_no, mode):
out = []
for f in (leg.get("route") or {}).get("features", []):
props = dict(f.get("properties") or {})
if props.get("segment_type") == "combined":
continue # drop per-leg full-path lines; we keep network/wilderness
# network_mode drives the map's per-mode polyline color; wilderness=foot
if "network_mode" not in props:
props["network_mode"] = (
"foot" if props.get("segment_type") == "wilderness" else mode)
props["leg"] = leg_no
out.append({"type": "Feature", "properties": props,
"geometry": f.get("geometry")})
return out
features = leg_features(leg1, 1, drive_mode)
features.append({
"type": "Feature",
"properties": {
"segment_type": "transition",
"kind": "transition",
"lat": trailhead["lat"],
"lon": trailhead["lon"],
"name": trailhead.get("name", ""),
"from_mode": drive_mode,
"to_mode": offroad_mode,
},
"geometry": {"type": "Point",
"coordinates": [trailhead["lon"], trailhead["lat"]]},
})
features.extend(leg_features(leg2, 2, offroad_mode))
s1 = leg1.get("summary") or {}
s2 = leg2.get("summary") or {}
def leg_summary(s, mode):
return {
"mode": mode,
"distance_km": float(s.get("total_distance_km", 0.0)),
"minutes": float(s.get("total_effort_minutes", 0.0)),
"segments_summary": {
"scenario": s.get("scenario"),
"network_km": float(s.get("network_distance_km", 0.0)),
"wilderness_km": float(s.get("wilderness_distance_km", 0.0)),
},
}
total_distance = (float(s1.get("total_distance_km", 0.0))
+ float(s2.get("total_distance_km", 0.0)))
total_minutes = (float(s1.get("total_effort_minutes", 0.0))
+ float(s2.get("total_effort_minutes", 0.0)))
summary = {
"total_distance_km": total_distance,
"total_effort_minutes": total_minutes,
"wilderness_minutes": (float(s1.get("wilderness_effort_minutes", 0.0))
+ float(s2.get("wilderness_effort_minutes", 0.0))),
"network_minutes": (float(s1.get("network_duration_minutes", 0.0))
+ float(s2.get("network_duration_minutes", 0.0))),
"mvum_closed_crossings": (int(s1.get("mvum_closed_crossings", 0) or 0)
+ int(s2.get("mvum_closed_crossings", 0) or 0)),
"mvum_segments_annotated": (int(s1.get("mvum_segments_annotated", 0) or 0)
+ int(s2.get("mvum_segments_annotated", 0) or 0)),
"scenario": "multi",
"network_mode": offroad_mode,
"wilderness_mode": "foot",
"legs": [leg_summary(s1, drive_mode), leg_summary(s2, offroad_mode)],
"transition": {
"lat": trailhead["lat"], "lon": trailhead["lon"],
"name": trailhead.get("name", ""),
"from_mode": drive_mode, "to_mode": offroad_mode,
},
}
return {
"status": "ok",
"route": {"type": "FeatureCollection", "features": features},
"summary": summary,
"selected_mode": "hybrid",
"scenario": "multi",
}
def _route_D_network_only(
self,
start_lat: float, start_lon: float,

View file

@ -0,0 +1,201 @@
"""MVUM Layer 3a tests: trailhead transition index + multi-modal Auto hybrids.
The index tests build a TrailheadIndex from a synthetic trail_entry_points table.
The hybrid tests drive OffrouteRouter._try_hybrid_auto on a bare instance with a
stubbed self.route, so no Valhalla/DEM dependencies are exercised.
"""
import sqlite3
import pytest
from services.navi_offroute.mvum_transitions import TrailheadIndex
from services.navi_offroute.router import OffrouteRouter
def _trailhead_db(tmp_path, points):
"""points: list of (lat, lon, highway_class, name)."""
db = tmp_path / "navi.db"
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE trail_entry_points "
"(id INTEGER PRIMARY KEY, lat REAL, lon REAL, highway_class TEXT, name TEXT)"
)
conn.executemany(
"INSERT INTO trail_entry_points (lat, lon, highway_class, name) VALUES (?,?,?,?)",
points,
)
conn.commit()
conn.close()
return db
# ── index ────────────────────────────────────────────────────────────────
def test_trailhead_index_loads(tmp_path):
db = _trailhead_db(tmp_path, [
(44.00, -114.00, "track", "Trailhead A"),
(44.01, -114.02, "residential", "Road B"),
])
idx = TrailheadIndex(db_path=db)
assert idx.count == 2
assert len(idx.records) == len(idx._points) == 2
rec = idx.records[0]
assert rec["name"] == "Trailhead A"
assert rec["road_class"] == "track" # highway_class surfaced as road_class
assert rec["lat"] == 44.00 and rec["lon"] == -114.00
def test_query_trailheads_near_line_returns_close_only(tmp_path):
# One point sits right on the line; one is ~30 km away (well outside 2 km).
db = _trailhead_db(tmp_path, [
(44.000, -114.000, "track", "On Line"),
(44.300, -114.000, "track", "Far Away"),
])
idx = TrailheadIndex(db_path=db)
line = [(44.000, -114.010), (44.000, 113.990 * -1)] # ~horizontal segment at lat 44
near = idx.query_trailheads_near_line(line, buffer_m=2000)
names = {r["name"] for r in near}
assert "On Line" in names
assert "Far Away" not in names
# ── hybrid selection (stubbed self.route) ──────────────────────────────────
def _ok_leg(distance_km, minutes, scenario="D"):
return {
"status": "ok",
"route": {"type": "FeatureCollection", "features": [
{"type": "Feature",
"properties": {"segment_type": "network", "network_mode": "x"},
"geometry": {"type": "LineString",
"coordinates": [[-114.0, 44.0], [-114.1, 44.1]]}},
]},
"summary": {
"total_distance_km": distance_km,
"total_effort_minutes": minutes,
"network_distance_km": distance_km,
"network_duration_minutes": minutes,
"wilderness_distance_km": 0.0,
"wilderness_effort_minutes": 0.0,
"scenario": scenario,
},
}
class _FakeTrailheads:
def __init__(self, records):
self._records = records
def query_trailheads_near_line(self, coords, buffer_m=2000):
return list(self._records)
def _bare_router(trailheads=None):
r = object.__new__(OffrouteRouter)
r.spatial_index = None
r.trailhead_index = trailheads
return r
def _winning_single_mode(distance_km, minutes):
"""A single-mode best_result with a combined polyline of the given distance."""
res = _ok_leg(distance_km, minutes)
res["route"]["features"].append({
"type": "Feature",
"properties": {"segment_type": "combined"},
"geometry": {"type": "LineString",
"coordinates": [[-114.0, 44.0], [-114.5, 44.0]]},
})
res["selected_mode"] = "vehicle"
return res
def test_hybrid_meets_mitigations(monkeypatch):
# Short trip (< MIN_HYBRID_DISTANCE_KM) -> never goes hybrid.
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
r = _bare_router(th)
monkeypatch.setattr(OffrouteRouter, "route",
lambda self, *a, **k: _ok_leg(1.0, 5.0))
best = _winning_single_mode(distance_km=5.0, minutes=60.0) # 5 km < 8 km
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 60.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
def test_hybrid_wins_with_big_savings(monkeypatch):
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "Sawtooth TH",
"road_class": "track"}])
r = _bare_router(th)
# Drive legs are fast; offroad legs are short-but-meaningful and fast. Any leg
# combo sums to ~50 min vs the 120 min single-mode winner -> saves > 15 min.
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
boundary_mode="pragmatic", annotate_mvum=True, **k):
if mode == "vehicle":
return _ok_leg(12.0, 20.0)
return _ok_leg(4.0, 30.0) # 4w/2w/foot offroad legs (>= 0.8 km)
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is not None
assert out["selected_mode"] == "hybrid"
assert out["summary"]["scenario"] == "multi"
assert len(out["summary"]["legs"]) == 2
assert out["summary"]["total_effort_minutes"] == pytest.approx(50.0)
# one transition marker present in the combined feature collection
kinds = [f["properties"].get("kind") for f in out["route"]["features"]]
assert kinds.count("transition") == 1
trans = next(f for f in out["route"]["features"]
if f["properties"].get("kind") == "transition")
assert trans["properties"]["name"] == "Sawtooth TH"
assert trans["geometry"]["type"] == "Point"
def test_hybrid_skips_trivial_offroad_detour(monkeypatch):
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
r = _bare_router(th)
# Offroad legs are below HYBRID_MIN_OFFROAD_KM (0.8 km) -> rejected, so even
# though the time math would otherwise win, no hybrid is produced.
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
boundary_mode="pragmatic", annotate_mvum=True, **k):
if mode == "vehicle":
return _ok_leg(12.0, 20.0)
return _ok_leg(0.3, 5.0) # < 0.8 km offroad
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
def test_no_trailheads_falls_back_to_single_mode(monkeypatch):
r = _bare_router(_FakeTrailheads([])) # no candidates near the line
monkeypatch.setattr(OffrouteRouter, "route",
lambda self, *a, **k: _ok_leg(5.0, 10.0))
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
def test_hybrid_not_taken_when_savings_below_threshold(monkeypatch):
# Hybrid total (40 min) is faster than the winner (50 min) but only by 10 min
# (< HYBRID_MIN_TIME_SAVINGS_MIN = 15) -> single-mode winner is kept.
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
r = _bare_router(th)
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
boundary_mode="pragmatic", annotate_mvum=True, **k):
if mode == "vehicle":
return _ok_leg(12.0, 20.0)
return _ok_leg(4.0, 20.0)
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=50.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 50.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react"
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical, Repeat } from "lucide-react"
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
@ -16,7 +16,9 @@ const TRAVEL_MODES = [
]
// Maps the backend's selected_mode to the chip label shown in the "Auto chose X" badge.
const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot" }
const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot", hybrid: "Multi-modal", multi: "Multi-modal" }
const KM_TO_MI = 0.621371
const BOUNDARY_MODES = [
{ id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" },
@ -331,6 +333,23 @@ export default function DirectionsPanel({ onClose }) {
</div>
)}
{/* MVUM Layer 3a: per-leg breakdown for a multi-modal (transition) trip */}
{(routeResult?.selected_mode === "hybrid" || routeResult?.selected_mode === "multi")
&& Array.isArray(routeResult?.summary?.legs) && (
<div
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg"
style={{ background: "var(--accent-muted)", color: "var(--accent)" }}
>
{routeResult.summary.legs.map((leg, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <Repeat size={12} />}
<span>{`${SELECTED_MODE_LABEL[leg.mode] || leg.mode} ${(leg.distance_km * KM_TO_MI).toFixed(1)} mi (${Math.round(leg.minutes)}min)`}</span>
</span>
))}
<span style={{ opacity: 0.8 }}>{`— total ${Math.round(routeResult.summary.total_effort_minutes)}min`}</span>
</div>
)}
{/* MVUM Layer 1: network-leg closures crossed for the selected mode */}
{routeResult?.summary?.mvum_closed_crossings > 0 && (
<div

View file

@ -8,7 +8,8 @@ import { useStore } from '../store'
import { decodePolyline } from '../utils/decode'
import { fetchReverse, requestOffroute } from '../api'
import { getConfig, hasFeature } from '../config'
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react'
import { createRoot } from 'react-dom/client'
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus, Repeat } from 'lucide-react'
import RadialMenu from './RadialMenu'
import useContextMenu from '../hooks/useContextMenu'
import toast from 'react-hot-toast'
@ -31,6 +32,9 @@ const OFFROUTE_SOURCE = 'offroute-source'
const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness'
const OFFROUTE_NETWORK_LAYER = 'offroute-network'
const OFFROUTE_MARKERS_LAYER = 'offroute-markers'
// MVUM Layer 3a: per-mode network polyline palette + labels for transition tooltips.
const MODE_COLORS = { vehicle: '#1f78b4', auto: '#1f78b4', '4w': '#ff7f00', '2w': '#33a02c', foot: '#e31a1c' }
const MODE_LABELS = { vehicle: 'Drive', auto: 'Drive', '4w': '4W', '2w': '2W', foot: 'Foot' }
const HILLSHADE_SOURCE = 'hillshade-dem'
const HILLSHADE_LAYER = 'hillshade-layer'
const TRAFFIC_SOURCE = 'traffic-tiles'
@ -1359,6 +1363,10 @@ function clearRouteDisplay(map) {
if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER)
if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER)
if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE)
if (map._offrouteTransitionMarkers) {
map._offrouteTransitionMarkers.forEach((m) => m.remove())
map._offrouteTransitionMarkers = []
}
}
/** Update offroute display with route GeoJSON */
@ -1406,16 +1414,43 @@ function updateRouteDisplay(map, routeGeojson) {
filter: ["==", ["get", "segment_type"], "network"],
layout: { "line-join": "round", "line-cap": "round" },
paint: {
"line-color": "#3b82f6", // blue-500
// Layer 3a: color each network leg by its travel mode (hybrid trips mix modes).
"line-color": [
"match", ["get", "network_mode"],
"vehicle", MODE_COLORS.vehicle, "auto", MODE_COLORS.auto,
"4w", MODE_COLORS["4w"], "2w", MODE_COLORS["2w"], "foot", MODE_COLORS.foot,
"#3b82f6", // default (single-mode legacy blue)
],
"line-width": 5,
"line-opacity": 0.85,
},
}, beforeId)
// Fit bounds to route
const features = routeGeojson.features || []
// Layer 3a: transition markers (drive -> offroad vehicle switch) at trailheads.
map._offrouteTransitionMarkers = map._offrouteTransitionMarkers || []
for (const f of features) {
if (f.properties?.kind !== "transition" || !f.geometry?.coordinates) continue
const el = document.createElement("div")
el.style.cssText = "width:26px;height:26px;border-radius:50%;background:#fff;" +
"border:2px solid #333;display:flex;align-items:center;justify-content:center;" +
"cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.4)"
createRoot(el).render(<Repeat size={15} color="#333" />)
const toLabel = MODE_LABELS[f.properties.to_mode] || f.properties.to_mode
const marker = new maplibregl.Marker({ element: el })
.setLngLat(f.geometry.coordinates)
.setPopup(new maplibregl.Popup({ offset: 14, closeButton: false })
.setText(`Switch to ${toLabel}`))
.addTo(map)
el.addEventListener("mouseenter", () => marker.togglePopup())
el.addEventListener("mouseleave", () => marker.togglePopup())
map._offrouteTransitionMarkers.push(marker)
}
// Fit bounds to route (LineString segments only; transition points are Points)
const allCoords = features
.filter(f => f.geometry?.coordinates)
.filter(f => f.geometry?.type === "LineString")
.flatMap(f => f.geometry.coordinates)
if (allCoords.length > 0) {