commit efde2cc71bbbf78a2797a716ff40e4cdd077ad3f Author: Greg Pomerantz Date: Sun Sep 6 00:05:17 2026 -0400 maps project: design, survey, mock app, and route-aware planning backend - DESIGN.md: full design incl. driving-trip requirements (R1-R4), stays model, focus mode, mobile, provenance rules - SURVEY.md: open-source landscape - mock/: interaction mock (Florence itinerary, focus mode, stays, region stops, mobile layout) - router/: Go module (stdlib-only) with Router interface (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor, routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing), integration tests, and setup-osrm.sh for the self-hosted router - osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b43a54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# map tile cache + server runtime +mock/.tilecache/ +mock/server.log + +# OSM data (downloaded by router/scripts/setup-osrm.sh) +osm/*.osm.pbf +osm/build/ + +# Go +router/router +router/bench diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..7cccaff --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,1044 @@ +# LLM Map Assistant — High-Level Design + +An itinerary building & refinement tool: the user chats with the system to +build and iteratively refine a multi-stop trip (sightseeing, food, errands, +travel between places), and the system answers map questions — routes and +travel times, businesses, opening hours, products & services — against +authoritative geospatial data. The itinerary is a first-class, versioned +state object that the LLM edits; free-form map QA is a sub-feature. + +The system carries a trip through the whole planning lifecycle: **idea +generation & filtering → organization & planning (incl. multi-modal route +planning) → changes & refinement → identification & selection of +alternatives** (see Planning lifecycle). + +Deployment: **web-first** — serious planning happens on a desktop browser +(map + timeline + chat, lots of screen). The mobile app is an **on-trip** +companion: read the plan, navigate, check "open now?", make small edits — +and it must keep working in **areas with limited network coverage** (see +Interface & offline strategy). The LLM agent + tools + data live on a +server; on-device inference is a later fallback, not a target (see Model +selection). + +## Core principle (from the research) + +Don't let the LLM "look at" the map. Have the LLM **orchestrate deterministic +geospatial tools** and let the tools produce exact facts (distances, times, +coordinates, hours). The LLM contributes: + +- understanding and decomposing the user's question, +- choosing/sequencing tool calls (agent loop), +- filtering/ranking retrieved results, +- composing a natural-language answer. + +Evidence: + +- *MapQA* (arXiv 2503.07871): open-domain geospatial QA benchmark built on + OpenStreetMap; question types include routing, POI attributes, spatial + relations. A good evaluation set for us. +- FOS4G 2026 talk "A Systematic Comparison of RAG Architectures for + Geographic POI Question Answering": structured/geotemporal RAG with tool + access beats naive vector RAG for spatial POI queries. +- *MapBench* (arXiv 2503.14607) and *MapReason-OSM*: even strong VLMs fail + at reliable path-finding/symbol reading on map images. ⇒ map images are + for the user, not the engine. + +## Itinerary: the core state object + +The product is *itinerary building & refinement*, so the itinerary is a +typed, persisted document — not chat history. It is **hierarchical**: +the top level spans countries/regions; each *place* (a country, city, +neighbourhood — **free-form, not a fixed category list**) carries its own +refined objective/"vibe" and contains its own days and stops. + +```jsonc +{ + "id": "trip-2026-09", + "version": 12, // every LLM edit = new version (undo, diff) + "preferences": { // the evolving taste model (see Exploration loop) + "likes": ["slow_mornings", "street_food", "coastal_walks"], + "rules_out": ["theme_parks", "early_flights"], + "budget": "moderate", + "vibe_by_place": {"portofino": "relaxed, food-first", + "florence": "art, but not the big 4 museums"} + }, + "dates": ["2026-09-12", "2026-09-20"], + "modes": {"default": "foot", "car": false, "transit": true}, + "places": [{ + "id": "PL1", + "name": "Tuscany — Florence & coast", // free-form label, any granularity + "centroid": [43.77, 11.25], // its geo anchor (city or area) + "bbox": [43.1, 10.0, 43.9, 11.9], // for tiles/caching/planning + "vibe": "art + food, relaxed pace", // refined objective for this place + "dates": ["2026-09-12", "2026-09-17"], + "days": [{ + "date": "2026-09-12", + // no per-day `base`: the anchor is RESOLVED from `stays` (below) — the + // stay(s) covering this date. >1 candidate stay on the same date ⇒ + // worst-case anchoring for that date (see rules). + "stops": [ + {"poi_ref": "P456", "arrive": "09:30", "depart": "10:15", + "purpose": "visit", + "suggested_duration": {"value": "02:30", "source": "search"}, + "planned_duration": {"value": "01:00", "source": "user"}, // sticky override + "price": {"amount": 14, "currency": "EUR", "basis": "per person", + "source": "search"}, + "slot": "morning_visit", + "kind": "point|region|transit", // region = vague ("lunch in Oltrarno") + "state": "planned|maybe|alt"}, // one `planned` per (day, slot) + {"poi_ref": "P789", "arrive": "12:00", "depart": "13:30", + "slot": "lunch", "kind": "point", "state": "planned", + "purpose": "meal", "meal": {"kind": "lunch", + "typical_duration": {"value": "01:15", "source": "search"}, + "typical_cost": {"amount": 25, "currency": "EUR", "source": "search"}} + ], + "legs": [ // filled by router, never by the LLM + {"from": "P123", "to": "P456", "mode": "foot", + "duration": "00:12", "distance_m": 900, "geometry_ref": "G1"} + ] + }] + }, { + "id": "PL2", "name": "Portofino", "centroid": [...], "bbox": [...], + "vibe": "beach + seafood, one slow day", + "dates": ["2026-09-17", "2026-09-19"], "days": [...] + }], + "transit": [ // external transport: opaque, not OSM-routed + {"type": "train", // flight | train | bus | car_rental | hotel | ferry … + "from_place": "PL1", "to_place": "PL2", + "date": "2026-09-17", "depart": "08:10", "arrive": "09:55", + "ref": "Trenitalia 12345", "source": "user_booking", + "status": "booked"}, // booked|planned|estimated — booked = hard anchor + {"type": "car_rental", + "place": "PL2", + "pickup": {"at": "2026-09-18T15:00", + "where": "Hertz, via Palazzuolo 12", "poi_ref": "P901"}, + "dropoff": {"at": "2026-09-21T10:00", "where": "…"}, + "ref": "Hertz conf. ABC123", "source": "user_booking", + "status": "booked"} + ], + "stays": [ // lodging = per-night records, NOT a trip attribute + {"id": "ST1", "place": "PL1", + "hotel": {"name": "Hotel Palagio", "poi_ref": "P123"}, + "check_in": "2026-09-12", "check_out": "2026-09-15", + "ref": "conf. H-77821", "source": "user_booking", + "status": "booked|candidate", // candidates ⇒ worst-case anchoring for those nights + "price_per_night": {"amount": 180, "currency": "EUR", "source": "booking"}}, + {"id": "ST2", "place": "PL2", "hotel": {"name": "hotel Y", "poi_ref": "P555"}, + "check_in": "2026-09-17", "check_out": "2026-09-19", + "status": "booked"} + ] + // router legs (intra-place) may themselves be multi-modal: + // {"mode": "multimodal", "sub_legs": [{"mode":"foot",…},{"mode":"bus",…}], …} +} +``` + +Rules that make this work: + +- **Hierarchy is data, not ceremony.** A place is any `{label, centroid, + bbox, vibe, dates}`. "Italy", "Florence" and "Oltrarno" are the same + record shape; the planner just works at the granularity that has days. + One country trip = one place; a neighbourhood focus inside it = a child + place with its own days. No fixed category taxonomy anywhere. +- **External transit is opaque, and bookings are hard anchors.** + Flights/trains/long hauls are *not* OSM-routed (rail/air networks aren't + in OSM at usable fidelity): they're first-class `transit` objects with + `{type, window, source, status}`. Anything the user has *booked* (dropped + in as a ticket/confirmation — see Booking ingestion below) is a **hard + constraint**: the planner re-anchors days and legs around it and may + never move it, only work around it. `car_rental` objects additionally + *widen available modes* (car legs allowed inside the pickup→dropoff + window at that place). Intra-place movement stays in the router — + including **multi-modal legs** (walk + bus/subway; OSM carries the transit + network, GraphHopper/OTP/Valhalla compute the combined leg with sub-legs). + The validator checks *junctions* (arrive PL2 09:55 → first stop 10:30 + feasible; transit departs after last stop; car legs only inside the + rental window). +- **Lodging is a set of *stays*, not a trip attribute.** A stay is + `{hotel, place, check_in, check_out, status, price_per_night}`. A day's + anchor *resolves* to the stay(s) covering its date — one city, one hotel + = one stay; a multi-city trip = several stays, each anchoring its own + dates. Multiple **candidate** stays covering the same dates are normal + (comparing two hotels before deciding), and they trigger the rule below. +- **Slots & states.** Every stop belongs to a **slot** (a free-form + mutual-exclusion group per day: `lunch`, `dinner`, `afternoon_visit`… — + not a fixed taxonomy) and a **state**: `planned` (counts against the + day's time budget), `maybe` (held, time-neutral), `alt` (candidate for + the same slot, rendered as an "or"). **One `planned` stop per (day, + slot)**; choosing a second demotes the incumbent to `alt` (swap-in + is one tap, nothing is lost). Removing the last `planned` of a slot + auto-promotes a remaining `alt`. A stop's **kind** is `point` (decided + POI), `region` (vague — "lunch near Oltrarno"; reserves the slot and + time, anchored to a centroid+radius), or `transit` (booked external + transport, a hard anchor). Region stops refine **in place** (the + concrete stop replaces the placeholder at the same position). +- **Worst-case anchoring (never plan on false precision).** Any leg whose + endpoint is a *set* — multiple candidate stays on that date, or a region + stop — is budgeted at the **farthest/worst member** and flagged in the + low-confidence tier ("worst-case until you commit/refine"). Committing a + hotel or refining a region recomputes those legs to real values; because + downstream decisions were made on worst cases, the plan stays valid. + Days end with a **return-to-stay** leg (included in the budget) except + on departure days. +- **Exclusion list.** "Not for me" / "no " writes to the + `preferences.rules_out`-style list; it is applied to every future search + (query level, before anything renders) and fed to the LLM as context. + +### Booking ingestion (drop a ticket, the plan adapts) + +Users drag & drop real-world documents — train tickets, flight +confirmations, car-rental emails (PDF, image, screenshot) — onto the app: + +1. **Parse (LLM):** document → structured proposal + `{type, date, times, origin, destination, reference, carrier}`. The LLM + extracts; nothing is invented — fields the document doesn't state stay + null and are asked for in the UI. +2. **Ground:** origin/destination names are geocoded to POIs/places; the + proposal is matched against the itinerary (which place, which day, near + which stop). +3. **Propose:** rendered as a *suggested transit object* (status + `booked`) plus the planner's re-anchoring diff: which legs/days move + around it, new mode availability (car), junction checks. Example: drop + a rental-car confirmation "pick up 15:00, via Palazzuolo" → car appears + available from 15:00, afternoon legs re-plan onto car, walking leg to + the agency inserted. +4. **Confirm:** user accepts (commits via `apply_edit`) or corrects fields + inline; the document's reference number is stored on the object and + shown on the itinerary. + +This is the mechanism by which the itinerary *subsumes the user's real +commitments* instead of competing with them — the app plans around what's +already booked, which is the state of the world on day 1 of any trip. + +**Hotels are special bookings:** a lodging booking sets each day's `base` +— the anchor that mornings depart from and evenings return to (drives +first/last leg computation and "open near your hotel" suggestions). Hotels +also enter the alternatives loop ("a quieter hotel near the old town?"), +and changing the hotel re-anchors every affected day. + +**Entry via booking:** the app's first screen is the "Where would you like +to go?" prompt — and it doubles as a drop zone. Dragging in a booking +(train/flight most common, hotel reservation works too) starts the app: +parse → ground → the itinerary is born already anchored to the user's real +commitments, with the places implied by the booking pre-selected. +- **The LLM proposes, the tools decide.** The LLM adds/moves/removes *stops* + and *places* (POI refs, vibes, date ranges). Every *leg* (mode, duration, + distance, geometry) is computed by the router and written back by a + service, never by the LLM. Arrival/departure times are *proposed* by the + LLM and *validated* by the checker (below); the checker can reject or + correct ("Museum A opens 10:00, your 09:30 arrival is impossible — moved + to 10:00"). +- **Deterministic validator after every edit** (`validate_itinerary`): + - opening-hours check of every stop against its scheduled time, + - time-budget feasibility (arrive ≤ depart, legs sum to the day), + - mode/time sanity (no 2 a.m. entries in closed venues, max walking + distance per leg, etc.). + The validator's warnings are fed back to the LLM and/or the user. This is + the itinerary equivalent of the grounding rule for plain QA. +- **Versioned diffs → the refinement UX.** "Make day 2 more leisurely", + "swap the gallery for a pub with live music", "we're staying 1 day longer" + become edits that produce a *new version*; the UI renders old vs new + (map + timeline) with accept/reject/undo. Refinement is where the product + lives — cheap incremental edits, not full replans. +- **Persistence & shareability:** itinerary is a JSON doc in Postgres (+ map + payload); exportable/shareable; re-validation is a pure function so a + shared itinerary can be re-checked when data refreshes. + +### Suggestion engine: knowledge sources + +Suggestions for places/vibes/stops are a **three-source blend**, in order of +latency and trust: + +1. **OSM ground truth (instant, local):** `place_stats` — what the place + actually contains (tag clusters, coastline, parks, transit). Sets the + floor: nothing is suggested the place can't deliver. +2. **LLM world knowledge (instant):** culture, seasonality, "what's worth + it and why", rough quality rankings, hidden gems. Fast but may be + stale or wrong. +3. **Web search (seconds, server-side):** augments 1+2 — current events + ("restored 2026", "festival in September", closures, new openings), + practical detail ("is the cable car running"), **per-stop expectations** + (recommended dwell time, entry price, typical meal duration & cost — + see Stop expectations below), and **freshness checks** on anything the + LLM asserts confidently. Implementation: a search backend behind one + interface (self-hosted **SearXNG** for privacy/free, or Tavily/Serper- + class APIs for quality); queries derived by the LLM per suggestion, + results cached per (place, topic, week), and **cited** — each + search-derived enrichment carries its source URL, shown in the UI. + +The blend is per-suggestion, not global: a "visit X" card may be 100% OSM+ +LLM (no search needed), while "best time to see the aurora here" is +search-heavy. Search **augments; it does not gate** (below). + +### Real-time, non-blocking UI (streaming enrichment) + +Search is seconds-slow; the chat and cards must feel instant. Pattern: + +- **Two-phase rendering per turn.** The LLM's fast-path output (OSM + + world knowledge) renders immediately: vibe chips, suggestion cards with + name/pitch/vibe, draft edits. Each card is *fully interactive the moment + it appears* (tap to like/rule-out/add to plan). +- **Async enrichment over SSE.** While the user reads, search results + stream back (server-sent events, one channel per conversation turn) and + *patch* existing cards in place: added image, "opens Sep 2026", source + link, a corrected fact. Enrichment never reorders, removes, or rewrites + committed content; if a search result contradicts a rendered claim, the + card shows the correction inline (note + source) and the next LLM turn + sees the corrected state. User actions made while enrichment is in + flight are valid — the server resolves conflicts against the itinerary + version, not the search results. +- **Progressive depth in chat:** the assistant answers in layers — direct + answer first, then "digging deeper…" details as search lands. The user + can steer or cancel at any time; a fast reply the user acted on is never + retracted silently. + +### Imagery in the UI + +Pictures guide activity choices (a beach, a monument, a valley). Sourcing, +cheapest-first: + +1. **Wikimedia Commons** (free API, no key, per-image license + + attribution) — keyed off the place/POI name; covers monuments, nature, + cities well. +2. **POI-level photos from OSM tags** where present (`image`/`photo` tags). +3. Optional paid upgrade: Unsplash/Pexels API for consistent lifestyle + shots. + +Rules: an image is a *card decoration with attribution + source link*, +fetched server-side and cached (keyed by POI/place), lazy-loaded, with a +graceful fallback (category icon over a gradient) when nothing is found — +an empty image slot must never block card interaction. + +### Exploration & preference loop (the fluid "where would you like to go?") + +Idea generation is a *conversation*, not a form. The `preferences` block +above is the state of that conversation, and it evolves through two input +channels: chat text and **UI selections** (liking/ruling out suggestions, +picking a vibe, dragging a suggested stop into a place). + +- **Seeding ("what is this place like?") is data-grounded.** When the user + names a destination, the server pulls its OSM *tag statistics* (top + tourism/amenity/leisure clusters: beaches, trails, museums, nightlife, + hiking, food culture; plus coastline/park area) — a small deterministic + function — and the LLM turns that into **vibe suggestions** ("relaxed", + "beach", "adventure", "food crawl") *for that specific place*, with web + search (Suggestion engine above) asynchronously adding current events + and seasonality ("harvest festival in September") as chips enrich. This + kills the failure mode of suggesting "beach days" for a landlocked + region: the chips are generated from what the place actually contains, + with the LLM adding world knowledge on top. +- **Suggestion loop:** the LLM surfaces *candidate* places/days/stops in the + UI as cards (not just chat text). Every user reaction — like, pass, + "not that, something quieter", a free-text refinement — writes into + `preferences` and the LLM regenerates/re-ranks. This is the same mechanism + as the alternatives loop, applied upstream: *generation* is iterative + constrained sampling, and the constraint set is user-observed, never + invented. +- **Progressive commitment:** suggestions are `candidate` → user picks → + `tentative` → planner fills in → `confirmed`. The trip takes shape top + down (country → places → days → stops) but any level can be edited at any + time; committing deeper doesn't lock higher levels. +- Free-form refinement field: always on. Its text goes to the LLM with the + current `preferences` + current suggestion batch; the LLM's reply may + include both prose and new suggestion cards. Nothing here requires + predetermined categories — the only structured bits are the preference + keys, which are open strings. + +### Stop expectations: dwell times, prices, meals + +A bottom-level stop is more than a slot: it carries **expectations** that +the plan is checked against: + +- `planned_duration` (what's scheduled) vs `suggested_duration` (how long + it *should* take, with `source: user|osm|llm|search`). +- `price` `{amount, currency, basis (per person/family), source}` — OSM + `fee`/`average_spend` tags first, else background search, else labeled + LLM estimate. +- Meal stops add `meal {kind, typical_duration, typical_cost}`. +- **User overrides are sticky and authoritative.** If the user sets 1 h at + the Palazzo Pitti, `source: user` — the LLM never silently re-extends it; + at most a one-time inline note ("guides usually suggest 2–3 h"), never + nagging. The UI always shows planned vs suggested ("1 h (suggested ~2.5 + h)") so the trade-off stays visible without being pushy. +- **Background search fills missing expectations** through the enrichment + pipeline (Suggestion engine §3) whenever a stop is added — dwell, price, + meal duration & cost — streaming into the card like any other + enrichment, cited. +- **Meals are a first-class planning target, not an afterthought:** + - `purpose: meal` stops carry a kind (breakfast/lunch/dinner). After an + activity is confirmed, `suggest_meals(day, window, near)` actively + finds restaurants: within a short walk of / along the corridor between + the neighboring activities, open during the free window, whose + `typical_duration` + travel fits the gap — each with cost and a + one-line character note. + - Day budgeting books meals at *typical* durations (lunch ≈ 60–90 min, + not 45), so free time isn't silently eaten by eating. + - Prices are collected per stop and rolled up in the background (per + day and trip, by category) — **not surfaced in the UI at this + stage**: no cost totals, no budget bars, no cost review issues. The + data model keeps it ready to surface later without rework. + +### Plan review (overall & detailed — issues, not prose) + +Layered, mirroring hard/soft: + +1. **Deterministic validator (existing):** opening hours, feasibility, + booked anchors, mode windows — blocking. +2. **Expectation-aware checks (new, deterministic):** planned vs suggested + dwell (45 min booked for a meal typical at 90 → issue), price vs budget, + daily walking distance, dead gaps, last-stop vs closing times. +3. **LLM review — `review_plan(scope: trip|place|day|stop)`:** reads + `itinerary_summary` + the computed expectation deltas and returns + **structured issues, not prose**: `{severity: error|warn|info, scope, + stop?, kind (meal_too_short | pacing | fatigue | cost | hours_risk | …), + message, fix_op?}`. The LLM catches what heuristics can't (three + museums in a row, dinner at 21:30 with the last train gone, a brutal + transition day after a lazy one); the narrative lives in `message`. + +Issues are a **UI object**: an issues panel (filter by scope/severity; +click → zoom to the stop; one-click apply of the attached `fix_op` via +`apply_edit`). Review runs on demand ("review day 2") and automatically +after large edits (badge lights up when new warnings appear — never +blocks). Every expectation cited in an issue carries its source, so the +user can see whether "typically 90 min" came from search or a guess. + +### Itinerary tools (added to the catalog below) + +- `place_stats(location | place_ref) -> tag statistics` + Deterministic: OSM amenity/tourism/leisure clusters, coastline/park area + for the place's bbox. Input to vibe generation — never invented by the LLM. +- `suggest_vibes(place_ref)` / `suggest_places(dates, budget)` — LLM-facing + wrappers that emit **structured suggestion cards** (place: name, label, + 1-line pitch, vibe, suggested days; stop: POI ref, pitch, trade-off). + Cards render in the UI; user reactions are recorded *server-side* into + `preferences` (the LLM never has to "remember" preferences). +- `add_place(name | bbox, dates, vibe)`, `remove_place(ref)`, + `move_place_dates(ref, dates)` — hierarchical edits; adding a place + auto-creates a `transit` stub from the previous place. +- `suggest_meals(day, window, near_stop?) -> [restaurant cards]` — nearby + restaurants fitting the free window (open at that time, typical_duration + + travel fits), each with cost, typical duration, hours, one-line + character note; expectations background-filled by search. +- `review_plan(scope: trip|place|day|stop) -> {issues[]}` — deterministic + expectation-aware checks + LLM review; issues carry severity, kind, + message, and an optional one-click `fix_op`. See Plan review. +- `transit_estimate(from, to, date, mode?) -> {windows, duration, source, + cost?}` — external rail/air lookup where available, else labeled estimate; + never fabricated silently — `source` is always shown in the UI. +- `plan_day(constraints, candidate_pool?) -> draft day` — server-side + heuristic planner (geo-cluster + hours + mode) that proposes a draft; the + LLM reviews/edits instead of freehand-sequencing from scratch. Keeps the + LLM's job in its comfort zone (taste/judgment) and the combinatorics in + code. +- `add_stop(day, poi_ref, slot?)`, `move_stop(...)`, `remove_stop(...)`, + `replace_stop(old_ref, constraints)` — atomic edits; each triggers leg + recomputation + validation. +- `validate_itinerary(itinerary) -> {ok, warnings[], fixes[]}` +- `itinerary_summary(itinerary) -> compact text` — the context form the LLM + sees (POI names, slots, leg durations; not geometries), keeps the context + window small on long trips. +- `parse_booking(doc) -> transit proposal` — LLM extraction from a dropped + ticket/confirmation (PDF/image) → structured fields (nulls asked for, not + invented) + geocoded origin/destination; feeds the Booking ingestion + flow. +- `apply_edit(op)` — **the single mutation endpoint for ALL itinerary + changes, from chat or from the UI** (both emit the same op vocabulary: + `move_stop`, `set_slot`, `set_leg_mode`, `set_route_via`, `add_stop`, + `remove_stop`, `add_transit`, …). Applies op → recompute affected legs → + validate (booked transit = immovable anchors) → version bump → stream + new state. Shared undo/redo lives here. +- `recompute_legs(day)` — router recomputation after edits (also called + implicitly by `apply_edit`). +- `web_search(topic, place_ref?, freshness?) -> [results with source URL, + date]` — the search backend (self-hosted SearXNG, or Tavily/Serper-class + API behind the same interface); per-turn budget (e.g. ≤4 queries), + cached per (topic, place, week). +- `image_for(poi_ref | place_ref | query) -> {url, attribution, license}` + — Wikimedia Commons → OSM photo tags → fallback icon; cached. + +The existing QA tools (geocode/search/hours/route) are the *building-block* +tools the itinerary tools are composed from, and they also serve ad-hoc +questions ("anything good near the castle?"). + +## Planning lifecycle (functional scope) + +Five capabilities, each mapped to the machinery above: + +| Phase | User does | System does | Key tools/mechanisms | +|---|---|---|---| +| 0. **Exploration** | "where would you like to go?" + free-form refinement; like/rule-out suggestions | OSM-grounded vibe suggestions per destination; candidate *place* cards; every reaction updates `preferences` | OSM tag-statistics function + LLM, suggestion cards, preference state (see Exploration loop) | +| 1. **Idea generation & filtering** | "we like history and coffee" | proposes candidate POIs (broad recall), surfaces hours/cuisine/tags, filters by constraints & interest match | `search_poi`, semantic vector path, `poi_details`, candidate pool with `status=candidate` | +| 2. **Organization & planning** | "plan day 2 around these" | clusters geographically, sequences stops, **multi-modal route planning** (foot/bike/car/train legs, with transfer stops), schedules slots | `plan_day`, `route` (per-mode legs), `distance_matrix`, validator | +| 3. **Changes & refinement** | "move the museum to the morning" | minimal-diff edits, re-derives affected legs & slots, re-validates | `move_stop`/`add_stop`/`remove_stop`, `recompute_legs`, versions | +| 4. **Alternatives** | "anything better for lunch?", "what if it rains?" | generates ranked *alternative sets* (2–5 options with trade-offs: distance, hours, price, weather-fit) for the user to pick from — the LLM presents, the user decides, the choice commits one option | `search_poi` + `distance_matrix` + `opening_hours` composed into an `alternatives` payload rendered as selectable cards | +| 5. **On-trip execution** | "where next?", "open right now?" | serves the synced itinerary, live re-validation (hours, traffic mode), small edits | read of synced itinerary, `opening_hours`, re-route | + +Notes: + +- **Multi-modal legs:** a leg is `{from, to, mode}`; the router returns the + best single-mode leg, and a separate *transfer planner* composes + multi-leg chains (e.g. train + walk) with intermediate stops. Transfer + chains are stored explicitly so refinement can swap a leg's mode without + touching its endpoints. +- **Alternatives are first-class UI, not chat text:** an alternatives request + produces a structured set (options + one-line trade-off rationale each); + the user taps one and it commits. This keeps selection a *human* decision + and keeps the LLM out of the final pick. +- **Scope honesty for multi-country:** intra-place = OSM routing + hours + + POIs (strong); inter-place = opaque transit objects (estimates or external + rail/air APIs) — the system plans *junctions and day structure* across + countries, it does not pretend to book or exact-route the long hauls. + +## Data layer + +Source of truth: **OpenStreetMap** (free, structured, has `opening_hours`, +`brand`, `amenity`, `shop`, `cuisine`, `opening_hours` etc. per POI). + +| Component | Options | Notes | +|---|---|---| +| Raw map data | Geofabrik PBF extract for the region of interest | one-time download + periodic refresh | +| Spatial DB | **PostGIS** (Postgres) | import PBF via `osmium`/`osm2pgsql`; gives SQL spatial queries (radius, nearest, bbox) | +| Geocoding | **Nominatim** (local) or Photon | address ↔ coordinates; fuzzy place names | +| Routing | **GraphHopper** (easy multi-modal) or **OSRM** (fast, table API) or **Valhalla** | car/bike/foot/wheelchair profiles, distance + duration, turn instructions | +| Opening hours | `osm-opening-hours` lib (Go/Py/JS) | evaluates OSM `opening_hours` for "now" / "at time X", timezone-aware | +| (optional) live queries | Overpass API | for regions we don't keep locally; rate-limited | + +Why local over pure APIs: +- reproducible, fast, no rate limits, works offline; +- PostGIS lets the LLM's tools do real spatial math (e.g. "shops within + 500 m of the route"), which no hosted API offers as a single call. + +Alternative for v0: skip PostGIS entirely and use Overpass API + a hosted +router (GraphHopper API / OSRM demo). Fine to start, but keep the interface +so a local backend can be swapped in. + +**Multi-region note:** with multi-country itineraries the data unit is +*per place* (per-region PBF / router graph / tile cache), not one global +load. Local per-city GraphHopper is fine; alternatively a hosted router +serves any place. Inter-place transit is **not** OSM routing — it's external +(rail/air search APIs where they exist, e.g. open rail timetables, else +labeled LLM/user estimates) stored as opaque `transit` objects. + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ Frontend: chat UI + map view (Leaflet / MapLibre GL) │ +│ (renders POIs, routes the LLM refers to; click → context) │ +└──────────────┬─────────────────────────────────────────────┘ + │ +┌──────────────▼─────────────────────────────────────────────┐ +│ Agent layer (the LLM, chat + function calling) │ +│ - conversation state (user location, preferences) │ +│ - itinerary state (current version, constraints) │ +│ - tool selection & multi-step planning │ +│ - answer synthesis with citations (POI ids / coords) │ +└──────────────┬─────────────────────────────────────────────┘ + │ tool calls (JSON) +┌──────────────▼─────────────────────────────────────────────┐ +│ Tool / service layer (deterministic, no LLM) │ +│ geocode · search_poi · poi_details · nearest · route · │ +│ distance_matrix · opening_hours · along_route · │ +│ route_options · corridor · stop_cost · optimize_stops · │ +│ place_facts │ +│ ┌────────────┬──────────────┬──────────────┬───────────┐ │ +│ │ PostGIS │ Router │ Geocoder │ Hours lib │ │ +│ └────────────┴──────────────┴──────────────┴───────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +### Tool catalog (the heart of the system) + +Each tool = a JSON-schema function the LLM can call. Keep them narrow and +composable. + +1. `geocode(query) -> [name, lat, lon, type]` + Resolve "St. James Park", "my location", street numbers. +2. `search_poi(name?, category?, tags?, near: {lat,lon,r} | bbox, limit) + -> [pois]` + Category synonyms handled in the LLM prompt ("place to get coffee" → + `amenity=cafe`). Returns id, name, tags of interest. +3. `poi_details(poi_id) -> full tags` + Second-level lookup so list queries stay cheap. +4. `nearest(category, from, max_results, max_distance)` +5. `route(from, to, profile=car|foot|bike|multimodal, via?, depart_at?) -> + {distance, duration, geometry, steps, sub_legs?}` — `multimodal` + (walk+bus/subway, from OSM transit data) returns sub-legs; `via[]` + waypoints make user-modified routes first-class (drag a waypoint, + recompute, done). The same call backs the L3 route editor with live + duration updates +6. `distance_matrix(places[], mode) -> matrix` + For "which of these is closest / quickest". +7. `opening_hours(poi_id | poi_ref, when=now|ISO-timestamp) -> + {open?, closes, weekly}` +8. `along_route(route_ref, category, max_distance) -> pois` + "a petrol station along the way" — PostGIS buffer over route geometry. + *Driving trips upgrade this to corridor search — see below.* +9. `reverse_geocode(lat, lon)` — for "what is near here". +10. `route_options(from, to, profile, max_alts) -> + [{ref, geometry, duration, tolls, via: [highways], diff_vs_fastest}]` + Fastest + user-relevant alternatives. A driving trip **must not emit + any time estimate before this has run** — the route is computed, never + narrated from LLM memory ("you'll cross Boston at 15:15" is a bug, + not an answer). +11. `corridor_from_route(route_ref, buffer_km, time_window?) -> corridor` + Spatial predicate (band around the geometry) **+ an along-route + coordinate system** (fraction / minutes-from-origin). User constraints + — "south of origin", "in the first 2 hours", "on the way" — are + filters in this coordinate space; direction is a fact, not a guess. +12. `search_along_route(corridor, slot, tags, prefs) -> + [{poi, along_route_pos, detour_min, facts}]` + OSM POIs inside the band ∪ LLM world-knowledge candidates *projected + into the band* (a remembered trail outside the corridor is filtered or + explicitly flagged "off route, +X min"). Exclusion list applied. + Every candidate carries its computed detour cost — the sort key. +13. `stop_cost(route_ref, stop | poi) -> {detour_min, total_added_min, + new_route_ref}` + `(route A→B via stop) − (route A→B) + dwell + declared overhead`. + The single most important number in trip planning: pure router + arithmetic, no LLM. Surfaced as a chip ("+18 min to trip") and used + by the validator (arrival-time constraints). +14. `optimize_stops(route_ref, candidates[], k, time_budget) -> [ordered + stops]` + Minimizes detour + Σ dwell + Σ overhead over ordered subsets of + exactly k (exhaustive permutation search ≤ 10 candidates — also the + benchmark ground truth — else greedy best-insertion). "Pick my two + 45-min hikes" is *this* call, not an LLM freehand choice — the LLM + frames candidates/tastes, the optimizer picks. +15. `place_facts(poi_id) -> {distance, loop_length, elevation_gain, + est_duration, dog_policy, parking, season_notes, hours, provenance[]}` + Sourced activity facts (OSM tags + cached source pages). The answer + may only quote numbers that exist here. + +Itinerary tools (`plan_day`, `add/move/remove/replace_stop`, +`validate_itinerary`, `itinerary_summary`, `recompute_legs`) — see the +Itinerary section above. + +Design rules for tools: + +- Return compact JSON; truncate large fields; include a stable `id`/ + `map_ref` per POI so the frontend can highlight it and the LLM can refer + to it without repeating all the data. +- Every tool returns coordinates so the answer is always renderable. +- Time is explicit: `now` resolved server-side with the location's + timezone (IANA), passed through opening-hours evaluation. +- **Provenance is enforced, not stylistic.** Every time/cost/policy value + carries a source tag: `computed` (router) / `measured` (OSM) / + `sourced` (booking, official page) / `assumed` (LLM). A hard output + rule: *an assumed number may never be presented as fact* — it either + triggers a tool call or renders in the low-confidence tier. (Failure + case that motivated this: a real planning session in which the route, + distances and a 40-mile "detour" were all confidently asserted from LLM + memory, and the "detour" turned out to be on the actual fastest route.) + +### Driving trips: the route is a first-class object + +Requirements sharpened by a real exercise (Lincoln NH → Queens NY, +"two 45-min hikes on the way") where the LLM-only approach failed on +route, direction, distance and activity facts while the *activity +recommendations themselves* (given correct spatial facts) were good. The +lesson: **bad facts poisoned good judgments**, and the fix is structural: + +1. **R1 — Compute the route before speaking.** A driving trip has an OD + pair; `route_options` runs first, the user's route choice (fastest vs + scenic vs toll-avoiding) becomes the versioned `route` object, and the + corridor derives from it. No suggestion, ETA or "you'll cross X at Y" + may be emitted before a route exists. +2. **R2 — "Along the route" is a spatial primitive.** Search is + corridor-constrained by default; *direction* and *position* constraints + ("south of origin", "first 2 hours") are filters on the corridor's + along-route coordinate system, not LLM geometry. +3. **R3 — Every stop has a computed cost.** `stop_cost` (detour + dwell + + overhead) is the display number, the sort key, and the input to the + validator ("detour pushes past your 19:00 arrival"). Adding/removing + stops re-derives the route and all costs through the existing mutation + pipeline. +4. **R4 — Provenance-gated output.** See design rules above. + +**Model impact:** the itinerary gains a versioned `route` per place/trip +(geometry ref, profile, chosen-alternative); stops gain `corridor_pos` +(minutes/fraction along route). The validator checks stop costs against +user time constraints ("home by 19:00"). **UI impact:** stop cards get a +`+X min` detour chip (computed tier); focus-mode pins are corridor- +constrained by default; the budget bar counts detour minutes, not just +dwell. + +**Evaluation hook:** *corridor-aware stop optimization* is a cleanly +bench-markable task class: given OD + k + time budget, ground truth = +router-optimal stop set and total time; compare system output. Generate +the benchmark from real OSM + router data (TravelPlanner covers leg +planning but not this composite). **Built:** `router/` (Go, stdlib-only) — +`route_options`/`stop_cost`/`optimize_stops`/corridor over Valhalla or +local OSRM, `cmd/bench` with 5 real NE-corridor tasks and recorded +goldens (26 checks passing), integration tests asserting e.g. the +fastest Lincoln NH → Queens NY drive stays ≥15 km from Boston. + +**`optimize_stops` semantics (implemented):** the user asked for *k* +stops, so the objective is total = detour + dwell + overhead over +ordered subsets of **exactly k**. "At most k" without a budget +degenerates (zero stops always wins — dwell only adds cost). With a +time budget: best feasible exactly-k → best feasible smaller set +(flagged `relaxed`) → best exactly-k ignoring the budget (flagged +infeasible). Exhaustive permutation search ≤ 10 candidates (this *is* +the ground truth), greedy best-insertion above. + +### Retrieval strategy (hybrid) + +- **Structured path (primary):** tools 1–9. Exact, cheap, verifiable. +- **Vector path (secondary):** embed the POI text (name + tags + any free + text) once per POI, index in Postgres `pgvector` (or SQLite-vec for v0). + Used for fuzzy semantic queries the tag vocabulary can't express: + "a place that sells artisan sourdough", "quiet pub with a garden". + Semantic search returns candidates → `poi_details` → LLM decides. +- Rerank candidates by distance (PostGIS) before handing to the LLM so the + context window always holds the *nearest, most relevant* few, not ten + random hits. + +### Agent loop + +Standard function-calling loop, with the itinerary state injected as +context (via `itinerary_summary`, not the raw doc): + +``` +user question + → LLM (system prompt: you are a map assistant; user location; tool list; + answer only from tool results; cite POIs; give times as durations, + don't invent) + → tool call(s), possibly parallel (geocode A, geocode B) + → results back → more calls or final answer + → answer + structured payload {pois:[refs], route: {geometry}} +``` + +- **Grounding rule:** the LLM must never state a travel time/distance/hours + value that didn't come from a tool result. This is the main guard against + hallucination and the reason the architecture matters. +- Multi-hop questions work naturally: "Is the best-rated bakery near my + home open right now?" → `search_poi` → `route` (optional) → + `opening_hours` → answer. +- Cap the loop (e.g. 6 tool rounds) and fall back gracefully. +- **Two operating modes:** *QA* (question → answer, no state change) and + *refine* (answer **plus** an itinerary edit). In refine mode the turn + commits as a new itinerary version; the validator runs between the edit + and the user sees anything. + +### Interface (web-first; mobile on-trip) + +Quality bar: **beautiful, responsive, flexible** — the map is the hero, the +chat is a tool, and every LLM suggestion must be visually inspectable and +reversible. + +**Web app (primary, desktop-optimized):** + +- Layout: **map (hero, ~60–70%) + day timeline rail + chat pane** (collapsible + to a bottom drawer on narrow screens). Responsive down to tablet/phone — + same app, panels reflow; it is *not* the on-trip app. +- Timeline rail: days → stops in sequence with slots, leg chips (mode + icon + duration); drag a stop to reorder (triggers `recompute_legs` + + validator), hover a leg to highlight its geometry. +- Alternatives & candidates render as **cards** (photo/tagline, hours, trade + -offs) alongside the map, with one-tap "put in plan". +- Every agent turn carries a **structured payload** (POI refs, geometries, + itinerary diff). Refine-mode edits render as an **accept/reject/undo diff** + (map + timeline animate old→new); rejection feeds back to the LLM as + conversation context. +- Power features for desktop: compare two versions, pin a POI as a + conversation anchor, filter layers (hours, price, mode), export + (PDF/ICS/GPX), split-screen two days. +- **Issues panel:** `review_plan` output lives here — filter by + scope/severity, click an issue to zoom to its stop, one-click apply of + the suggested fix-op; badge lights up when an edit introduces new + warnings (never blocks). Stops show planned vs suggested time ("1 h + (sug. ~2.5 h)") and price chips with source attribution. + +**Focus mode (slot search — the Google-Maps pattern).** "Where should we +lunch? What for the afternoon? Which hotel?" is a *different mental mode* +from editing the plan, and the UI says so: + +- Triggered by chat — "find a lunch spot", "lunch near Oltrarno", + "alternative hotels", "find another activity" — and works whether the + slot is **empty, vague, or already decided**: search is always + **re-openable**, and choosing is a *swap* (incumbent → `alt`, chosen → + `planned`), so any decision can be revisited later. +- The rail dims; the map becomes the hero with **name-labeled teardrop + pins** on the candidates and nothing else. Click a pin → popup: pitch, + **distance from the day's anchor (the previous stop — where the user + will actually be) and to the hotel**, duration, price, actions. +- **Chat becomes the filter bar**: "cheaper", "near Boboli", "views", + "vegan", "no Mario" — pins re-filter live; each reply names the closest + remaining option to the anchor. Filters are slot-aware ("cheaper" means + ≤ €25 for lunch, ≤ €220 for a hotel). +- Actions per pin: **Choose this** (slot swap), **Maybe**, for hotels + **Keep as option** (adds a candidate stay for those nights ⇒ worst-case + anchoring) and **Make this the hotel** (commit ⇒ re-anchor, real legs), + and **More like this** (re-query on the candidate's own tags). +- Candidates come from the 3-source engine (OSM ground truth → LLM world + knowledge → web enrichment); the **exclusion list** is applied on entry. + +**Mobile (web-first, usable from day one).** Below ~860px the 3-pane grid +collapses to a **single pane with a bottom tab bar (Plan | Map | Chat)**: +each pane full-width, the topbar wraps to two rows with horizontally +scrollable day tabs, and the map re-fits to the full viewport. Same app, +same state — not a separate mobile build. (A native on-trip shell with an +offline trip bundle is a later phase, not a prerequisite.) + +**Direct editing is first-class — chat is one editor, not the only one.** +Anything the LLM can do to the itinerary, the user can do by hand, and both +paths funnel through the **same mutation pipeline**: + +- Every change — LLM or human — is an atomic *edit op* (`move_stop`, + `set_slot`, `set_leg_mode`, `set_route_via`, `add_stop`, …) sent to the + server's single `apply_edit` endpoint. The server recomputes affected + legs, runs the validator, bumps the version, and streams back the new + state. The LLM never gets a fast path around validation, and a user drag + never bypasses leg recomputation. +- Shared **undo/redo** stack across both editors (each op is a version + delta); an LLM suggestion the user rejects is just an unapplied op. +- Hand-editing affordances: drag stop reorder / drag across days; inline + slot-time editing (typing a time or dragging on a time ruler — later + slots cascade and the validator flags overruns live); per-leg mode + switcher (foot/bike/car) with instant duration update; **route editing: + drag waypoints on the map (or click to add them) to alter a leg's path — + for a hike, snap to the actual trail — and duration/distance recompute + live**; add a stop by clicking the map (reverse-geocode → search → pick); + remove/duplicate stops; per-place day-length and budget settings. +- While the user is editing, chat suggestions for the same stops are + dimmed, not force-updated (the UI never fights the user's hand). + +**Time is the product's core value — a first-class display object, not +metadata.** + +- Every stop shows its *duration*; every leg shows *mode + duration* + (distance on hover); each day shows *planned total vs available* as a + budget bar (7.5 h planned of 11 h waking; amber/red on overrun); places + and the whole trip roll the totals up. +- **Travel times are never LLM-estimated.** Leg durations come from the + router (mode-specific profiles) or from explicit `transit` objects + (scheduled or labeled estimate). *Dwell* times (how long at the museum) + are a different beast: they carry `planned` vs `suggested` with source + labels, and user-set values are authoritative (see Stop expectations). + All estimates render with a "~" (dashed chip), distinct from computed + values. +- **Confidence hierarchy — every time/number estimate carries a level, and + the UI encodes each level distinctly:** + + | Level | Source | UI indicator | + |---|---|---| + | 5 (top) | user-set | filled chip + pin/lock glyph | + | 4 | scheduled (booked transit / hotel) | filled chip + ticket glyph | + | 3 | router-computed | plain filled chip | + | 2 | search-derived | "~" + source link | + | 1 (lowest) | LLM estimate | dashed chip, labeled "estimate" | + + Rules: a lower-confidence value never silently replaces a higher one + (user overrides are sticky — §Stop expectations); background enrichment + that *raises* confidence (1→2, 2→3) streams into the UI as an update + with a small "updated" pulse, never a silent rewrite. +- Time changes animate: a recompute that moves the afternoon is visible + (slots slide on the rail, the budget bar moves), so the user always sees + the *consequence* of an edit in minutes, not just in words. + +**Multi-scale navigation (one UI, four zoom levels):** + +| Level | View | Map shows | Rail shows | +|---|---|---|---| +| **L0 Trip** | all places + transit | places as nodes, transit arcs, day counts | places → days as a strip | +| **L1 Place / Day** (primary working view) | one place or one day | stops + intra-place legs | stops with slots + leg chips | +| **L2 Time window** | a slice of a day ("after lunch") | that subset | the subset, rest dimmed | +| **L3 Leg / Activity** | a single stop **or a single leg** | **the route editor**: the path with draggable waypoints (hike: snap to the trail; urban: walk-only, or multi-modal walk+bus+subway with the router's sub-legs shown as segments), distance + **live-updating duration** as the user edits it, optional elevation profile (GraphHopper elevation API) | day context, collapsed | + +Drill down by click (place → day → time window → leg/stop), back via +breadcrumb; map zoom and rail scope stay in sync. L3 is the **generic route +editor** — a hike is just a foot-only instance of it; so is a walk through +the city, or a "walk + bus + metro" chain (the router returns the +multi-modal leg with sub-legs; the user can drag waypoints on any of them +or swap a sub-leg's mode and the duration updates live). It must feel +instant: leg recompute for short legs is tens of ms on the local router. + +The mobile on-trip app uses the same hierarchy (L0–L3) with direct editing +scoped to today (reorder, skip, route-edit) and read-mostly elsewhere. + +**Mobile app (on-trip, secondary):** + +- Modes: *today* (current day as a checklist timeline + map, live "open now" + badges), *navigate* (leg-by-leg with mode-aware guidance), *ask* (chat — + server LLM when online), *edit small* (reorder today's stops, drop a + stop; server re-plans legs when connectivity allows). +- **Offline trip bundle** — the key on-trip requirement (limited coverage + on the trip itself): + - On "prepare for trip" the phone downloads: the itinerary + full stop + POI details (hours, tags, contact), **vector map tiles** for the trip + bbox (MapLibre + locally generated tiles, e.g. via `osm2vsmartin`, or + raster MBTiles), and leg geometries with turn-by-turn instructions. + - Offline the app serves: itinerary, maps, stop details, hours evaluation + (the hours library runs fully offline — pure function of local time), + and walking/bike re-routing within the trip area if we ship a small + local router (GraphHopper can preload a small region; treat as a v2+ + stretch — offline re-route degrades gracefully to "show saved leg"). + - **Sync model:** bundle = snapshot (itinerary version N + tile version); + edits made offline queue and reconcile on reconnect (itinerary is a + versioned doc, so a simple last-writer + server re-validation works); + re-validation on sync flags stops whose hours/legs changed. + - No LLM offline (except the optional on-device 4B for "is X open?" — + see Model selection); the offline experience is *plan + navigate*, + not *re-plan*. + +## Hard sub-problems to plan for + +1. **Opening hours**: OSM coverage is patchy (especially for small towns); + answer honestly ("no hours listed") instead of guessing. +2. **Timezones**: evaluate hours in the *place's* timezone, show the user + their timezone. +3. **"Open now" at the route level**: `along_route` + `opening_hours` + composition is a differentiator. +4. **Ambiguity**: geocoding "Main Street" → ask a clarifying question or + show top-3 candidates in the UI (structured, not just text). +5. **Staleness**: schedule PBF refresh (e.g. weekly); show data timestamp. +6. **Offline bundles**: tile size vs bbox (vector tiles keep this small; + a city at z14 ≈ a few hundred MB raster, much less vector); offline-edit + conflict resolution (versioned doc + server re-validation is the simple + correct answer); bundle validity (re-validate on reconnect). +7. **Time is the value proposition**: calibrate router profiles per mode + where possible; always distinguish *scheduled* (transit), *computed* + (router) and *estimated* (LLM/user) times in the UI; L3 route-edit + recompute latency must stay <~100 ms for short legs (local router, + preloaded graph). +8. **Two editors, one truth**: chat and hand-editing share `apply_edit` — + conflict resolution is by itinerary version (last op wins, validator + re-checks), never by "who asked first". +9. **Booking-document parsing**: tickets/confirmations arrive as PDFs, + screenshots, and carrier-specific layouts — LLM extraction needs + per-field confidence (show "did we read the platform right?" for + doubtful fields), and station/agency names need robust geocoding + ("near via Palazzuolo" → closest matching POI with user confirmation). + Car-rental windows are easy to get subtly wrong (timezone, date line) — + always echo the parsed window back for confirmation. + +## Evaluation + +- **MapQA** (arXiv 2503.07871) — 3,154 QA pairs from OSM (Southern + California, Illinois): routing + POI attribute questions. Great regression + suite for the QA sub-feature. +- **TravelPlanner** (ICML'24, OSU-NLP-Group/TravelPlanner) — 1,225 curated + travel-planning intents against ~4M records with a tool sandbox; the + standard benchmark for exactly this task class (constrained, multi-day, + transportation + meals + attractions + lodging). Use its constraint-checker + idea for our itinerary validator. +- Build a small local eval set over our region with golden itineraries; + score: (a) tool-answer correctness (deterministic, easy), (b) validator + pass-rate of LLM-edited itineraries (should be high after validation), + (c) LLM faithfulness to tool results (LLM-as-judge + spot checks), + (d) refinement quality: does a 1-instruction edit touch only the intended + stops (diff-locality metric). +- Track: question types, tool-call success rate, hallucinated-number rate + (should be ~0), median latency. + +## Model selection (server-first, on-device as fallback) + +Server (primary): any strong function-calling model behind one swappable +interface. Candidates from current benchmarks: + +- **Qwen3.8-27B** (dense, ~24 GB) — **first choice for this system: it runs + well on our hardware.** The 27B class buys real headroom exactly where + this workload stresses models: long context (whole-trip summaries across + places/days), multi-step planning with many tool rounds, and taste-level + suggestion writing. Good general/search reasoning; native tool calling. +- **Granite 4.2-8B** (Aug 2026): dense, Apache 2.0, native OpenAI-format + tool calling, agentic RL training, switchable thinking. Strong fit for the + refine-loop workload at lower compute; run at Q4 or FP16, thinking off on + simple tool turns. +- **Qwen3-30B-A3B-Instruct-2507**: MoE with 3B active — 8B-class capacity at + 3B-class latency; great if we serve on modest GPUs. +- Any hosted API (Claude/GPT-class) as the "quality ceiling" option and + for LLM-as-judge in eval. + +On-device fallback (optional, later — e.g. offline trip planning on a phone): + +- **Qwen3-4B-Instruct-2507** (dense 4B, Q4 ≈ 2.5–3 GB) — the reference + small tool-calling model; note it is the *4B*, not a 3B (the "3B" in the + family is the 30B-A3B MoE's *active* count). +- **Granite 4.2-3B** — new contender with the same agentic-RL pedigree; worth + a head-to-head on our MapQA subset (don't trust vendor BFCL numbers — + Granite 4.1-8B's were panned by the community). +- **Phi-4-mini (3.8B)** — fastest of the class, best if latency dominates. +- Sub-2B models: not reliable agent brains (tool JSON breaks); acceptable + only as an intent classifier in front of the main model. +- Quantization floor: **never below 4-bit** — structured output (tool calls) + degrades fastest under aggressive quantization (QuantCall benchmark; Q3 + already shows 3–8% PPL + visible reasoning regression), and a 2–3-bit 8B + is strictly worse than a 4B at Q4 for the same footprint. + +Reality check that shaped the server-first choice: an on-device 4B agent +loop takes ~30–60 s per multi-hop question (each tool call is a 100–300 +token generation at 8–15 tok/s). Fine as an offline fallback for "is this +open?"-class questions; the itinerary planner belongs on the server. + +## Build plan + +**Pre-v0 (mock, 1–2 days):** before backend work, a **static interactive +mock of the web UI** in the real frontend stack, with fake data behind the +same interface the API will implement: map + timeline rail + chat; vibe +chips; the **landing screen** ("Where would you like to go?" + free-form field ++ drop zone, incl. a mocked booking drag-in → parse → anchored plan); +suggestion cards with *simulated streaming enrichment* (image + +citation appearing after a delay); accept/reject diff animation; +like/rule-out reactions; **drag-reorder a stop and watch leg durations + +the day budget bar update live**; **L3 route editor (hike: draggable +trail with live duration; urban: multi-modal walk+bus leg)**; +**drop a (mocked) train ticket → parsed transit object pinned, day +re-anchors around it**; a stop card showing planned vs suggested time + +price; **issues panel with one mocked warning ("lunch 45 min, typically +~90 at this venue") and a one-click fix**; L0/L1 drill-down (mocked +data only). Purpose: +validate the interaction model, settle the visual design language, and +produce the API data contract by construction. The mock becomes the app shell — only the fake data layer +gets swapped for the real server. + +**v0 (days, all-hosted APIs):** chat + Leaflet + day timeline; the +**hierarchical skeleton from day one** (trip → place → days → stops, +start with one place); exploration loop: destination → OSM tag stats → +vibe chips → suggestion cards → `preferences`; single-day itinerary over +Nominatim (demo) + Overpass + GraphHopper public API + opening-hours lib; +QA mode + add/move/remove stop + validator. Validation: the fluid +"where would you like to go → vibe → place plan" flow works end-to-end for +one city. + +**v1 (local data + multi-place):** PostGIS + local GraphHopper + local +Nominatim for the target region; multi-day + **multi-place itineraries with +opaque `transit`** and junction validation; `plan_day` heuristic planner, +versioning/undo, semantic POI index (pgvector); TravelPlanner-style +constraint eval in CI. + +**v2 (multi-country + mobile on-trip):** per-place data loading for true +multi-country trips; external rail/air transit APIs; the mobile app with +today-view, leg navigation, **offline trip bundle** (tiles + itinerary + +stop data + hours), and offline-edit sync; alternatives UI (structured +option cards) in web; clarifying questions; "open on arrival" filters; MapQA ++ local eval in CI; optional VLM: send the *rendered* map view to a VLM +only when the user shares/asks about a screenshot; optional on-device 4B +for offline "is X open?". + +## Tech stack suggestion (aligns with your existing Go tooling) + +- **Go** service: agent loop, tools, HTTP API (chi), Postgres driver with + PostGIS, GraphHopper/OSRM client, `osm-opening-hours` equivalent + (or shelling to the Python lib / a small sidecar). +- **LLM**: any function-calling model; keep it behind one interface so the + provider is swappable (including a local model for offline use). +- **Frontend**: small SPA (or Go + templ + htmx if you want minimal) with + Leaflet. + +## References + +- MapQA — arxiv.org/abs/2503.07871 +- RAG architectures for POI QA — talks.osgeo.org/foss4g-2026/talk/VBGM3W +- MapBench (VLM map reading limits) — arxiv.org/abs/2503.14607 +- MapReason-OSM — arxiv.org/abs/2606.22597 +- LAMP: A Language Model on the Map — arxiv.org/abs/2403.09059 +- OSRM API — project-osrm.org/docs +- Overpass API — wiki.openstreetmap.org/wiki/Overpass_API +- GraphHopper — github.com/graphhopper/graph-hopper +- GROKE (OSM graph reasoning, ACL'26) — github.com/Geo-R2LLM/groke +- TravelPlanner (ICML'24 travel-planning benchmark) — github.com/OSU-NLP-Group/TravelPlanner +- Granite 4.2 (IBM open reasoning models, Aug 2026) — ibm.com/granite/docs/models/granite4-2 +- Qwen3-2507 refresh (Instruct/Thinking, 4B–235B) — github.com/QwenLM/Qwen3 +- TravelAgent (LLM travel planning, 2024) — jiangjiechen.github.io/publication/travelagent +- QuantCall (quantization effect on tool calling) — github.com/Happynood/quant-toolcall-bench diff --git a/SURVEY.md b/SURVEY.md new file mode 100644 index 0000000..3e0789e --- /dev/null +++ b/SURVEY.md @@ -0,0 +1,127 @@ +# Survey: existing open-source projects in the LLM + maps domain + +Compiled 2026-09-04. Categories are ordered by proximity to the target +system (a chat assistant answering map questions: routes/travel times, +businesses, opening hours, products/services). + +## 1. Closest matches: LLM tool layers over OpenStreetMap + +These provide *part* of the architecture (tools for an LLM), not a complete +product. This is the most active corner of the space. + +| Project | What it is | Notes | +|---|---|---| +| [geodaai/openassistant](https://github.com/geodaai/openassistant) | OpenAssistant (open-source LLM assistant framework) with an **OpenStreetMap tool plugin**: geocoding, reverse geocoding, routing, isochrone analysis | Framework + tools, no map-UI product; tools mix with other tools for multi-step tasks. Python. | +| [jagan-shanmugam/open-streetmap-mcp](https://github.com/jagan-shanmugam/open-streetmap-mcp) | **MCP server** exposing OSM to LLM clients: geocoding, POI search, routing | One of several near-identical MCP servers (2025+ wave). | +| [NERVsystems/osmmcp](https://github.com/NERVsystems/osmmcp) | OSM **MCP server, written in Go**: geocoding, routing, nearby places, neighborhood analysis, EV charging stations | Go implementation — reusable pieces for us; same "MCP server" pattern. | +| [visuelconcept/myosm-mcp-server](https://github.com/visuelconcept/myosm-mcp-server) | Another OSM MCP server (geocoding etc.) | | +| [IBM/chuk-mcp-geocoder](https://github.com/IBM/chuk-mcp-geocoder) | MCP geocoder: forward/reverse, batch, nearby places, route waypoints, admin boundaries | Useful geocoding reference. | +| [steveattewell/osm-ai-map](https://github.com/steveattewell/osm-ai-map) | Demonstrator: ChatGPT interprets natural language → **Overpass queries** → results on a map | Old (pre-function-calling era) but exactly the target interaction; demonstrates the pattern on a real map UI. | +| [Aravindak27/LLM-GIS-ASSISTANT](https://github.com/Aravindak27/LLM-GIS-ASSISTANT) | AI-driven GIS assistant; natural language → maps, climate graphs, spatial analysis (Open-Meteo, WorldPop, OSM) | Analyst-oriented, not POI/routing-focused. | +| [DBishal13/geospatial-data-copilot](https://github.com/DBishal13/geospatial-data-copilot) | Natural-language interface over spatial infrastructure data (SF utility poles etc.) | Niche dataset but same idea: NL → structured geospatial query. | + +## 2. GIS-workbench agents (QGIS plugins & the like) + +A crowded niche, all aimed at *analysts inside QGIS* rather than end-user +map questions. Still useful for prompting patterns (inject layer/CRS context +into the prompt, generate+execute code): + +- [opengeos/GeoAgent](https://github.com/opengeos/GeoAgent) — shared AI agent + layer for geospatial Python packages, map widgets, QGIS plugins. +- [xinguangYan/GeoPilot](https://github.com/xingguangYan/GeoPilot) — NL → + QGIS Python code. +- **GIS Chat for QGIS** (zenodo.org/records/18916276) — LLM chat panel in + QGIS, injects live workspace context. +- [gladcolor/LLM-Geo](https://github.com/gladcolor/LLM-Geo), + **IntelliGeo** (intelligeo.org), + [Teakinboyewa/SpatialAnalysisAgent](https://github.com/Teakinboyewa/SpatialAnalysisAgent), + [r-wenger/LLMFileDescribe](https://github.com/r-wenger/LLMFileDescribe). +- [iamtekson/GeoAgent](https://github.com/iamtekson/GeoAgent) (QGIS plugin, + distinct from opengeos/GeoAgent). + +**Takeaway:** nobody here targets "ask about a business / route / hours"; +they target geodata processing. + +## 3. Research code & benchmarks (evaluation assets) + +| Project | What it gives us | +|---|---| +| [knowledge-computing/MapQA-dataset](https://github.com/knowledge-computing/MapQA-dataset) | **MapQA**: 3,154 OSM QA pairs (SoCal, Illinois), 9 question types incl. routing & POI attributes; code for both a retrieval-based and an LLM-based approach. Our primary eval set. | +| [OSU-slatelab/MapQA](https://github.com/OSU-slatelab/MapQA) | *Different* "MapQA" — QA on US choropleth maps. Don't confuse. | +| [rohinmanvi/GeoLLM](https://github.com/rohinmanvi/GeoLLM) | ICLR'24: extract geospatial knowledge from LLMs using **auxiliary OSM data in prompts**; models + datasets. Validated that OSM context unlocks LLM geo-knowledge. | +| [Geo-R2LLM/groke](https://github.com/Geo-R2LLM/groke) | GROKE (ACL'26): vision-free, training-free hierarchical LLM reasoning **over the OSM graph** for navigation-instruction evaluation. | +| [makunyang/Geo-Loc](https://github.com/makunyang/Geo-Loc) | GeoKG-Loc: urban knowledge graph from OSM + LLM parsing of spatial constraints → coordinate estimation. | +| [MarcWeberFS/Text-to-sql](https://github.com/MarcWeberFS/Text-to-sql) | **Text-to-PostGIS**: NL → SQL for PostGIS with saved-query memory + benchmark. Direct alternative to (or complement of) the fixed tool catalog: let the LLM write PostGIS SQL. | +| [nl2sql-geospatial-benchmark](https://github.com/2023302141060/nl2sql-geospatial-benchmark) | 200-question NL2GeoSQL benchmark. | +| MapBench / MapReason-OSM / GeoLLM-OSM (papers) | VLM map-reading benchmarks; code scattered, mostly paper-driven. | +| [CityMind-Lab/Awesome-Location-Intelligence](https://github.com/CityMind-Lab/Awesome-Location-Intelligence) | Curated list of geospatial-representation-learning papers/datasets/code — good ongoing pointer. | + +## 4. The mature building blocks (everyone builds on these) + +- **Routing:** [OSRM](https://github.com/Project-OSRM/osrm-backend) (fast, + C++, table API), [Valhalla](https://github.com/valhalla/valhalla) + (multimodal, isochrones, matrix, elevation, TSP; open global server now + exists), [GraphHopper](https://github.com/graphhopper/graph-hopper) + (easiest to configure, car/bike/foot). +- **Geocoding:** [Nominatim](https://github.com/osmandapp/nominatim), + Photon (MeiliSearch-based, lighter). +- **Map data:** [Overpass API](https://github.com/danimw/overpass-api) + (query engine), `osm2pgsql` / [osmium-tool](https://github.com/osmcode/osmium-tool) + (ingest), Geofabrik extracts. +- **Opening hours:** `osm-opening-hours` (Python), `opening_hours` (JS), + `is-it-open-now` (JS) — the standard evaluators for the OSM hours language. +- **Frontend:** Leaflet / MapLibre GL, OSM tile servers (tileserver-gl). + +## 5. Travel-itinerary agents (prior art for the itinerary framing) + +All research-grade; none are OSM-native, none treat the itinerary as a +versioned state object, and their toolkits are synthetic sandboxes (or +Google Maps), not production geodata services: + +- **TravelPlanner** (OSU-NLP-Group/TravelPlanner, ICML'24) — benchmark: 1,225 + planning intents over ~4M records with a tool sandbox. The eval standard + for this task class. +- **TravelAgent** (jiangjiechen.github.io/publication/travelagent, 2024) — + LLM planning with four modules incl. itinerary generation & refinement; + paper-level. +- **AgentTravel** (OpenReview) — knowledge-augmented agent framework for + spatially-feasible itineraries. +- **Trip Weaver** (manolo-alvarez.github.io/projects/TripWeaver) — fine-tuned + small LLM for itineraries on consumer hardware. +- assorted LangGraph/LangChain multi-agent planner demos — thin, not robust. + +## 6. Gap analysis — what does *not* exist yet + +1. **No turnkey consumer product** "chat with your region's map": business + discovery + opening hours + routes in a chat/map UI. Everything is either + a demo, a tool server (MCP), or an analyst plugin. +2. **The MCP servers (2025 wave) are a signal**: the ecosystem standardized + on "OSM tools as MCP" but they are thin (hosted APIs, no local data, + no hours evaluation, no `along_route`, no semantic POI search, no eval). + We can *consume* osmmcp-style tools rather than rebuild them, or reuse + the Go code. +3. **Opening-hours-aware QA is underserved** — nearly no project treats + "open now / open when I arrive" as a first-class capability. +4. **Eval is research-only** — MapQA is a dataset; no project ships a CI + regression harness for map-QA answers. +5. **Hybrid structured + semantic POI retrieval** (PostGIS + vector index) + appears in the FOS4G 2026 comparison paper but no strong open-source + implementation of it. +6. **No production itinerary-refinement product over real OSM data** with a + deterministic feasibility validator and versioned diffs — the itinerary + agents above are benchmarks/papers, the map-QA projects are Q&A. + +## Implications for our design + +- **Reuse, don't rebuild:** routing/geocoding/hours libraries and even an + MCP server's tool set are off-the-shelf; our differentiators are (a) the + chat+map product, (b) hours-aware multi-hop tools (`along_route` + hours), + (c) hybrid retrieval, (d) evaluation. +- **Consider text-to-PostGIS as a second tool tier** (Text-to-sql / + PostGISer evidence): fixed tools for the common 90%, an LLM-generated + SQL escape hatch for the long tail, with a read-only DB role as the + safety boundary. +- **MapQA-dataset** is the natural benchmark to wire in from day one. +- **MCP as an interface option:** if we build our tool server in MCP form, + it works with Claude/Cursor/etc. for free *and* with our own agent — + low extra cost, high optionality. diff --git a/mock/app.js b/mock/app.js new file mode 100644 index 0000000..1e92cf2 --- /dev/null +++ b/mock/app.js @@ -0,0 +1,958 @@ +/* Interaction demo. All data is fake (data.js). */ +const M = window.MOCK; +const $ = s => document.querySelector(s); +const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; +const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0'); +const deep = o => JSON.parse(JSON.stringify(o)); + +// ---------------- state ---------------- +const days = {}; +M.days.forEach(d => days[d.id] = deep(d)); +let curDay = 'D1'; +let day = days[curDay]; +const stopById = id => day.stops.find(s => s.id === id); +const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id); +let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = []; +let map, l3 = null, scriptBusy = false; + +// ---------------- mobile: single pane + bottom tabs ---------------- +(function () { + const tabs = $('#mobiletabs'); if (!tabs) return; + const panes = { plan: $('#rail'), map: $('#mapwrap'), chat: $('#chat') }; + const setTab = t => { + Object.entries(panes).forEach(([k, e]) => e.classList.toggle('pane-hidden', k !== t)); + tabs.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.t === t)); + if (t === 'map') setTimeout(() => map.invalidateSize(), 80); + }; + tabs.querySelectorAll('button').forEach(b => b.onclick = () => setTab(b.dataset.t)); + if (window.matchMedia('(max-width: 860px)').matches) setTab('plan'); +})(); + +// ---------------- map ---------------- +let hotelMks = [], trainMks = []; +// stays, not a single hotel: each stay anchors the dates it covers. +// >1 candidate stay covering the same dates → worst-case anchoring for those dates. +// Multi-city trips = more stays, each anchoring its own dates (per-night granularity). +let stays = deep(M.trip.stays); +let staySeq = 1; +const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; // checkout day included +const staysForDay = (d = day) => stays.filter(s => covers(s, d.date)); +const dayBase = (d = day) => { + const list = staysForDay(d); + return list.find(s => s.state === 'booked') || list[0] || stays[0]; +}; +const hotelWalkMin = (at, d = day) => { + const list = staysForDay(d); + return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1; +}; +const stayRange = s => `${s.checkIn.slice(8)}–${s.checkOut.slice(8)} Sep`; + +function renderHotelMks() { + hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; + stays.forEach(o => { + const booked = o.state === 'booked'; + hotelMks.push(L.marker(o.at, { + icon: L.divIcon({ className: 'hotel-ico', html: `
🏨
`, iconSize: [0, 0] }) + }).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'option (worst-case anchor)'}`)); + }); +} + +function initMap() { + map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 14); + L.control.zoom({ position: 'bottomright' }).addTo(map); + L.tileLayer('/tiles/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19 + }).addTo(map); + renderHotelMks(); + // booked trains: origin + destination markers only, no route line + M.trip.bookings.filter(b => b.type === 'train').forEach(b => { + const mk = (pt, label) => L.marker(pt, { + icon: L.divIcon({ className: 'hotel-ico', html: '
🚄
', iconSize: [0, 0] }) + }).addTo(map).bindTooltip(label); + trainMks.push(mk(b.stations.from, `${b.name} · dep ${b.depart} · ${b.from}`)); + trainMks.push(mk(b.stations.to, `${b.name} · arr ${b.arrive} · ${b.to}`)); + }); +} +function flashMarker(mk, on) { + const e = mk.getElement()?.querySelector('.hotel-pin'); + if (e) e.style.transform = on ? 'translate(-50%,-90%) scale(1.35)' : 'translate(-50%,-90%)'; + if (on) mk.openTooltip(); else mk.closeTooltip(); + if (on && !map.getBounds().contains(mk.getLatLng())) map.flyTo(mk.getLatLng(), 15, { duration: .5 }); +} +const CONF_CLS = { 5: 't-user', 4: 't-sched', 3: 't-computed', 2: 't-search', 1: 't-llm' }; +function tchip(mins, conf, src) { + const e = el('span', 'tc ' + CONF_CLS[conf], fmt(mins) + ' min'); + if (src) e.title = 'source: ' + src; + return e; +} +const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌' }; + +// ---------------- layout / legs ---------------- +// every day ends by returning to the hotel anchor +function returnDur(d = day) { + if (d.isDeparture) return 0; // departure day: the train is the end of the day + const last = d.stops[d.stops.length - 1]; + if (!last) return 0; + const list = staysForDay(d); + // worst case across every candidate stay for these nights (equals the real walk when one) + return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60))); +} +function layout(d = day) { + let t = d.startMin; + d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; }); + return t + returnDur(d); +} +function rebuildLegs(d = day) { + d.legs = []; + for (let i = 0; i < d.stops.length - 1; i++) { + const a = d.stops[i], b = d.stops[i + 1]; + // a vague (region) stop at either end → worst-case leg until refined + if (a.kind === 'region' || b.kind === 'region') { + d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); + } else if (b.id === 'S5') { + d.legs.push({ mode: 'multimodal', dur: 18, conf: 3, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] }); + } else { + let h = 0; const key = a.name + '→' + b.name; + for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; + d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 3 }); + } + } +} + +// ---------------- vague / region placeholder stops ---------------- +const REGION_MAP = { + oltrarno: { label: 'Oltrarno', at: [43.7606, 11.2485], r: 520 }, + smn: { label: 'near SMN', at: [43.7731, 11.2563], r: 620 }, + duomo: { label: 'around the Duomo', at: [43.7731, 11.2556], r: 620 }, +}; +let nextStopId = 30; +function addRegionStop(slot, region) { + const existing = day.stops.find(x => x.slot === slot && x.state === 'planned'); + const stop = { id: 'S' + (nextStopId++), + name: `${slot[0].toUpperCase() + slot.slice(1)} — somewhere ${region.label}`, + kind: 'region', mealKind: slot, slot, state: 'planned', + at: region.at, region: { ...region }, + dur: slot === 'breakfast' ? 30 : 90, durConf: 1, + suggested: { value: slot === 'breakfast' ? 30 : 90, conf: 1, src: null } }; + if (existing) { existing.state = 'alt'; day.stops[day.stops.indexOf(existing)] = stop; } + else day.stops.push(stop); + rebuildLegs(); renderRail([stop.id]); renderMap(); + ai(`Placed a placeholder for ${slot} ${region.label} — it holds the slot and reserves time; travel times are worst-case until you pick a specific place. Say “find a ${slot} ${region.label}” or tap the card to refine.`, 900); +} +function renderMap() { + Object.values(markers).forEach(m => map.removeLayer(m)); markers = {}; + Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {}; + otherLayers.forEach(l => map.removeLayer(l)); otherLayers = []; + retLine = null; retLines = []; + // every day, faintly — the whole trip is always visible + M.days.forEach(d => { + const obj = days[d.id]; + if (!obj.stops.length) return; + if (d.id !== curDay) { + const pts = obj.stops.map(s => s.at); + for (let i = 0; i < pts.length - 1; i++) + otherLayers.push(L.polyline([pts[i], pts[i + 1]], { color: '#b6bcc8', weight: 3, opacity: .8, dashArray: '4 7' }).addTo(map)); + if (pts.length && !obj.isDeparture) + staysForDay(obj).forEach(o => + otherLayers.push(L.polyline([pts[pts.length - 1], o.at], { color: '#c8cdd6', weight: 2.5, opacity: .8, dashArray: '2 6' }).addTo(map))); + obj.stops.forEach(s => otherLayers.push(L.circleMarker(s.at, { radius: 5, color: '#9aa3b2', weight: 2, fillColor: '#fff', fillOpacity: 1 }) + .addTo(map).bindTooltip(`${d.label} · ${s.name}`))); + return; + } + const pts = obj.stops.map(s => s.at); + for (let i = 0; i < pts.length - 1; i++) + legEls[i] = L.polyline([pts[i], pts[i + 1]], { + color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', + weight: 5, opacity: .85 + }).addTo(map); + if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) + staysForDay(obj).forEach(o => { + retLine = L.polyline([pts[pts.length - 1], o.at], { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map); + otherLayers.push(retLine); + retLines.push(retLine); + }); + } + obj.stops.forEach((s, i) => { + const dim = s.state !== 'planned'; + markers[s.id] = L.marker(s.at, { + icon: L.divIcon({ className: '', html: dim + ? `
${i + 1}
` + : `
${i + 1}
`, + iconSize: [26, 26], iconAnchor: [13, 13] }) + }).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + s.state : ''}`); + if (s.region) // vague stop: show the search area, not a point + otherLayers.push(L.circle(s.region.at, { radius: s.region.r, color: '#b5533c', weight: 2, dashArray: '6 6', fillColor: '#b5533c', fillOpacity: .08 }).addTo(map)); + }); + }); + const cur = days[curDay]; + if (cur.stops.length) { + const pad = matchMedia('(max-width: 860px)').matches + ? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width + : { paddingTopLeft: [370, 70], paddingBottomRight: [410, 70] }; + map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...cur.stops.map(s => s.at)]), pad); + } +} +function highlightStop(id, on) { + const m = markers[id]; if (!m) return; + m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : ''); + if (on) m.openTooltip(); else m.closeTooltip(); +} +// pulse in place; only reposition the map if the point is out of view +function focusPoint(latlng, zoom = 16) { + if (!map.getBounds().contains(latlng)) map.flyTo(latlng, Math.max(map.getZoom(), zoom), { duration: .5 }); + const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map); + setTimeout(() => map.removeLayer(ring), 1500); +} +const flyTo = s => focusPoint(s.at); +function tempShow(s) { + if (stopById(s.id)) return; + const m = L.circleMarker(s.at, { radius: 8, color: '#5b6472', weight: 2, fillColor: '#8b93a3', fillOpacity: .6 }) + .addTo(map).bindTooltip(s.name); + tempMarkers.push(m); + m.openTooltip(); + if (!map.getBounds().contains(s.at)) map.flyTo(s.at, Math.max(map.getZoom(), 15), { duration: .4 }); +} +function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; } + +// ---------------- rail ---------------- +function renderRail(flashIds = []) { + layout(); // assign start times before the cards render them + const body = $('#rail-body'); body.innerHTML = ''; + day.stops.forEach((s, i) => { + if (s.state === 'alt') body.append(el('div', 'or-row', 'or')); + const c = el('div', 'stop-card st-' + s.state); + if (flashIds.includes(s.id)) c.classList.add('flash'); + const top = el('div', 'sc-top'); + top.append(el('span', 'num', String(i + 1))); + if (s.kind === 'transit') { + // booked external transport: fixed, shown only on its own day + top.append(el('span', 'sc-name', `${s.transit.name} → ${s.transit.to}`)); + top.append(el('span', 'sc-times', `${s.transit.arriveBy}–${s.transit.depart}`)); + c.append(top); + const meta = el('div', 'sc-meta'); + meta.append(el('span', 'tc t-sched', 'booked')); + meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`)); + c.append(meta); + c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy} — ${s.transit.buffer}`)); + c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true)); + c.addEventListener('mouseleave', () => flashMarker(trainMks[0], false)); + c.addEventListener('click', () => focusPoint(s.at)); + body.append(c); + return; + } + // drag & drop reordering within the day + c.draggable = true; + c.addEventListener('dragstart', e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); c.classList.add('dragging'); }); + c.addEventListener('dragend', () => c.classList.remove('dragging')); + c.addEventListener('dragover', e => e.preventDefault()); + c.addEventListener('drop', e => { + e.preventDefault(); + const from = parseInt(e.dataTransfer.getData('text/plain'), 10); + if (isNaN(from) || from === i) return; + const [moved] = day.stops.splice(from, 1); + day.stops.splice(i, 0, moved); + rebuildLegs(); + renderRail([moved.id]); renderMap(); + }); + top.append(el('span', 'grip', '⠿')); + top.append(el('span', 'sc-name', s.name)); + top.append(el('span', 'sc-times', `${fmt(s.start)}–${fmt(s.start + s.dur)}`)); + c.append(top); + const meta = el('div', 'sc-meta'); + const stateChip = el('span', 'tc t-' + s.state + ' sc-state', s.state); + stateChip.title = 'Change state'; + stateChip.onclick = e => { e.stopPropagation(); openStateMenu(s, stateChip); }; + meta.append(stateChip); + meta.append(tchip(s.dur, s.durConf)); + if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned') + meta.append(el('span', 'sug-note', `sug. ~${fmt(s.suggested.value)}`)); + if (s.price) meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`)); + c.append(meta); + if (s.region) { + c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined')); + const rf = el('button', 'refine-btn', 'Find a specific place →'); + rf.onclick = e => { e.stopPropagation(); enterFocus(s.slot, s.region); }; + c.append(rf); + } + c.addEventListener('mouseenter', () => highlightStop(s.id, true)); + c.addEventListener('mouseleave', () => highlightStop(s.id, false)); + c.addEventListener('click', e => { if (!e.target.closest('.sc-state, .pop')) flyTo(s); }); + body.append(c); + if (day.legs[i]) { + const g = day.legs[i]; + const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : '')); + lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•')); + if (g.sub) lr.append(el('span', 'leg-sub', g.sub.map(x => `${MODE_ICO[x.mode]} ${x.dur}′`).join(' + '))); + if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined')); + lr.append(document.createTextNode(' ')); + lr.append(tchip(g.dur, g.conf)); + const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', weight: 5, opacity: .85 }; + lr.addEventListener('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 })); + lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base)); + lr.onclick = () => openL3(i); + body.append(lr); + } + }); + if (day.stops.length && !day.isDeparture) { + const multi = staysForDay().length > 1; + const ret = el('div', 'leg-row', `🏨back to hotel `); + ret.append(tchip(returnDur(), multi ? 1 : 3)); + if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`)); + const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null }; + const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }; + ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on))); + ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off))); + body.append(ret); + } + renderBudget(); renderIssues(); +} + +// state menu (planned ⇄ maybe ⇄ alt, move day, remove) +function openStateMenu(s, anchor) { + closeMenus(); + const menu = el('div', 'pop'); + const mk = (label, fn, danger) => { const b = el('button', danger ? 'danger' : '', label); b.onclick = e => { e.stopPropagation(); closeMenus(); fn(); }; menu.append(b); }; + if (s.state === 'planned') { + mk('→ Maybe', () => setState(s, 'maybe')); + mk('→ Alternative', () => setState(s, 'alt')); + } else if (s.state === 'maybe') { + mk('→ Planned', () => setState(s, 'planned')); + mk('→ Alternative', () => setState(s, 'alt')); + } else { + mk('→ Planned (swap in)', () => setState(s, 'planned')); + mk('→ Maybe', () => setState(s, 'maybe')); + } + M.days.filter(d => d.id !== day.id).forEach(d => { + mk(`Move to ${d.label}`, () => moveStopToDay(s, d.id)); + }); + mk('Remove from day', () => { + const slot = s.slot; + day.stops.splice(day.stops.indexOf(s), 1); + let promoted = null; + if (!day.stops.some(x => x.slot === slot && x.state === 'planned')) { + promoted = day.stops.find(x => x.slot === slot && x.state === 'alt'); + if (promoted) promoted.state = 'planned'; // last candidate in the slot becomes the plan + } + rebuildLegs(); renderRail(); renderMap(); + ai(`Removed ${s.name} from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so ${promoted.name} is now your ${slot}.` : '.'}`, 500); + }, true); + anchor.parentElement.parentElement.append(menu); // card is position:relative +} +function closeMenus() { document.querySelectorAll('.pop').forEach(p => p.remove()); } +document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMenus(); }); + +function setState(s, state) { + if (state === 'planned' && s.state !== 'planned') { + const cur = day.stops.find(x => x.slot === s.slot && x.state === 'planned' && x !== s); + if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} is the alternative.`, 600); } + } + s.state = state; + rebuildLegs(); renderRail([s.id]); renderMap(); + if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt'))) + ai(`${s.name} is now “${state}”.`, 400); +} +function moveStopToDay(s, targetId) { + day.stops.splice(day.stops.indexOf(s), 1); + const t = days[targetId]; + t.stops.push(s); + rebuildLegs(); if (t !== day) rebuildLegs(t); + renderRail(); renderMap(); + ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500); +} +function dayLabel() { return M.days.find(d => d.id === day.id).label; } + +function renderBudget() { + const prim = day.stops.filter(s => s.state === 'planned'); + const flex = day.stops.length - prim.length; + const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0) + + (prim.length ? returnDur() : 0); + const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0); + const end = layout(); + const cap = day.wakingHours * 60; + const pct = Math.min(100, planned / cap * 100); + const fill = $('#budget-fill'); + fill.style.width = pct + '%'; + fill.classList.toggle('warn', pct > 92); + $('#budget-text').textContent = `${(planned / 60).toFixed(1)} h planned${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h flex' : ''} · ${day.wakingHours} h waking (ends ${fmt(end)})`; +} + +// ---------------- issues ---------------- +const issuesByDay = { + D1: [ + { id: 'I1', kind: 'warn', fixed: false, + title: 'Lunch may be too short', + body: '45 min at Trattoria Mario — a meal there typically runs ~90 min (web), plus the local queue.', + fix: 'Extend to ~90 min' }, + { id: 'I2', kind: 'info', fixed: false, + title: 'Pitti: 1 h (you) vs ~2.5 h suggested', + body: 'Guides suggest 2–3 h for the full complex. Kept at your 1 h.', + fix: 'Keep as is' } + ], + D2: [], D3: [ + { id: 'I3', kind: 'info', fixed: false, + title: 'Dinner is “maybe”', + body: 'Il Latte is still an option, not a plan — decide by this evening so there’s a fallback if it’s full.', + fix: 'Keep as is' } + ] +}; +let showResolved = false; +function renderIssues() { + const list = issuesByDay[day.id] || []; + const p = $('#issues-panel'); p.innerHTML = ''; + const active = list.filter(i => !i.fixed); + const resolved = list.filter(i => i.fixed); + $('#issue-badge').classList.toggle('ok', !active.length); + $('#issue-badge').innerHTML = active.length ? `⚠ ${active.length} issue${active.length > 1 ? 's' : ''}` : '✓ plan checks out'; + [...(showResolved ? list : active)].forEach(it => { + const d = el('div', 'issue' + (it.fixed ? ' fixed' : '')); + d.append(el('div', 'it', it.title)); + d.append(el('div', null, it.body)); + if (!it.fixed) { + const row = el('div', 'fix'); + const b = el('button', 'btn ghost small', it.fix); + b.onclick = () => { + it.fixed = true; + if (it.id === 'I1' && stopById('S4')) { stopById('S4').dur = 90; renderRail(['S4']); renderMap(); ai('Lunch extended to 90 min — everything after it slides later; the day still fits.', 300); } + renderIssues(); + }; + row.append(b); d.append(row); + } else d.append(el('div', 'fix', '✓ resolved')); + p.append(d); + }); + if (resolved.length) { + const t = el('button', 'resolved-toggle', showResolved ? 'Hide resolved' : `Show resolved (${resolved.length})`); + t.onclick = () => { showResolved = !showResolved; renderIssues(); }; + p.append(t); + } +} + +// ---------------- L3 route editor ---------------- +function haversine(a, b) { + const R = 6371e3, toR = d => d * Math.PI / 180; + const dLat = toR(b[0] - a[0]), dLon = toR(b[1] - a[1]); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(toR(a[0])) * Math.cos(toR(b[0])) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(h)); +} +function openL3(legIdx) { + closeL3(); + const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at; + const leg = day.legs[legIdx]; + const mid = [(a[0] + b[0]) / 2 + 0.0012, (a[1] + b[1]) / 2 - 0.0018]; + const layer = L.layerGroup().addTo(map); + const line = L.polyline([a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).addTo(layer); + const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '
', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer); + const m1 = mk(a), m2 = mk(mid), m3 = mk(b); + const box = $('#l3-editor .l3-time'); + const upd = () => { + const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng(); + line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]); + const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]); + leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // 4.8 km/h walking + box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3)); + renderBudget(); + }; + [m1, m2, m3].forEach(m => m.on('drag', upd)); + $('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`; + box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3)); + $('#l3-editor').classList.remove('hidden'); + map.fitBounds(L.latLngBounds([a, b]).pad(0.4)); + l3 = { layer }; + const obs = setInterval(() => { + if (!$('#l3-editor').classList.contains('hidden')) return; + clearInterval(obs); + renderRail(); renderMap(); + }, 250); +} +function closeL3() { + if (l3) { map.removeLayer(l3.layer); l3 = null; } + $('#l3-editor').classList.add('hidden'); +} +$('#l3-close').onclick = closeL3; + +// ---------------- focus mode (slot search, Google-Maps-style) ---------------- +const excluded = new Set(); // the exclusion list — survives across searches +let focus = null; // {slot, results} +let candMks = {}; + +function isExcluded(c) { return [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase())); } + +function enterFocus(slot, region) { + if (focus) exitFocus(); + let pool = (MOCK.candidates[slot] || []).filter(c => !isExcluded(c)); + if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r); + const gone = (MOCK.candidates[slot] || []).length - pool.length; + focus = { slot, region: region || null, results: pool }; + $('#rail').classList.add('dimmed'); + $('#focusbar').classList.remove('hidden'); + renderFocus(); + ai(`Here are ${pool.length} ${slot === 'visit' ? 'afternoon ideas' : slot + ' options'}${region ? ` in ${region.label}` : ''} on the map${gone ? ` — ${gone} outside the area or excluded` : ''}. Click a pin to open it; keep talking to filter: “cheaper”, “views”, “no <name>”.`, 700); +} +function exitFocus(note) { + if (!focus) return; + Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; + focus = null; + $('#rail').classList.remove('dimmed'); + $('#focusbar').classList.add('hidden'); + if (note) ai(note, 400); +} +const WALK_KMH = 4.6; +function walkMin(from, to) { return Math.max(1, Math.round(haversine(from, to) / 1000 / (WALK_KMH / 60))); } +// where the user is when they make this choice: the stop right before the slot, else the last planned stop, else the hotel +function focusAnchor() { + const idx = day.stops.findIndex(x => x.slot === focus.slot); + if (idx > 0) return day.stops[idx - 1]; + if (idx < 0) { + const planned = day.stops.filter(x => x.state === 'planned'); + if (planned.length) return planned[planned.length - 1]; + } + return { name: dayBase().name, at: dayBase().at }; +} + +function renderFocus() { + const label = focus.slot === 'visit' ? 'afternoon' : focus.slot; + $('#focusbar-title').innerHTML = `🔍 ${label}${focus.region ? ' · ' + focus.region.label : ''} · ${dayLabel()} — ${focus.results.length} option${focus.results.length !== 1 ? 's' : ''}${excluded.size ? ` · ${excluded.size} excluded` : ''}`; + Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; + const anchor = focusAnchor(); + const fromHotel = anchor.name !== dayBase().name; + const isHotel = focus.slot === 'hotel'; + focus.results.forEach(c => { + const dist = `↦ ${walkMin(anchor.at, c.at)} min walk from ${anchor.name}` + + (fromHotel ? `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}` : ''); + const html = `
+
${c.name}
+
${c.pitch}
+
${dist}
+
${c.dur ? `~${fmt(c.dur)}` : ''}${c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'}
+
+ + +
+
`; + const mk = L.marker(c.at, { + icon: L.divIcon({ className: '', html: `
${c.name}
`, + iconSize: [26, 36], iconAnchor: [13, 34] }) + }).addTo(map); + mk.bindPopup(html, { maxWidth: 280, closeButton: true }); + mk.on('popupopen', e => { + const p = e.popup.getElement(); + p.querySelector('.fp-choose').onclick = () => chooseCand(c); + p.querySelector('.fp-maybe').onclick = () => maybeCand(c); + }); + candMks[c.id] = mk; + }); +} +function makeStop(c, state) { + return { id: c.id, name: c.name, kind: focus.slot === 'visit' ? 'visit' : 'meal', + mealKind: focus.slot === 'visit' ? undefined : focus.slot, slot: focus.slot, state, + at: c.at, dur: c.dur, durConf: 3, + suggested: { value: c.dur, conf: 2, src: 'tripadvisor.com' }, + price: c.price ? { amount: c.price, cur: '€', conf: 2, src: 'tripadvisor.com' } : null, + img: c.img, url: c.url }; +} +function chooseCand(c) { + if (focus.slot === 'hotel') { + const cur = dayBase(); + const inRange = s => s.checkIn <= cur.checkOut && s.checkOut >= cur.checkIn; // same nights + stays = stays.filter(s => !inRange(s)); + stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, + checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price }); + renderHotelMks(); renderBaseChip(); + exitFocus(); renderRail(); renderMap(); + ai(`Now staying at ${c.name} for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700); + return; + } + const slot = focus.slot; + const loose = day.stops.find(x => x.slot === slot && x.kind === 'region'); + const existing = loose || day.stops.find(x => x.slot === slot && x.state === 'planned'); + const stop = makeStop(c, 'planned'); + if (existing) { + if (existing.kind !== 'region') existing.state = 'alt'; // a placeholder is replaced, not demoted + day.stops[day.stops.indexOf(existing)] = stop; + } else day.stops.push(stop); + rebuildLegs(); + exitFocus(); + renderRail([stop.id]); renderMap(); + ai(loose + ? `Refined: ${c.name} is your ${slot} now — the placeholder is gone and the leg times are real.` + : `Locked in ${c.name} for ${dayLabel()}${existing ? ` — ${existing.name} drops to alternative: one ${slot}, one plan.` : '. See it in the day.'}`, 600); +} +function maybeCand(c) { + if (focus.slot === 'hotel') { + if (stays.some(o => o.name === c.name)) { exitFocus(); return; } + const cur = dayBase(); + stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, + checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price }); + renderHotelMks(); renderBaseChip(); + exitFocus(); renderRail(); renderMap(); + ai(`Keeping ${c.name} open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800); + return; + } + const loose = day.stops.find(x => x.slot === focus.slot && x.kind === 'region'); + if (loose) { + const stop = makeStop(c, 'maybe'); + day.stops[day.stops.indexOf(loose)] = stop; + rebuildLegs(); exitFocus(); renderRail([stop.id]); renderMap(); + ai(`Holding ${c.name} as a maybe — the placeholder became a specific option, off the time budget.`, 600); + return; + } + if (day.stops.some(x => x.id === c.id)) { exitFocus(); return; } + day.stops.push(makeStop(c, 'maybe')); + rebuildLegs(); + exitFocus(); + renderRail([c.id]); renderMap(); + ai(`Holding ${c.name} as an option — dashed, off the time budget.`, 500); +} +function focusFilter(text) { + const t = text.toLowerCase(); + let res = [...focus.results]; + const notes = []; + if (/cheaper|cheap|less|budget|under/.test(t)) { + const m = t.match(/under\s*€?(\d+)/); + const cap = m ? +m[1] : (focus.slot === 'hotel' ? 220 : 25); // “cheaper” means different things per slot + res = res.filter(c => c.price <= cap); + notes.push(`≤ €${cap}`); + } + if (/boboli/.test(t)) { res = res.filter(c => haversine(c.at, [43.7579, 11.2463]) < 1200); notes.push('near Boboli'); } + if (/pitti|accademia/.test(t)) { res = res.filter(c => haversine(c.at, [43.7639, 11.2518]) < 900); notes.push('near the sights you have planned'); } + if (/views|terrace|rooftop/.test(t)) { res = res.filter(c => c.tags.some(x => /view|terrace/.test(x))); notes.push('views'); } + if (/veg/.test(t)) { res = res.filter(c => c.tags.some(x => /veg|garden/.test(x))); notes.push('vegetarian-friendly'); } + if (/quiet|calm|cozy|cellar/.test(t)) { res = res.filter(c => c.tags.some(x => /quiet|calm|cozy|cellar/.test(x))); notes.push('quieter'); } + if (/free/.test(t)) { res = res.filter(c => !c.price); notes.push('free'); } + if (/fast|quick|casual/.test(t)) { res = res.filter(c => c.tags.some(x => /fast|casual/.test(x))); notes.push('fast & casual'); } + const noM = text.match(/\b(?:no|not|without|skip)\s+([a-zàé'. ]{3,30})/i); + if (noM) { + const nm = noM[1].trim(); + const hit = focus.results.find(c => c.name.toLowerCase().includes(nm)); + if (hit) { excluded.add(hit.name); res = res.filter(c => c !== hit); notes.push(`excluded ${hit.name}`); } + } + focus.results = res; + renderFocus(); + if (res.length) { + const a = focusAnchor(); + const closest = res.reduce((m, c) => walkMin(a.at, c.at) < walkMin(a.at, m.at) ? c : m, res[0]); + ai(`${res.length} left${notes.length ? ' — ' + notes.join(', ') : ''}. Closest to ${a.name}: ${closest.name} (${walkMin(a.at, closest.at)} min walk). Keep filtering or click a pin.`, 600); + } else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600); +} +$('#focusbar-x').onclick = () => exitFocus('Back to the plan.'); +document.addEventListener('keydown', e => { if (e.key === 'Escape') exitFocus(); }); + +// ---------------- day tabs ---------------- +function buildDayTabs() { + const tabs = $('#daytabs'); tabs.innerHTML = ''; + M.days.forEach(d => { + const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label); + t.onclick = () => setDay(d.id); + t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); }); + t.addEventListener('dragleave', () => t.classList.remove('over')); + t.addEventListener('drop', e => { + e.preventDefault(); t.classList.remove('over'); + const i = parseInt(e.dataTransfer.getData('text/plain'), 10); + const s = day.stops[i]; if (!s) return; + day.stops.splice(i, 1); + const tDay = days[d.id]; + tDay.stops.push(s); + rebuildLegs(); if (tDay !== day) rebuildLegs(tDay); + setDay(curDay); + ai(`Moved ${s.name} to ${tDay.label}.`, 400); + }); + tabs.append(t); + }); +} +function setDay(id) { + curDay = id; day = days[id]; + if (!day.legs.length) rebuildLegs(day); + document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', M.days[i] && M.days[i].id === id)); + closeL3(); // a selected route from another day is stale + tempClear(); + renderRail(); renderMap(); +} + +// ---------------- chat ---------------- +function msg(role, html) { + const m = el('div', 'msg ' + role, html); + $('#chat-body').append(m); + m.scrollIntoView({ behavior: 'smooth', block: 'end' }); + return m; +} +function typing() { return msg('ai', ''); } +function ai(html, delay = 700) { + const t = typing(); + setTimeout(() => { t.remove(); msg('ai', html); scriptBusy = false; }, delay); +} +function chatSend() { + const v = $('#chat-input').value.trim(); + if (!v) return; + $('#chat-input').value = ''; + msg('user', v); + handleUser(v); +} +function handleUser(v) { + const s = v.toLowerCase(); + if (focus) { + if (/reset/.test(s)) { excluded.clear(); enterFocus(focus.slot, focus.region); return; } + if (/done|exit|never ?mind|back to/.test(s)) { exitFocus('Back to the plan.'); return; } + focusFilter(v); + return; + } + if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { enterFocus('hotel'); return; } + const slotWord = s.match(/(lunch|dinner|breakfast|snack)/); + const regWord = s.match(/\b(oltrarno|smn|duomo|city centre)\b/); + if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → refine + if (/find|looking|search|options/.test(s)) enterFocus(slotWord[1], REGION_MAP[regWord[1]]); + else addRegionStop(slotWord[1], REGION_MAP[regWord[1]]); + return; + } + if (/find|looking|want|search|options|another|different/.test(s) && slotWord) { enterFocus(slotWord[1]); return; } + if (/find|looking|want|search|options|another|different/.test(s) && /activit|afternoon|things to do/.test(s)) { enterFocus('visit'); return; } + if (s.includes('later') || s.includes('morning')) { + day.startMin += 30; + renderRail(day.stops.map(x => x.id)); renderMap(); + ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at ${fmt(layout())}.`); + } else if (s.includes('lunch') || s.includes('longer')) { + const it = (issuesByDay[day.id] || [])[0]; + const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned'); + if (it && !it.fixed && lunch) { + it.fixed = true; lunch.dur = 90; + renderRail([lunch.id]); renderMap(); renderIssues(); + ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`); + } else ai(`There’s no time issue on ${dayLabel()} right now — the issues panel is clear.`); + } else { + ai('I can shift times, swap stops, move things between days, or re-route a leg — e.g. “start the day later” or “make lunch longer”. You can also drag stops by the ⠿ grip, drop one on a day tab, or click a leg to edit its route.'); + } +} +$('#chat-send').onclick = chatSend; +$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend()); + +// ---------------- landing → parse → plan ---------------- +const SAMPLE = [ + ['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'], + ['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'], + ['dates', '12 – 15 Sep 2026', '3 nights', 'hi'], + ['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'], + ['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid'] +]; +function showParse() { + const t = $('#parse-table'); t.innerHTML = ''; + SAMPLE.forEach(([k, v, note, c]) => { + const tr = document.createElement('tr'); + tr.innerHTML = `${k}${v}${c === 'hi' ? 'confirmed' : 'verify'}
${note}
`; + t.append(tr); + }); + $('#parse-modal').classList.remove('hidden'); +} +$('#parse-cancel').onclick = () => $('#parse-modal').classList.add('hidden'); +$('#parse-confirm').onclick = startApp; + +// ?autostart=plan|map|chat — skips the landing screen (dev/testing) +const auto = new URLSearchParams(location.search).get('autostart'); +if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120); + +function startApp() { + $('#parse-modal').classList.add('hidden'); + $('#landing').classList.add('hidden'); + $('#topbar').classList.remove('hidden'); + $('#app').classList.remove('hidden'); + renderBaseChip(); + buildDayTabs(); + if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint + renderRail(); renderMap(); + script1(); +} + +// ---------------- detail sheet ---------------- +// the hotel chip: reflects the (possibly multiple) open base options +function renderBaseChip() { + const tb = $('#transit-block'); tb.innerHTML = ''; + const b = dayBase(); + const n = staysForDay().length; + const h = el('div', 'transit-chip', `🏨${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'}base`); + h.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true))); + h.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false))); + tb.append(h); +} + +function openDetail(s, actions) { + const d = s.detail; if (!d) return; + const oldM = document.getElementById('detail-modal'); if (oldM) oldM.remove(); + const modal = el('div', 'modal'); modal.id = 'detail-modal'; + const card = el('div', 'modal-card wide'); + const img = el('div', 'd-img'); + img.style.background = s.img[2]; + if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; img.append(im); } + else img.append(el('div', 'd-emoji', s.img[0])); + img.append(el('div', 'd-attr', s.img[1] + ' · Wikimedia Commons')); + const body = el('div', 'd-body'); + const t = el('div', 'd-title'); + const a = el('a', null, s.name); a.href = s.url; a.target = 'blank'; a.rel = 'noopener'; + t.append(a, el('span', 'd-kind', s.kind)); + body.append(t); + const facts = el('div', 'd-facts'); + facts.append( + el('span', 'tc ' + CONF_CLS[s.durConf], 'planned ' + fmt(s.dur)), + el('span', 'tc t-search', 'typically ' + fmt(s.suggested.value)), + s.price ? el('span', 'pchip', '~' + s.price.amount + s.price.cur + (s.mealKind ? ' / person' : '')) : null + ); + body.append(facts); + body.append(el('div', 'd-summary', d.summary)); + const links = el('div', 'd-links'); + d.links.forEach(l => { + const b = el('a', 'd-link', l.label); b.href = l.href; b.target = 'blank'; b.rel = 'noopener'; + links.append(b); + }); + body.append(links); + if (actions) { + const foot = el('div', 'd-actions'); + const a2 = el('button', 'btn primary small', 'Add to day'); a2.onclick = actions.add; + const m2 = el('button', 'btn ghost small', 'Maybe'); m2.onclick = actions.maybe; + const p2 = el('button', 'btn ghost small', 'Not for me'); p2.onclick = actions.pass; + foot.append(a2, m2, p2); + actions.register([a2, m2, p2]); + body.append(foot); + } + const close = el('button', 'd-close', '✕'); + close.onclick = () => modal.remove(); + card.append(img, body, close); + modal.append(card); + modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); + document.body.append(modal); +} + +// ---------------- adding stops (planned / maybe / alt) ---------------- +function addStopToDay(id, requested) { + const src = allStops(id); + const copy = deep(src); + const hasPrimary = day.stops.some(x => x.slot === copy.slot && x.state === 'planned'); + let state = requested || (hasPrimary ? 'alt' : 'planned'); + if (state === 'planned') { + const cur = day.stops.find(x => x.slot === copy.slot && x.state === 'planned'); + if (cur) cur.state = 'alt'; + } + copy.state = state; + day.stops.push(copy); + rebuildLegs(); + renderRail([id]); renderMap(); + return state; +} + +function script1() { + setTimeout(() => { + scriptBusy = true; + ai('Anchored your stay — Hotel Palagio, 12–15 Sep; the 15th’s train (09:12) is pinned as well. What kind of trip are you after?', 900); + setTimeout(() => { + const m = msg('ai', 'Pick a vibe — or tell me in your own words:'); + const chips = el('div', 'chips'); + M.vibes.forEach(v => { + const c = el('button', 'chip', v.label); + c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(); }; + chips.append(c); + }); + m.append(chips); + }, 1800); + }, 300); +} +function script2() { + scriptBusy = true; + ai('Noted — relaxed, food-first. A few ideas for your first day:', 900); + setTimeout(() => { + const m = msg('ai', ''); + const cards = el('div', 'cards'); + m.append(cards); + let sequenced = false; + M.suggestions.forEach((sg, i) => { + const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop); + const card = el('div', 'scard'); + const imgBox = el('div', 'scard-img'); + imgBox.style.background = s.img[2]; + const skel = el('div', 'skel'); + const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); }; + imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons')); + if (s.img[3]) { + const im = document.createElement('img'); + im.src = s.img[3]; im.alt = s.img[1]; + im.onload = ready; im.onerror = ready; + imgBox.append(im); + } else { + imgBox.append(el('div', 'ph', s.img[0])); + setTimeout(ready, 1400 + i * 500); + } + const body = el('div', 'scard-body'); + const title = el('div', 'scard-name'); + const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener'; + title.append(tl); + body.append(title); + body.append(el('div', 'scard-pitch', sg.pitch)); + const foot = el('div', 'scard-foot'); + const add = el('button', 'btn primary small', 'Add to day'); + const maybeB = el('button', 'btn ghost small', 'Maybe'); + const pass = el('button', 'btn ghost small', 'Not for me'); + const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : ''); + const st = { done: false }; + const sheetBtns = []; + const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; }); + const doAdd = () => { if (st.done) return; st.done = true; + card.classList.add('added'); + finish(['Added ✓', '', '', 'Added ✓', '', '']); + const state = addStopToDay(s.id, null); + if (state === 'alt') ai(`${s.name} is in as the alternative for your ${s.slot} — one slot, one plan. Swap them any time.`, 500); + else ai(`Added ${s.name} — I’ll sequence the day once the set is done.`, 500); + if (!sequenced) { sequenced = true; setTimeout(script3, 1500); } + }; + const doMaybe = () => { if (st.done) return; st.done = true; + card.classList.add('maybe-card'); + finish(['', 'Holding', '', '', 'Holding', '']); + addStopToDay(s.id, 'maybe'); + ai(`Holding ${s.name} as an option — dashed on the day, not counted against the time budget.`, 500); + if (!sequenced) { sequenced = true; setTimeout(script3, 1500); } + }; + const doPass = () => { if (st.done) return; st.done = true; + card.classList.add('added', 'dropped'); + finish(['', '', 'Ruled out', '', '', 'Ruled out']); + cards.appendChild(card); + const dm = document.getElementById('detail-modal'); if (dm) dm.remove(); + excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches + ai(`Ruled out ${s.name} — noted as a preference.`, 500); + }; + add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass; + card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } }); + card.addEventListener('mouseenter', () => tempShow(s)); + card.addEventListener('mouseleave', tempClear); + foot.append(add, maybeB, pass, pc); + body.append(foot); + const en = el('div', 'scard-enrich', sg.enrich); + body.append(en); + card.append(imgBox, body); + cards.append(card); + setTimeout(() => en.classList.add('show'), 2600 + i * 500); + }); + m.scrollIntoView({ behavior: 'smooth' }); + }, 1900); +} +function script3() { + setTimeout(() => { + const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 }; + day.stops.sort((a, b) => (rank[a.id] ?? 99) - (rank[b.id] ?? 99)); + if (!stopById('S6')) { + const gel = deep(allStops('S6')); + day.stops.push(gel); + } + rebuildLegs(); + renderRail(day.stops.map(s => s.id)); renderMap(); + ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h of 11 h waking. Two flags below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 1100); + }, 1400); +} + +// ---------------- landing wiring ---------------- +const dz = $('#dropzone'); +['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); })); +['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); })); +dz.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); }); +$('#dz-sample').onclick = showParse; +$('#dest-go').onclick = () => { + const v = $('#dest').value.trim(); + if (!v) { showParse(); return; } + SAMPLE[1] = ['name', v, 'geocoded', 'hi']; + showParse(); +}; +$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click()); + +initMap(); diff --git a/mock/data.js b/mock/data.js new file mode 100644 index 0000000..8e3331f --- /dev/null +++ b/mock/data.js @@ -0,0 +1,264 @@ +// Fake data layer — same shapes the real API will return. +// Each stop: day, slot (mutual-exclusion group), state: planned | maybe | alt. +// Only one `planned` stop per slot per day; `alt`/`maybe` don't count against +// the day's time budget. +window.MOCK = { + trip: { + id: 'trip-2026-09', + version: 1, + title: 'Florence 12–15 Sep', + // stays, not a single hotel: {hotel, place, checkIn, checkOut, state}. + // Days resolve their anchor per date; >1 candidate stay covering the + // same nights → worst-case anchoring for those nights. + // Multi-city trips = more stays, each anchoring its own dates. + stays: [ + { id: 'ST1', name: 'Hotel Palagio', at: [43.7684, 11.2566], place: 'Florence', + checkIn: '2026-09-12', checkOut: '2026-09-15', state: 'booked', price: 180 }, + ], + places: [ + { id: 'PL1', name: 'Florence', bbox: [43.73, 11.21, 43.80, 11.29], + center: [43.769, 11.255], vibe: 'art + food, relaxed pace', + dates: ['2026-09-12', '2026-09-15'] } + ], + bookings: [ + { type: 'hotel', name: 'Hotel Palagio', ref: 'H-77821', + dates: '12–15 Sep', where: [43.7684, 11.2566], + status: 'booked', source: 'user_booking' }, + { type: 'train', name: 'Frecciarossa 9512', ref: 'F-55812', + from: 'Firenze SMN', to: 'Pisa Centrale', date: '2026-09-15', + depart: '09:12', arrive: '09:58', status: 'booked', source: 'user_booking', + stations: { from: [43.7726, 11.2597], to: [43.7157, 10.3922] } } + ] + }, + + days: [ + { id: 'D1', date: '2026-09-12', label: 'Day 1 · Fri 12', + startMin: 8 * 60, wakingHours: 11, + base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] }, + legs: [], + stops: [ + { id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [43.7684, 11.2566], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'triptadvisor.com' } }, + { id: 'S2', name: 'Palazzo Pitti', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.palazzopittiturin.it', + at: [43.7639, 11.2518], dur: 60, durConf: 5, // user override (sticky) + suggested: { value: 150, conf: 2, src: 'wikivoyage.org' }, + price: { amount: 14, cur: '€', conf: 2, src: 'uffizi.it' }, + note: 'guides suggest 2–3 h', img: ['🏛️', 'Palazzo Pitti', 'linear-gradient(135deg,#b5533c,#7a3a2c)', + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/Palacio_Pitti%2C_Florencia%2C_Italia%2C_2022-09-18%2C_DD_197.jpg/1280px-Palacio_Pitti%2C_Florencia%2C_Italia%2C_2022-09-18%2C_DD_197.jpg'], + detail: { + summary: 'The Medici family’s riverside palazzo: the Galleria Palatina (Portraiture, the 500 Room) and the modern-art Galleria d’Arte Moderna. Rooms are small and crowded in peak season — the apartments alone run 1.5–2 h if you linger, ~1 h for the highlights. September afternoons are the sweet spot: light is good for photos and lines thin after 16:00.', + links: [ + { label: 'Official site', href: 'https://www.palazzopittiturin.it' }, + { label: 'Tickets', href: 'https://www.uffizi.it/en/visit/tickets' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183726-Reviews-Palazzo_Pitti-Florence_Tuscany.html' }, + { label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=palazzo+pitti&restrict_sr=1' } + ] } }, + { id: 'S4', name: 'Lunch — Trattoria Mario', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.trattoriamario.it', + at: [43.7667, 11.2544], dur: 45, durConf: 3, + suggested: { value: 90, conf: 2, src: 'trattoriamario.it' }, + price: { amount: 25, cur: '€', conf: 2, src: 'trattoriamario.it' }, + img: ['🍝', 'Trattoria Mario', 'linear-gradient(135deg,#d08b3c,#9c5f1e)', + 'https://upload.wikimedia.org/wikipedia/commons/1/12/Bistecca_alla_fiorentina-01.jpg'], + detail: { + summary: 'A no-frills Florentine kitchen on Via dei Neri, open since 1954. Counter seating, short menu (bistecca, lampredotto, tripe soup), and the queue is real — arriving as lunch opens (12:00) is the difference between 10 minutes and 40. Budget ~1 h including eating.', + links: [ + { label: 'Official site', href: 'https://www.trattoriamario.it' }, + { label: 'Menu', href: 'https://www.trattoriamario.it/menu' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d310951-Reviews-Trattoria_Mario-Florence_Tuscany.html' }, + { label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=trattoria+mario+florence&restrict_sr=1' } + ] } }, + { id: 'S5', name: 'Boboli Gardens', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.boboli.it', + at: [43.7579, 11.2463], dur: 120, durConf: 3, + suggested: { value: 120, conf: 2, src: 'boboli.it' }, + price: { amount: 10, cur: '€', conf: 2, src: 'uffizi.it' }, + img: ['🌿', 'Boboli Gardens', 'linear-gradient(135deg,#4f9e63,#2c6b40)', + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/d/d2/Obelisco%2C_Boboli_Gardens%2C_Florence_%2826408174180%29.jpg/1280px-Obelisco%2C_Boboli_Gardens%2C_Florence_%2826408174180%29.jpg'], + detail: { + summary: 'The Medici’s formal garden behind Pitti — statues, the Apollo fountain, a hilltop view over the city. The full loop with the Accademia annex runs 2 h; the upper terraces alone are a fine 45-minute pace-break. Pairs naturally with Pitti: the entrance is the courtyard behind it.', + links: [ + { label: 'Official site', href: 'https://www.boboli.it' }, + { label: 'Tickets', href: 'https://www.uffizi.it/en/visit/tickets' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183872-Reviews-Giardini_Di_Boboli-Florence_Tuscany.html' }, + { label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=boboli&restrict_sr=1' } + ] } }, + { id: 'S6', name: 'Gelato — Vivoli', kind: 'meal', mealKind: 'snack', + slot: 'snack', state: 'planned', + at: [43.7680, 11.2537], dur: 20, durConf: 3, + suggested: { value: 20, conf: 1, src: null }, + price: null, + img: ['🍦', 'Vivoli gelato', 'linear-gradient(135deg,#e0a5c0,#a56a92)'] }, + { id: 'S7', name: 'Lunch — Ristorante Santini', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'alt', url: 'https://www.santini.it', + at: [43.7646, 11.2528], dur: 75, durConf: 3, + suggested: { value: 90, conf: 2, src: 'santini.it' }, + price: { amount: 45, cur: '€', conf: 2, src: 'santini.it' }, + img: ['🍷', 'Ristorante Santini', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)', + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/c/c0/View_from_Giotto%27s_Bell_Tower_-_Florence.jpg/1280px-View_from_Giotto%27s_Bell_Tower_-_Florence.jpg'], + detail: { + summary: 'A classic Florentine restaurant in a 16th-century building on the Arno — the terrace looks across to Pitti. Set tables, longer menus, lunch from 12:30. The step-up option: worth holding if Mario’s queue looks long, or if the day goes well and you want to mark it.', + links: [ + { label: 'Official site', href: 'https://www.santini.it' }, + { label: 'Menu', href: 'https://www.santini.it/en/menu' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d1442007-Reviews-Ristorante_Santini-Florence_Tuscany.html' }, + { label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=santini+florence&restrict_sr=1' } + ] } } + ] + }, + { id: 'D2', date: '2026-09-13', label: 'Day 2 · Sat 13', + startMin: 8 * 60 + 30, wakingHours: 11, + base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] }, + legs: [], + stops: [ + { id: 'S8', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [43.7684, 11.2566], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S9', name: 'Galleria dell’Accademia (David)', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.galleriaaccademia.firenze.it', + at: [43.7734, 11.2628], dur: 90, durConf: 3, + suggested: { value: 120, conf: 2, src: 'wikivoyage.org' }, + price: { amount: 16, cur: '€', conf: 2, src: 'galleriaaccademia.firenze.it' }, + img: ['🗿', 'Accademia', 'linear-gradient(135deg,#6b7280,#3f4650)'], + detail: { + summary: 'Home of the original David plus Donatello’s Prigione. Timed-entry tickets, and the David room moves in batches — a 09:30 slot is noticeably calmer than midday. Allow ~90 min; 2 h if you do the whole collection.', + links: [ + { label: 'Official site', href: 'https://www.galleriaaccademia.firenze.it' }, + { label: 'Tickets', href: 'https://www.galleriaaccademia.firenze.it/en/tickets' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183692-Reviews-Galleria_dell_Accademia-Florence_Tuscany.html' }, + { label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=accademia+david&restrict_sr=1' } + ] } }, + { id: 'S10', name: 'Lunch — Trattoria Sostanza', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.sostanza.it', + at: [43.7609, 11.2469], dur: 75, durConf: 3, + suggested: { value: 90, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 35, cur: '€', conf: 2, src: 'tripadvisor.com' }, + img: ['🍕', 'Trattoria Sostanza', 'linear-gradient(135deg,#b0563c,#7a3a2c)'], + detail: { + summary: 'Oltrarno kitchen a short walk across the Arno from the Accademia: Florentine classics, a proper wine list, and a roomy back — the kind of lunch that runs long. Reservations recommended on Saturdays.', + links: [ + { label: 'Official site', href: 'https://www.sostanza.it' }, + { label: 'Menu', href: 'https://www.sostanza.it/menu' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d559983-Reviews-Trattoria_Sostanza-Florence_Tuscany.html' }, + { label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=sostanza+florence&restrict_sr=1' } + ] } }, + { id: 'S11', name: 'Oltrarno stroll · San Niccolò', kind: 'visit', slot: 'visit', state: 'planned', + at: [43.7613, 11.2497], dur: 60, durConf: 3, + suggested: { value: 60, conf: 1, src: null }, + price: null, + img: ['⛪', 'Oltrarno', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'] } + ] + }, + { id: 'D3', date: '2026-09-14', label: 'Day 3 · Sun 14', + startMin: 9 * 60, wakingHours: 11, + base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] }, + legs: [], + stops: [ + { id: 'S12', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [43.7684, 11.2566], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S13', name: 'San Marco & cloister', kind: 'visit', slot: 'visit', state: 'planned', + at: [43.7723, 11.2597], dur: 45, durConf: 3, + suggested: { value: 45, conf: 1, src: null }, + price: { amount: 8, cur: '€', conf: 1, src: null }, + img: ['🖼️', 'San Marco', 'linear-gradient(135deg,#7a6a4f,#4d422e)'] }, + { id: 'S14', name: 'Aperitivo — Il Latte', kind: 'meal', mealKind: 'dinner', + slot: 'dinner', state: 'maybe', + url: 'https://www.illatte.it', + at: [43.7700, 11.2560], dur: 60, durConf: 3, + suggested: { value: 60, conf: 1, src: null }, + price: { amount: 12, cur: '€', conf: 2, src: 'tripadvisor.com' }, + img: ['🥂', 'Il Latte', 'linear-gradient(135deg,#c9a13c,#8f6f1e)'] } + ] + }, + { id: 'D4', date: '2026-09-15', label: 'Day 4 · Mon 15', isDeparture: true, + startMin: 8 * 60, wakingHours: 11, + base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] }, + legs: [], + stops: [ + { id: 'S15', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [43.7684, 11.2566], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S16', name: 'Train → Pisa Centrale', kind: 'transit', slot: 'transit', state: 'planned', + at: [43.7726, 11.2597], dur: 30, durConf: 4, + transit: { name: 'Frecciarossa 9512', ref: 'F-55812', + from: 'Firenze SMN', to: 'Pisa Centrale', + depart: '09:12', arrive: '09:58', arriveBy: '08:42', + buffer: '30 min before departure' }, + suggested: null, + price: { amount: 45, cur: '€', conf: 4, src: 'booking' }, + img: ['🚄', 'Frecciarossa 9512', 'linear-gradient(135deg,#274b8f,#16294d)'] } + ] + } + ], + + // focus-mode candidate pools (what a slot search returns) + candidates: { + hotel: [ + { id: 'H1', name: 'Hotel Palagio', at: [43.7684, 11.2566], price: 180, dur: null, + tags: ['central', 'quiet', 'refundable'], pitch: 'Where you are now — refundable until the 8th.', url: null }, + { id: 'H2', name: 'Hotel Savoy', at: [43.7726, 11.2565], price: 210, dur: null, + tags: ['near SMN', 'grand', 'rooftop'], pitch: 'Grand hotel by the station — handy for the 15th, 10 min from the centre.', url: null }, + { id: 'H3', name: 'Residenza Oltrarno', at: [43.7604, 11.2510], price: 140, dur: null, + tags: ['oltrarno', 'small', 'value'], pitch: 'Six rooms above a bookshop — cheaper, and puts you on the south side.', url: null }, + ], + lunch: [ + { id: 'C1', name: 'Trattoria Mario', at: [43.7667, 11.2544], price: 25, dur: 90, + tags: ['counter', 'local', 'queue'], pitch: 'No-frills counter since 1954 — the queue is the point.', + url: 'https://www.trattoriamario.it', img: ['🍝', 'Trattoria Mario', 'linear-gradient(135deg,#d08b3c,#9c5f1e)', 'https://upload.wikimedia.org/wikipedia/commons/1/12/Bistecca_alla_fiorentina-01.jpg'] }, + { id: 'C2', name: 'Ristorante Santini', at: [43.7646, 11.2528], price: 45, dur: 90, + tags: ['views', 'terrace', 'splurge'], pitch: '16th-century set tables on the Arno, terrace across from Pitti.', + url: 'https://www.santini.it', img: ['🍷', 'Santini', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)'] }, + { id: 'C3', name: 'Trattoria Sostanza', at: [43.7609, 11.2469], price: 35, dur: 90, + tags: ['oltrarno', 'roomy', 'wine'], pitch: 'Oltrarno kitchen, proper wine list, a roomy back — runs long.', + url: 'https://www.sostanza.it', img: ['🍕', 'Sostanza', 'linear-gradient(135deg,#b0563c,#7a3a2c)'] }, + { id: 'C4', name: 'Vendemmia', at: [43.7735, 11.2585], price: 30, dur: 75, + tags: ['vegetarian', 'garden', 'near SMN'], pitch: 'Garden terrace by SMN, strong vegetarian menu, calmer pace.', + url: 'https://www.vendemmia.it', img: ['🥗', 'Vendemmia', 'linear-gradient(135deg,#5f9e6b,#33613c)'] }, + { id: 'C5', name: 'La Cantina di San Niccolò', at: [43.7615, 11.2490], price: 22, dur: 75, + tags: ['cellar', 'cozy', 'oltrarno'], pitch: 'Stone cellar, candlelight, short honest menu — book ahead.', + url: 'https://www.lacantina.it', img: ['🕯️', 'La Cantina', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'] }, + { id: 'C6', name: 'Buonami Pizza', at: [43.7680, 11.2550], price: 15, dur: 45, + tags: ['fast', 'casual', 'near hotel'], pitch: 'Two-stop, fast, good — the reset-button lunch.', + url: 'https://www.buonami.it', img: ['🍕', 'Buonami', 'linear-gradient(135deg,#c98a2c,#8a5f1a)'] } + ], + visit: [ + { id: 'V1', name: 'Piazzale Michelangelo', at: [43.7669, 11.2455], price: 0, dur: 60, + tags: ['views', 'sunset', 'free'], pitch: 'The postcard view — best from 17:00 as the light warms.', + url: 'https://en.wikipedia.org/wiki/Piazzale_Michelangelo', img: ['🌇', 'Piazzale Michelangelo', 'linear-gradient(135deg,#c97a3c,#8a4f1a)'] }, + { id: 'V2', name: 'San Miniato al Monte', at: [43.7620, 11.2521], price: 4, dur: 60, + tags: ['views', 'romanesque', 'calm'], pitch: 'Romanesque basilica on the hill — quiet, cool in the afternoon.', + url: 'https://en.wikipedia.org/wiki/San_Miniato_al_Monte', img: ['⛪', 'San Miniato', 'linear-gradient(135deg,#8f9a7a,#5c644d)'] }, + { id: 'V3', name: 'Santo Spirito square', at: [43.7603, 11.2496], price: 0, dur: 45, + tags: ['square', 'lazier', 'market'], pitch: 'Oltrarno’s lazy square; Saturday’s market is the event.', + url: 'https://en.wikipedia.org/wiki/Santo_Spirito,_Florence', img: ['☕', 'Santo Spirito', 'linear-gradient(135deg,#a58a6a,#6d5840)'] }, + { id: 'V4', name: 'Terme di Caracalla (ruins)', at: [43.7589, 11.2707], price: 10, dur: 75, + tags: ['ruins', 'open', 'afternoon'], pitch: 'Third-century baths in open fields — cool, empty, odd.', + url: 'https://en.wikipedia.org/wiki/Baths_of_Caracalla', img: ['🏛️', 'Terme di Caracalla', 'linear-gradient(135deg,#b0a58a,#756a4f)'] } + ] + }, + + vibes: [ + { id: 'v1', label: 'art & food · relaxed pace' }, + { id: 'v2', label: 'gardens & walking' }, + { id: 'v3', label: 'day trip · Fiesole' }, + { id: 'v4', label: 'views & aperitivo' } + ], + + suggestions: [ + { stop: 'S2', pitch: 'The Palazzo Medici’s private apartments — less crowded than the Uffizi, opens right at 9:30.', + enrich: 'open 09:30–19:30 · skip-the-line available' }, + { stop: 'S4', pitch: 'No-frills Florentine kitchen the locals queue for. Expect a wait without a reservation.', + enrich: 'lunch 12:00–14:30 · dinner from 19:30 · ~1 h typically' }, + { stop: 'S5', pitch: 'Renaissance gardens behind Pitti — a good pace-break in the heat; ~2 h is the sweet spot.', + enrich: 'open 08:15–19:30 · last entry 18:15' }, + { stop: 'S7', pitch: 'The step-up lunch: set tables on the Arno, terrace across from Pitti. Worth holding if Mario’s queue looks long.', + enrich: 'lunch from 12:30 · book ahead in season' } + ] +}; diff --git a/mock/index.html b/mock/index.html new file mode 100644 index 0000000..01702cd --- /dev/null +++ b/mock/index.html @@ -0,0 +1,104 @@ + + + + + +Trip + + + + +
+ + +
+
+

Where would you like to go?

+
+ + +
+
or
+
+
🎫
+
Drop a travel booking to start
ticket, flight or hotel confirmation
+ +
+
+
+ + + + + + + + + + + + + + + + diff --git a/mock/server.js b/mock/server.js new file mode 100644 index 0000000..364d302 --- /dev/null +++ b/mock/server.js @@ -0,0 +1,58 @@ +// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached). +// The UI only ever talks to localhost; swap the upstream for a real +// tileserver-gl later without touching the frontend. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { Readable } = require('stream'); + +const PORT = 8077; +const ROOT = __dirname; +const CACHE = path.join(__dirname, '.tilecache'); +fs.mkdirSync(CACHE, { recursive: true }); + +const mime = { + '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', +}; + +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + + // ---- tile backend ------------------------------------------------- + if (u.pathname.startsWith('/tiles/')) { + const parts = u.pathname.split('/'); + const [z, x, y] = [parts[2], parts[3], (parts[4] || '').replace(/\.png$/, '')]; + if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { res.writeHead(400); return res.end(); } + const file = path.join(CACHE, `${z}/${x}`, `${y}.png`); + const upstream = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; } + fs.mkdirSync(path.dirname(file), { recursive: true }); + const fail = (code) => { if (!res.headersSent) { res.writeHead(code); res.end(); } else res.destroy(); }; + fetch(upstream, { headers: { 'User-Agent': 'mapmock-dev/0.1 (local tile proxy)', 'Referer': `http://localhost:${PORT}/` } }) + .then(r => { + if (!r.ok) { return fail(r.status); } + const body = Readable.fromWeb(r.body); + body.on('error', () => fail(502)); + res.writeHead(200); + const ws = fs.createWriteStream(file, { flags: 'x' }); + ws.on('error', () => {}); + body.pipe(ws); body.pipe(res); + }) + .catch(() => fail(502)); + return; + } + + // ---- static ------------------------------------------------------- + let p = u.pathname === '/' ? '/index.html' : u.pathname; + const file = path.join(ROOT, p); + if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); } + fs.readFile(file, (e, d) => { + if (e) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' }); + res.end(d); + }); +}).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png)`)); diff --git a/mock/styles.css b/mock/styles.css new file mode 100644 index 0000000..a3ebad1 --- /dev/null +++ b/mock/styles.css @@ -0,0 +1,338 @@ +:root { + --ink: #1f2430; --ink2: #5b6472; --line: #e4e7ec; --bg: #f5f6f8; + --card: #ffffff; --accent: #b5533c; --accent2: #8f3f2d; + --ok: #2e9e5b; --warn: #c98a1b; --err: #c0392b; + --blue: #2f6fd6; + --shadow: 0 1px 3px rgba(20,24,35,.08), 0 8px 24px rgba(20,24,35,.07); +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; color: var(--ink); background: var(--bg); } +.hidden { display: none !important; } +button { font: inherit; } + +#map { position: fixed; inset: 0; z-index: 0; background: #dde3ea; } +.leaflet-container { font: inherit; } + +/* ---------------- landing ---------------- */ +#landing { position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; + background: linear-gradient(rgba(20,24,35,.28), rgba(20,24,35,.42)); } +.land-card { width: min(560px, 92vw); background: var(--card); border-radius: 18px; box-shadow: var(--shadow); padding: 34px 36px; } +.land-kicker { font-size: 11px; letter-spacing: .14em; color: var(--accent); font-weight: 700; margin-bottom: 10px; } +.land-card h1 { margin: 0 0 16px; font-size: 26px; font-weight: 700; letter-spacing: -.01em; } +.land-row { display: flex; gap: 8px; } +#dest { flex: 1; padding: 12px 14px; border: 1.5px solid var(--line); border-radius: 10px; font-size: 15px; outline: none; } +#dest:focus { border-color: var(--accent); } +.land-div { display: flex; align-items: center; gap: 12px; color: var(--ink2); font-size: 12px; margin: 18px 0; } +.land-div::before, .land-div::after { content: ""; flex: 1; height: 1px; background: var(--line); } +#dropzone { border: 2px dashed #cfd4dd; border-radius: 14px; padding: 22px 18px; text-align: center; transition: all .15s; } +#dropzone.over { border-color: var(--accent); background: #fbf1ee; transform: scale(1.01); } +.dz-icon { font-size: 30px; margin-bottom: 8px; } +.dz-text { font-size: 13.5px; color: var(--ink2); line-height: 1.5; } +.dz-text b { color: var(--ink); font-size: 14.5px; } +.land-hint { margin-top: 14px; font-size: 12px; color: var(--ink2); text-align: center; } + +/* ---------------- buttons ---------------- */ +.btn { border: 0; border-radius: 10px; padding: 11px 16px; font-size: 14px; font-weight: 600; cursor: pointer; transition: filter .12s, transform .05s; } +.btn:active { transform: translateY(1px); } +.btn.primary { background: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { background: #eef0f4; color: var(--ink); } +.btn.ghost:hover { background: #e4e7ee; } +.btn.small { padding: 8px 12px; font-size: 12.5px; } + +/* ---------------- topbar ---------------- */ +#topbar { position: fixed; top: 0; left: 0; right: 0; height: 46px; z-index: 15; background: var(--card); + border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; } +.brand { font-weight: 800; letter-spacing: .12em; font-size: 13px; color: var(--accent); } +.crumb { font-size: 13px; font-weight: 600; margin-left: 12px; } +.crumb.sub { color: var(--ink2); font-weight: 500; } +.badge { font-size: 12.5px; background: #fdf3e3; color: #8a6116; border: 1px solid #f0dcae; padding: 5px 10px; border-radius: 999px; cursor: pointer; } +.badge.ok { background: #eaf6ef; color: #1f7a45; border-color: #c9e8d6; } + +/* ---------------- app layout ---------------- */ +#app { position: fixed; top: 46px; left: 0; right: 0; bottom: 0; z-index: 10; display: grid; + grid-template-columns: 350px 1fr 390px; pointer-events: none; } /* center column: let events reach the map */ +#rail, #chat { min-height: 0; pointer-events: auto; } +#rail { background: var(--card); border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow: hidden; } +#rail-body, #chat-body { min-height: 0; } +#rail-body { flex: 1; overflow-y: auto; padding: 10px 12px 4px; } +#rail-foot { border-top: 1px solid var(--line); padding: 10px 14px 12px; background: #fbfcfd; } +#mapwrap { position: relative; pointer-events: none; } /* let wheel/drag reach the Leaflet map underneath */ +#chat { background: var(--card); border-left: 1px solid var(--line); display: flex; flex-direction: column; } +#chat-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; } +#chat-input-row { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); } +#chat-input { flex: 1; border: 1.5px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 13.5px; outline: none; } +#chat-input:focus { border-color: var(--accent); } + +/* ---------------- rail: transit / stops / legs ---------------- */ +.transit-chip { display: flex; align-items: center; gap: 8px; background: #f2f5fa; border: 1px solid #dfe6f2; + border-radius: 10px; padding: 8px 10px; margin: 4px 12px; font-size: 12.5px; } +.transit-chip .tt { font-weight: 700; } +.transit-chip .ts { color: var(--ink2); } +.stop-card { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 6px 0; background: var(--card); + box-shadow: 0 1px 2px rgba(20,24,35,.05); transition: box-shadow .2s, border-color .2s; position: relative; } +.stop-card.flash { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(181,83,60,.15); } +.stop-card.dragging { opacity: .45; } +.stop-card.optional { border: 1.5px dashed #cfae5f; background: #fffdf6; } +.grip { cursor: grab; color: #c3c9d4; font-size: 14px; margin-right: 2px; user-select: none; letter-spacing: -2px; } +.grip:active { cursor: grabbing; } +.sc-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.sc-name { font-weight: 700; font-size: 13.5px; } +.sc-times { font-size: 12px; color: var(--ink2); font-variant-numeric: tabular-nums; white-space: nowrap; } +.sc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; align-items: center; } +.sc-sub { font-size: 11.5px; color: var(--ink2); margin-top: 5px; } +.sc-sub a { color: var(--blue); text-decoration: none; } +.sc-move { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 4px; opacity: .25; } +.stop-card:hover .sc-move { opacity: 1; } +.sc-move button { border: 1px solid var(--line); background: #fff; border-radius: 6px; width: 22px; height: 20px; cursor: pointer; font-size: 10px; color: var(--ink2); } +.sc-move button:hover { background: #eef0f4; } + +.leg-row { display: flex; align-items: center; gap: 8px; margin: 2px 0 2px 22px; padding: 3px 8px; cursor: pointer; + border-radius: 8px; font-size: 12px; color: var(--ink2); width: fit-content; } +.leg-row:hover { background: #eef0f4; } +.leg-ico { font-size: 13px; } +.leg-sub { font-size: 11px; color: var(--ink2); opacity: .8; } + +/* ---------------- time/number chips (confidence hierarchy) ---------- */ +.tc { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600; + padding: 2.5px 8px; border-radius: 999px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.t-user { background: var(--ink); color: #fff; } +.t-user::before { content: "📌"; font-size: 9px; } +.t-sched { background: #274b8f; color: #fff; } +.t-sched::before { content: "🎫"; font-size: 9px; } +.t-computed { background: #e7ebf2; color: var(--ink); } +.t-search { background: #e8f1fd; color: #1d4f9c; } +.t-search::before { content: "~"; font-weight: 800; } +.t-llm { background: transparent; border: 1.5px dashed #b9c0cc; color: var(--ink2); } +.t-llm::after { content: "est"; font-size: 9px; opacity: .8; } +.t-maybe { background: #fdf3e3; color: #8a6116; border: 1px dashed #d9b96a; } +.pchip { font-size: 11.5px; color: var(--ink2); font-weight: 600; } +.sug-note { font-size: 11px; color: var(--ink2); font-style: italic; } + +/* ---------------- budget / issues / legend ---------------- */ +.budget-label { font-size: 11.5px; color: var(--ink2); margin-bottom: 5px; } +.budget-bar { height: 7px; background: #e7ebf2; border-radius: 99px; overflow: hidden; } +#budget-fill { height: 100%; width: 0; background: var(--ok); border-radius: 99px; transition: width .5s ease, background .3s; } +#budget-fill.warn { background: var(--warn); } +#issues-panel { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; } +.issue { border: 1px solid #f0dcae; background: #fdf6e8; border-radius: 10px; padding: 8px 10px; font-size: 12px; line-height: 1.45; } +.issue .it { font-weight: 700; color: #8a6116; } +.issue .fix { margin-top: 6px; } +.issue.fixed { border-color: #c9e8d6; background: #eef8f2; opacity: .75; } +.issue.fixed .it { color: #1f7a45; } +.legend { display: flex; flex-wrap: wrap; gap: 5px 10px; margin-top: 10px; font-size: 10.5px; color: var(--ink2); align-items: center; } +.legend .tc { font-size: 10px; padding: 1px 6px; } + +/* ---------------- chat ---------------- */ +.msg { max-width: 94%; border-radius: 14px; padding: 10px 13px; font-size: 13.5px; line-height: 1.5; animation: pop .18s ease; } +@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +.msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } +.msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; } +.msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; } +.msg .src a { color: var(--blue); text-decoration: none; } +.typing { display: inline-flex; gap: 4px; padding: 12px 14px; } +.typing i { width: 6px; height: 6px; border-radius: 50%; background: #9aa3b2; animation: blink 1.2s infinite; } +.typing i:nth-child(2) { animation-delay: .2s; } .typing i:nth-child(3) { animation-delay: .4s; } +@keyframes blink { 0%, 70%, 100% { opacity: .3; } 35% { opacity: 1; } } + +.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 8px; } +.chip { border: 1.5px solid var(--line); background: #fff; border-radius: 999px; padding: 7px 13px; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: all .12s; } +.chip:hover { border-color: var(--accent); color: var(--accent); } +.chip.sel { background: var(--accent); border-color: var(--accent); color: #fff; } + +.cards { display: flex; flex-direction: column; gap: 9px; margin-top: 10px; } +.scard { border: 1px solid var(--line); border-radius: 13px; overflow: hidden; background: #fff; box-shadow: var(--shadow); } +.scard-img { height: 96px; display: flex; align-items: center; justify-content: center; position: relative; } +.scard-img .skel { position: absolute; inset: 0; background: linear-gradient(100deg,#eceef2 40%,#f6f7fa 50%,#eceef2 60%); background-size: 200% 100%; animation: shimmer 1.2s infinite; } +@keyframes shimmer { to { background-position: -200% 0; } } +.scard-img .ph { font-size: 44px; opacity: 0; transition: opacity .5s; } +.scard-img.done .ph { opacity: 1; } +.scard-img .attr { position: absolute; right: 6px; bottom: 4px; font-size: 9px; color: rgba(255,255,255,.85); text-shadow: 0 1px 2px rgba(0,0,0,.5); } +.scard-body { padding: 10px 12px; } +.scard-name { font-weight: 700; font-size: 13.5px; } +.scard-pitch { font-size: 12.5px; color: var(--ink2); margin: 4px 0 7px; line-height: 1.45; } +.scard-enrich { font-size: 11.5px; color: var(--blue); margin-top: 6px; opacity: 0; transition: opacity .6s; } +.scard-enrich.show { opacity: 1; } +.scard-enrich a { text-decoration: none; } +.scard-foot { display: flex; gap: 6px; align-items: center; margin-top: 8px; } +.scard-foot .sp { margin-left: auto; } +.scard { transition: opacity .4s; cursor: pointer; } +.scard.added { opacity: .55; } +.scard.maybe-card { opacity: .7; } +.scard.dropped { opacity: .3; cursor: default; } +.scard-name a { color: var(--ink); text-decoration: none; } +.scard-name a:hover { color: var(--accent); text-decoration: underline; } +.scard-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .6s; } +.scard-img.done img { opacity: 1; } + +/* detail sheet */ +.modal-card.wide { width: min(600px, 94vw); max-height: 86vh; overflow-y: auto; position: relative; padding: 0; } +.d-img { height: 150px; display: flex; align-items: center; justify-content: center; position: relative; } +.d-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.d-emoji { font-size: 56px; } +.d-actions { display: flex; gap: 8px; margin-top: 18px; } +.d-attr { position: absolute; right: 10px; bottom: 8px; font-size: 10px; color: rgba(255,255,255,.85); text-shadow: 0 1px 2px rgba(0,0,0,.5); } +.d-body { padding: 18px 22px 20px; } +.d-title { display: flex; align-items: baseline; gap: 10px; } +.d-title a { font-size: 19px; font-weight: 700; color: var(--ink); text-decoration: none; } +.d-title a:hover { color: var(--accent); text-decoration: underline; } +.d-kind { font-size: 11px; color: var(--ink2); border: 1px solid var(--line); padding: 2px 8px; border-radius: 99px; } +.d-facts { display: flex; flex-wrap: wrap; gap: 7px; margin: 10px 0 12px; align-items: center; } +.d-summary { font-size: 13.5px; line-height: 1.6; color: var(--ink); } +.d-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.d-link { border: 1.5px solid var(--line); background: #fff; border-radius: 999px; padding: 7px 13px; font-size: 12.5px; font-weight: 600; color: var(--ink); text-decoration: none; transition: all .12s; } +.d-link:hover { border-color: var(--accent); color: var(--accent); } +.d-close { position: absolute; top: 10px; right: 12px; border: 0; background: rgba(255,255,255,.85); width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 12px; color: var(--ink); z-index: 2; } +.d-close:hover { background: #fff; } +.pulse { animation: pulse .8s ease; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(181,83,60,.4); } 100% { box-shadow: 0 0 0 12px rgba(181,83,60,0); } } + +/* ---------------- L3 editor ---------------- */ +#l3-editor { position: absolute; left: 14px; bottom: 18px; z-index: 12; background: var(--card); border-radius: 14px; + box-shadow: var(--shadow); padding: 12px 14px; width: 300px; pointer-events: auto; } +.l3-title { font-weight: 700; font-size: 13.5px; } +.l3-hint { font-size: 11.5px; color: var(--ink2); margin: 5px 0 8px; line-height: 1.4; } +.l3-time { font-size: 12.5px; color: var(--ink2); display: flex; gap: 8px; align-items: center; } +#l3-close { float: right; margin-top: -4px; } + +/* ---------------- modal ---------------- */ +.modal { position: fixed; inset: 0; z-index: 40; background: rgba(20,24,35,.45); display: flex; align-items: center; justify-content: center; } +.modal-card { width: min(480px, 92vw); background: var(--card); border-radius: 16px; box-shadow: var(--shadow); padding: 24px 26px; } +.modal-card h2 { margin: 0 0 14px; font-size: 18px; } +#parse-table { width: 100%; border-collapse: collapse; font-size: 13px; } +#parse-table td { padding: 7px 4px; border-bottom: 1px solid var(--line); vertical-align: top; } +#parse-table td:first-child { color: var(--ink2); width: 34%; } +.conf { font-size: 10.5px; padding: 1.5px 7px; border-radius: 99px; font-weight: 700; margin-left: 6px; } +.conf.hi { background: #eaf6ef; color: #1f7a45; } +.conf.mid { background: #fdf3e3; color: #8a6116; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +.wp { cursor: grab; } +.leaflet-marker-icon.wp { transition: none; } + +/* ---------------- day tabs ---------------- */ +.daytabs { display: flex; gap: 6px; margin-left: 14px; align-items: center; } +.daytab { border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 5px 12px; + font-size: 12px; font-weight: 600; color: var(--ink2); cursor: pointer; transition: all .12s; } +.daytab:hover { border-color: var(--accent); color: var(--accent); } +.daytab.sel { background: var(--ink); border-color: var(--ink); color: #fff; } +.daytab.dep { cursor: default; background: transparent; border-style: dashed; color: var(--ink2); } +.daytab.dep:hover { border-color: var(--line); color: var(--ink2); } +.daytab.over { border-color: var(--accent); background: #fbf1ee; color: var(--accent); } + +/* ---------------- stop states ---------------- */ +.t-planned { background: #e7ebf2; color: var(--ink); } +.t-maybe { background: #fdf3e3; color: #8a6116; border: 1px dashed #d9b96a; } +.t-alt { background: #eef1f6; color: #4a5a78; border: 1px dashed #9fb0cc; } +.sc-state { cursor: pointer; } +.sc-state:hover { filter: brightness(.94); text-decoration: underline; } +.stop-card.st-maybe { border: 1.5px dashed #cfae5f; background: #fffdf6; } +.stop-card.st-alt { border: 1.5px dashed #9fb0cc; background: #f8fafd; } +.or-row { margin: 2px 0 2px 24px; font-size: 11px; color: var(--ink2); font-style: italic; } +.leg-row.dim { opacity: .55; } + +/* state menu popover */ +.pop { position: absolute; top: 36px; right: 8px; z-index: 30; background: #fff; border: 1px solid var(--line); + border-radius: 10px; box-shadow: var(--shadow); padding: 4px; display: flex; flex-direction: column; min-width: 170px; } +.pop button { border: 0; background: none; text-align: left; padding: 7px 10px; font-size: 12.5px; border-radius: 7px; cursor: pointer; color: var(--ink); font-weight: 500; } +.pop button:hover { background: #eef0f4; } +.pop button.danger { color: var(--err); } + +/* rail item numbers (match the map markers) */ +.num { width: 20px; height: 20px; border-radius: 50%; background: var(--ink); color: #fff; font-size: 11px; + font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.stop-card.st-maybe .num { background: #cfae5f; } +.stop-card.st-alt .num { background: #9fb0cc; } + +/* hotel / train pins */ +.hotel-pin { font-size: 22px; transform: translate(-50%,-90%); transition: transform .15s; } +.hotel-pin.hotel-alt { filter: grayscale(1); opacity: .75; } + +/* resolved issues toggle */ +.resolved-toggle { border: 0; background: none; color: var(--ink2); font-size: 11.5px; cursor: pointer; + text-decoration: underline; padding: 2px 0; } +.resolved-toggle:hover { color: var(--ink); } + +/* ---------------- focus mode (slot search) ---------------- */ +#rail.dimmed { opacity: .35; pointer-events: none; } +#focusbar { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); z-index: 12; + background: var(--card); border-radius: 999px; box-shadow: var(--shadow); padding: 7px 8px 7px 15px; + display: flex; gap: 12px; align-items: center; font-size: 12.5px; pointer-events: auto; } +#focusbar-title b { color: var(--accent); } +.fb-hint { color: var(--ink2); font-size: 11px; white-space: nowrap; } +.fb-x { border: 0; background: #eef0f4; width: 26px; height: 26px; border-radius: 50%; cursor: pointer; + font-size: 11px; color: var(--ink); flex-shrink: 0; } +.fb-x:hover { background: #e2e5ec; } + +/* candidate pins: teardrop + name label, right on the map */ +.candwrap { position: relative; } +.cand { position: absolute; left: 0; bottom: 0; width: 26px; height: 26px; background: var(--accent); color: #fff; + border-radius: 50% 50% 50% 0; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; + border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.35); transition: transform .12s; cursor: pointer; } +.cand::after { content: ''; width: 7px; height: 7px; background: #fff; border-radius: 50%; } +.candwrap:hover .cand { transform: rotate(-45deg) scale(1.15); } +.cand-lbl { position: absolute; left: 24px; top: 6px; white-space: nowrap; font-size: 11.5px; font-weight: 700; + background: rgba(255,255,255,.94); color: var(--ink); padding: 2px 8px; border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,.3); pointer-events: none; } + +/* click-to-open result popup */ +.focpop { width: 230px; font-size: 12.5px; } +.fp-name { font-weight: 700; font-size: 14px; margin-bottom: 3px; } +.fp-sub { color: var(--ink2); line-height: 1.4; margin-bottom: 8px; } +.fp-dist { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; margin-bottom: 8px; } +.fp-dist .pchip { margin: 0; } +.fp-facts { display: flex; gap: 7px; align-items: center; margin-bottom: 9px; } +.fp-facts .tc { margin: 0; } +.fp-actions { display: flex; gap: 6px; flex-wrap: wrap; } + +/* map markers: current-day numbered vs other-day dots */ +.mk { background: #b5533c; color: #fff; width: 26px; height: 26px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; + border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); transition: transform .15s; } +.mkd { background: #fff; color: #8b93a3; border: 2px dashed #9aa3b2; box-shadow: none; } + +/* vague / region placeholder stops */ +.region-note { font-size: 11px; color: var(--ink2); margin-top: 5px; } +.refine-btn { margin-top: 7px; border: 1px solid var(--line); background: #f5f3ef; color: var(--accent); + border-radius: 999px; font-size: 11.5px; font-weight: 600; padding: 4px 10px; cursor: pointer; } +.refine-btn:hover { background: var(--card2); border-color: var(--accent); } + +/* ---------------- mobile: single pane + bottom tab bar ---------------- */ +#mobiletabs { display: none; } +@media (max-width: 860px) { + .pane-hidden { display: none !important; } + /* topbar: crumb + badge on row 1, horizontally scrollable day tabs on row 2 */ + #topbar { height: auto; flex-wrap: wrap; row-gap: 8px; padding: 9px 12px; } + .tb-left { flex: 1 1 auto; min-width: 0; } + .crumb { font-size: 12.5px; } + .daytabs { margin-left: 0; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 2px; } + .daytab { flex-shrink: 0; white-space: nowrap; font-size: 11.5px; padding: 5px 10px; } + .badge { font-size: 11.5px; padding: 4px 9px; } + /* one pane at a time: Plan | Map | Chat */ + #app { top: 92px; bottom: 58px; grid-template-columns: 1fr; grid-template-rows: 1fr; } + #rail { border-right: 0; } + #chat { border-left: 0; } + #mobiletabs { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; + height: 58px; background: var(--card); border-top: 1px solid var(--line); } + #mobiletabs button { flex: 1; border: 0; background: none; font-size: 11px; color: var(--ink2); } + #mobiletabs button.on { color: var(--accent); font-weight: 700; box-shadow: inset 0 2.5px 0 var(--accent); } + #rail-body, #chat-body { padding: 10px; } + .rail-foot { padding: 10px 12px; } + .stop-card { padding: 9px 10px; } + .msg { font-size: 13px; padding: 9px 11px; } + #chat-input-row { padding: 10px; } + .tc { font-size: 11px; } + /* map overlays */ + #l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; } + #focusbar { max-width: 94vw; padding: 6px 6px 6px 12px; gap: 8px; font-size: 11.5px; } + .fb-hint { display: none; } + .cand-lbl { font-size: 10.5px; } + .focpop { width: 200px; } + /* landing + detail modal */ + .land-card { padding: 24px 20px; } + .land-card h1 { font-size: 21px; } + .scard-img { height: 80px; } + .modal-card.wide { width: min(560px, 96vw); max-height: 88vh; } + .d-img { height: 120px; } + .d-body { padding: 14px 16px 16px; } +} diff --git a/router/README.md b/router/README.md new file mode 100644 index 0000000..03b74b0 --- /dev/null +++ b/router/README.md @@ -0,0 +1,114 @@ +# maps/router — route-aware planning primitives + +The Go backend for the "search along the route" capability +([DESIGN.md](../DESIGN.md), "Driving trips" section). Everything the LLM +would otherwise *guess* about a trip — route shape, travel times, detour +costs — is computed here and returned with provenance. + +## What's in here + +``` +internal/geo/ dependency-free geodesy (haversine, point-to-line) +internal/route/ Router interface + backends + - valhalla.go: hosted/self-hosted Valhalla (works today; + no geometry on the hosted instance → chord fallback) + - osrm.go: local OSRM (full geometry; scripts/setup-osrm.sh) +internal/plan/ the primitives: + - stopcost.go: stop_cost = route(A,C via B) − route(A,C) + - optimize.go: optimize_stops (exhaustive ≤10 stops, greedy above) + - corridor.go: corridor = buffer band around the route +cmd/routectl/ manual CLI: route | stopcost | optimize +cmd/bench/ benchmark runner (bench/tasks.json) +bench/tasks.json 5 real NE-corridor tasks with recorded goldens +scripts/setup-osrm.sh self-host OSRM over the NH+MA+CT+NY extract +``` + +Zero third-party Go dependencies (stdlib only). + +## Try it (hosted Valhalla, no setup) + +```sh +go run ./cmd/routectl route --from 44.054,-71.650 --to 40.729,-73.966 +# Lincoln NH → Queens NY: ~5h32m, 531 km, I-93/90/495/290 (NOT via Boston) + +go run ./cmd/routectl stopcost --from 44.054,-71.650 --to 40.729,-73.966 \ + --stop 42.2767,-71.806:60:cascade \ + --stop 43.507,-71.548:50:ladd \ + --stop 43.642,-71.156:120:mtmajor +# cascade detour= 6 min (on the I-495 corridor) +# ladd detour=17 min +# mtmajor detour=80 min (off-corridor) + +go run ./cmd/routectl optimize --from 44.054,-71.650 --to 40.729,-73.966 --k 2 \ + --stop 42.2767,-71.806:60:cascade --stop 43.507,-71.548:50:ladd \ + --stop 43.902,-71.64:60:plymouth --stop 42.425,-71.68:60:brewsters +# order: ladd -> cascade detour: 23 min +``` + +## Tests + +```sh +go test ./... # unit tests, offline, fast +go test -tags integration ./... # live router (Valhalla by default) +ROUTER_BACKEND=osrm ROUTER_URL=http://localhost:5000 \ + go test -tags integration ./... # against local OSRM +``` + +The integration tests encode the key regression: the fastest +Lincoln→Queens drive stays ≥15 km from Boston (it goes via Worcester, +I-90/I-495/I-290). That is exactly the fact an LLM confidently gets wrong. + +## Benchmark + +```sh +go run ./cmd/bench # run all tasks, assert goldens + corridor checks +go run ./cmd/bench --record # re-record goldens (after a backend/extract change) +``` + +Tasks are real OD pairs across NH/MA/CT/NY with real POI candidates and +dwell times. Check types: + +| type | meaning | +|---|---| +| `golden_route_min` | direct route time within a recorded band (±10%) | +| `route_avoids` | route stays ≥ N km from a point (geometry, or bbox when the backend has none) | +| `stop_free` / `stop_detour_at_least` / `stop_detour_band` | computed detour of a candidate vs an expected band | +| `optimize_order` | the exhaustive optimum matches the recorded order | +| `crosscheck` | single multi-waypoint route ≈ matrix-summed prediction (≤ maxPct drift) | + +Current state: **26 PASS / 0 FAIL** against hosted Valhalla. + +## Self-hosted OSRM (full geometry, exact corridors) + +`scripts/setup-osrm.sh` builds OSRM 26.4.1 from source (Boost header-only), +extracts the 4-state PBFs (pre-downloaded in `../osm/`), partitions, +customizes, and serves on `:5000`. Then: + +```sh +go run ./cmd/routectl route --backend osrm --from 44.054,-71.650 --to 40.729,-73.966 +``` + +With OSRM, `Route.Geometry` is populated, so the corridor is exact +(distances to the actual routed polyline) instead of the chord fallback +used with the hosted Valhalla. This is what makes +`search_along_route`'s spatial pre-filter (PostGIS `ST_DWithin` on the +corridor polygon in the v1 design) sound. + +## Design notes + +- **The route is computed, never narrated.** No function in this module + returns a time estimate from model knowledge; every number is router + output (`computed` provenance per DESIGN.md R4). +- **Matrix via pairwise route calls.** Valhalla's + `sources_to_targets` caps at 150 km, so the common path is + `BuildMatrix` (pairwise `/route`). OSRM's `Table` service is available + for the local backend when a single-call matrix matters. +- **optimize_stops semantics**: the user asked for *k* stops ("two + hikes"), so the objective minimizes total = detour + dwell + overhead + over ordered subsets of **exactly k**, with fallback (a) to the best + feasible smaller set when a time budget is given, then (b) to the best + exactly-k ignoring the budget, flagged `Feasible=false`. + "At most k" without a budget degenerates (zero stops always wins). +- **Stop coordinates are POI centroids.** The router snaps to the + network; a 200 m centroid error moves a 531 km route's detour by + seconds, not minutes. diff --git a/router/cmd/bench/main.go b/router/cmd/bench/main.go new file mode 100644 index 0000000..f64a730 --- /dev/null +++ b/router/cmd/bench/main.go @@ -0,0 +1,385 @@ +// bench runs the corridor stop-optimization benchmark (bench/tasks.json) +// against a router backend. It is the acceptance test for the +// "search along the route" primitives: route, stop_cost, optimize_stops. +// +// Usage: +// +// bench [--tasks bench/tasks.json] [--record] +// +// --record writes observed golden values (direct route minutes and +// optimized orders) back into the tasks file for regression use. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "strings" + "time" + + "maps/router/internal/geo" + "maps/router/internal/plan" + "maps/router/internal/route" +) + +type Place struct { + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +func (p Place) Point() geo.Point { return geo.Point{Lat: p.Lat, Lon: p.Lon} } + +type Candidate struct { + ID string `json:"id"` + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + DwellMin int `json:"dwellMin"` +} + +type Check struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Lat, Lon float64 `json:",omitempty"` + MinDistKm float64 `json:"minDistKm,omitempty"` + Candidate string `json:"candidate,omitempty"` + MaxDetourMin int `json:"maxDetourMin,omitempty"` + MinDetourMin int `json:"minDetourMin,omitempty"` + MaxPct float64 `json:"maxPct,omitempty"` + GoldMin float64 `json:"goldMin,omitempty"` + GoldMax float64 `json:"goldMax,omitempty"` + Want []string `json:"want,omitempty"` +} + +type Task struct { + ID string `json:"id"` + Desc string `json:"desc"` + Origin Place `json:"origin"` + Dest Place `json:"dest"` + K int `json:"k"` + BudgetMin int `json:"budgetMin"` + Candidates []Candidate `json:"candidates"` + Checks []Check `json:"checks"` +} + +type BenchFile struct { + Backend string `json:"backend"` + ValhallaURL string `json:"valhalla_url"` + Tasks []Task `json:"tasks"` +} + +type result struct { + check string + status string // PASS FAIL RECORD + detail string +} + +func main() { + tasksPath := flag.String("tasks", "bench/tasks.json", "path to tasks.json") + record := flag.Bool("record", false, "record golden values into the tasks file") + flag.Parse() + + raw, err := os.ReadFile(*tasksPath) + if err != nil { + fatal(err) + } + var bf BenchFile + if err := json.Unmarshal(raw, &bf); err != nil { + fatal(err) + } + var r route.Router + switch bf.Backend { + case "valhalla": + r = route.NewValhalla(bf.ValhallaURL) + case "osrm": + r = route.NewOSRM("http://localhost:5000") + default: + fatal(fmt.Errorf("unknown backend %q", bf.Backend)) + } + ctx := context.Background() + delay := 150 * time.Millisecond + if _, ok := r.(*route.OSRM); ok { + delay = 0 + } + + failures := 0 + for ti := range bf.Tasks { + t := &bf.Tasks[ti] + fmt.Printf("=== %s: %s -> %s (k=%d)\n", t.ID, t.Origin.Name, t.Dest.Name, t.K) + fmt.Printf(" %s\n", t.Desc) + + // Direct route (golden + corridor source). + direct, err := r.Route(ctx, route.ProfileDrive, []geo.Point{t.Origin.Point(), t.Dest.Point()}) + if err != nil { + fatal(fmt.Errorf("task %s: direct route: %w", t.ID, err)) + } + fmt.Printf(" direct: %s (%.1f km)\n", fmtDur(direct.Duration), direct.Distance/1000) + + // Matrix over [candidates..., origin, dest]. + pts := make([]geo.Point, 0, len(t.Candidates)+2) + for _, c := range t.Candidates { + pts = append(pts, geo.Point{Lat: c.Lat, Lon: c.Lon}) + } + pts = append(pts, t.Origin.Point(), t.Dest.Point()) + fmt.Printf(" building %dx%d matrix (%d calls)...\n", len(pts), len(pts), len(pts)*(len(pts)-1)) + t0 := time.Now() + m, err := route.BuildMatrix(ctx, r, route.ProfileDrive, pts, delay) + if err != nil { + fatal(fmt.Errorf("task %s: matrix: %w", t.ID, err)) + } + fmt.Printf(" matrix done in %s\n", time.Since(t0).Round(time.Second)) + + stops := make([]plan.Stop, len(t.Candidates)) + for i, c := range t.Candidates { + stops[i] = plan.Stop{ID: c.ID, Name: c.Name, At: geo.Point{Lat: c.Lat, Lon: c.Lon}, DwellMin: c.DwellMin} + } + + candByID := map[string]plan.Stop{} + for _, s := range stops { + candByID[s.ID] = s + } + + opt := plan.OptimizeStops(m, stops, t.K, t.BudgetMin) + optNames := make([]string, len(opt.Order)) + for i, si := range opt.Order { + optNames[i] = stops[si].ID + } + fmt.Printf(" optimized: %s (detour %d min, total %d min, exhaustive=%v)\n", + strings.Join(optNames, " -> "), opt.DetourMin, opt.TotalMin, opt.Exhaustive) + + for ci := range t.Checks { + ck := &t.Checks[ci] + res := runCheck(ctx, r, t, m, stops, direct, opt, *ck) + if res.status == "FAIL" { + failures++ + } + fmt.Printf(" [%s] %-22s %s\n", res.status, ck.ID, res.detail) + if *record { + applyRecord(ck, direct, optNames) + } + } + } + + if *record { + out, _ := json.MarshalIndent(bf, "", " ") + if err := os.WriteFile(*tasksPath, out, 0o644); err != nil { + fatal(err) + } + fmt.Printf("\nrecorded goldens into %s\n", *tasksPath) + } + fmt.Printf("\n%s (%d failures)\n", outcome(failures), failures) + if failures > 0 { + os.Exit(1) + } +} + +func runCheck(ctx context.Context, r route.Router, t *Task, m route.Matrix, stops []plan.Stop, + direct *route.Route, opt plan.OptimizeResult, ck Check) result { + res := result{check: ck.ID} + switch ck.Type { + case "golden_route_min": + mins := direct.Duration / 60 + if ck.GoldMin == 0 && ck.GoldMax == 0 { + res.status = "RECORD" + res.detail = fmt.Sprintf("direct = %.0f min (no golden yet, run --record)", mins) + return res + } + lo, hi := ck.GoldMin, ck.GoldMax + if lo == 0 { + lo = mins - math.Inf(1) + } + if hi == 0 { + hi = math.Inf(1) + } + if mins < lo || mins > hi { + res.status = "FAIL" + res.detail = fmt.Sprintf("direct = %.0f min, golden [%v, %v]", mins, lo, hi) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("direct = %.0f min in [%v, %v]", mins, lo, hi) + } + + case "route_avoids": + p := geo.Point{Lat: ck.Lat, Lon: ck.Lon} + dist := distFromRoute(direct, p) + if dist < ck.MinDistKm*1000 { + res.status = "FAIL" + res.detail = fmt.Sprintf("%s: route comes within %.1f km (< %.0f km)", ck.Name, dist/1000, ck.MinDistKm) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("%s: route stays %.0f km away (min %.0f)", ck.Name, dist/1000, ck.MinDistKm) + } + + case "stop_free": + c, ok := stopIdx(stops, ck.Candidate) + if !ok { + res.status, res.detail = "FAIL", "unknown candidate" + return res + } + cost := plan.StopCost(m, c, stops[c]) + if cost.DetourMin > ck.MaxDetourMin { + res.status = "FAIL" + res.detail = fmt.Sprintf("detour = %d min > %d min cap", cost.DetourMin, ck.MaxDetourMin) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("detour = %d min <= %d min", cost.DetourMin, ck.MaxDetourMin) + } + + case "stop_detour_at_least": + c, ok := stopIdx(stops, ck.Candidate) + if !ok { + res.status, res.detail = "FAIL", "unknown candidate" + return res + } + cost := plan.StopCost(m, c, stops[c]) + if cost.DetourMin < ck.MinDetourMin { + res.status = "FAIL" + res.detail = fmt.Sprintf("detour = %d min < %d min floor", cost.DetourMin, ck.MinDetourMin) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("detour = %d min >= %d min", cost.DetourMin, ck.MinDetourMin) + } + + case "stop_detour_band": + // The computed detour must fall in [min, max] — a regression + // guard on corridor shape (router/extract changes would show here). + c, ok := stopIdx(stops, ck.Candidate) + if !ok { + res.status, res.detail = "FAIL", "unknown candidate" + return res + } + cost := plan.StopCost(m, c, stops[c]) + if cost.DetourMin < ck.MinDetourMin || cost.DetourMin > ck.MaxDetourMin { + res.status = "FAIL" + res.detail = fmt.Sprintf("detour = %d min outside [%d, %d]", cost.DetourMin, ck.MinDetourMin, ck.MaxDetourMin) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("detour = %d min in [%d, %d]", cost.DetourMin, ck.MinDetourMin, ck.MaxDetourMin) + } + + case "optimize_order": + got := make([]string, len(opt.Order)) + for i, si := range opt.Order { + got[i] = stops[si].ID + } + if ck.Want == nil { + res.status = "RECORD" + res.detail = fmt.Sprintf("order = %s (no golden yet)", strings.Join(got, " -> ")) + return res + } + if !sameSeq(got, ck.Want) { + res.status = "FAIL" + res.detail = fmt.Sprintf("got %s, want %s", strings.Join(got, " -> "), strings.Join(ck.Want, " -> ")) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("order = %s", strings.Join(got, " -> ")) + } + + case "crosscheck": + // Re-route in the optimized order as a single call; compare + // travel time against the matrix-summed prediction. + if len(opt.Order) == 0 { + res.status, res.detail = "PASS", "no stops, nothing to cross-check" + return res + } + pts := []geo.Point{t.Origin.Point()} + for _, si := range opt.Order { + pts = append(pts, stops[si].At) + } + pts = append(pts, t.Dest.Point()) + rr, err := r.Route(ctx, route.ProfileDrive, pts) + if err != nil { + res.status, res.detail = "FAIL", "cross-check route: "+err.Error() + return res + } + // matrix-predicted travel time (no dwell): sum legs + n := len(stops) + via := 0.0 + prev := n // origin index in matrix + for _, si := range opt.Order { + via += m[prev][si] + prev = si + } + via += m[prev][n+1] + diffPct := math.Abs(via-rr.Duration) / rr.Duration * 100 + if diffPct > ck.MaxPct { + res.status = "FAIL" + res.detail = fmt.Sprintf("matrix %.0f s vs route %.0f s (%.1f%% drift > %.0f%%)", via, rr.Duration, diffPct, ck.MaxPct) + } else { + res.status = "PASS" + res.detail = fmt.Sprintf("matrix %.0f s vs route %.0f s (%.1f%% drift)", via, rr.Duration, diffPct) + } + + default: + res.status, res.detail = "FAIL", "unknown check type "+ck.Type + } + return res +} + +// applyRecord fills in goldens. +func applyRecord(ck *Check, direct *route.Route, optNames []string) { + mins := direct.Duration / 60 + switch ck.Type { + case "golden_route_min": + // +/- 10% tolerance band around the observed value. + ck.GoldMin = math.Floor(mins*0.9/5) * 5 + ck.GoldMax = math.Ceil(mins*1.1/5) * 5 + case "optimize_order": + ck.Want = append([]string(nil), optNames...) + } +} + +// distFromRoute returns the min distance from p to the route: geometry +// if available, else bbox (conservative: inside bbox => 0). +func distFromRoute(r *route.Route, p geo.Point) float64 { + if len(r.Geometry) >= 2 { + return geo.DistToPolylineMeters(p, r.Geometry) + } + if !r.BBox.Empty() { + return r.BBox.DistMeters(p) + } + return 0 +} + +func stopIdx(stops []plan.Stop, id string) (int, bool) { + for i, s := range stops { + if s.ID == id { + return i, true + } + } + return 0, false +} + +func sameSeq(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func fmtDur(sec float64) string { + m := int(0.5 + sec/60) + return fmt.Sprintf("%dh%02dm", m/60, m%60) +} + +func outcome(failures int) string { + if failures == 0 { + return "BENCH PASS" + } + return "BENCH FAIL" +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "bench error:", err) + os.Exit(2) +} diff --git a/router/cmd/routectl/main.go b/router/cmd/routectl/main.go new file mode 100644 index 0000000..13a747b --- /dev/null +++ b/router/cmd/routectl/main.go @@ -0,0 +1,218 @@ +// routectl is the manual/CI front end for the router + plan primitives. +// +// Usage: +// +// routectl route --backend valhalla --from 44.054,-71.650 --to 40.729,-73.966 +// routectl stopcost --backend valhalla --from A --to C --stop 42.277,-71.806:60:CascadeFalls +// routectl optimize --backend valhalla --from A --to C --k 2 \ +// --stop 42.277,-71.806:60:CascadeFalls --stop 43.507,-71.548:50:Ladd +// +// stop format: lat,lon[:dwellMin[:name]] +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" + + "maps/router/internal/geo" + "maps/router/internal/plan" + "maps/router/internal/route" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + var err error + switch os.Args[1] { + case "route": + err = cmdRoute(os.Args[2:]) + case "stopcost": + err = cmdStopCost(os.Args[2:]) + case "optimize": + err = cmdOptimize(os.Args[2:]) + default: + usage() + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: routectl {route|stopcost|optimize} [flags]") + os.Exit(2) +} + +type common struct { + backend string + from, to string +} + +func (c *common) parse(fs *flag.FlagSet) { + fs.StringVar(&c.backend, "backend", "valhalla", "valhalla|osrm") + fs.StringVar(&c.from, "from", "", "lat,lon origin") + fs.StringVar(&c.to, "to", "", "lat,lon destination") +} + +func (c *common) router() route.Router { + switch c.backend { + case "valhalla": + return route.NewValhalla("https://valhalla1.openstreetmap.de") + case "osrm": + return route.NewOSRM("http://localhost:5000") + default: + panic("bad backend " + c.backend) + } +} + +func parsePt(s string) (lat, lon float64) { + parts := strings.Split(s, ",") + lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + return +} + +func point(s string) geo.Point { + lat, lon := parsePt(s) + return geo.Point{Lat: lat, Lon: lon} +} + +func parseStops(args []string) []plan.Stop { + var stops []plan.Stop + for _, a := range args { + parts := strings.Split(a, ":") + lat, lon := parsePt(parts[0]) + s := plan.Stop{At: geo.Point{Lat: lat, Lon: lon}, ID: parts[0]} + if len(parts) > 1 && parts[1] != "" { + s.DwellMin, _ = strconv.Atoi(parts[1]) + } + if len(parts) > 2 && parts[2] != "" { + s.Name = parts[2] + s.ID = parts[2] + } + stops = append(stops, s) + } + return stops +} + +func fmtDur(sec float64) string { + m := int(0.5 + sec/60) + return fmt.Sprintf("%dh%02dm", m/60, m%60) +} + +// ---- commands ------------------------------------------------------- + +func cmdRoute(args []string) error { + fs := flag.NewFlagSet("route", flag.ExitOnError) + var c common + c.parse(fs) + var walk bool + fs.BoolVar(&walk, "walk", false, "walking profile") + fs.Parse(args) + if c.from == "" || c.to == "" { + return fmt.Errorf("--from and --to required") + } + r, err := c.router().Route(context.Background(), prof(walk), []geo.Point{point(c.from), point(c.to)}) + if err != nil { + return err + } + fmt.Printf("backend: %s\n", c.router().Name()) + fmt.Printf("time: %s (%.0f s)\n", fmtDur(r.Duration), r.Duration) + fmt.Printf("distance: %.1f km\n", r.Distance/1000) + if len(r.Geometry) > 0 { + fmt.Printf("geometry: %d points (exact corridor available)\n", len(r.Geometry)) + } else { + fmt.Printf("geometry: none (chord-corridor fallback, low confidence)\n") + } + return nil +} + +func cmdStopCost(args []string) error { + fs := flag.NewFlagSet("stopcost", flag.ExitOnError) + var c common + c.parse(fs) + var stopArgs []string + fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error { + stopArgs = append(stopArgs, v) + return nil + }) + fs.Parse(args) + if c.from == "" || c.to == "" || len(stopArgs) == 0 { + return fmt.Errorf("--from, --to, and --stop required") + } + stops := parseStops(stopArgs) + m, err := buildMatrix(c.router(), c.from, c.to, stops) + if err != nil { + return err + } + for i, s := range stops { + cost := plan.StopCost(m, i, s) + fmt.Printf("%-18s detour=%3d min dwell=%3d min overhead=%2d min total=%3d min (direct %d min)\n", + s.ID, cost.DetourMin, cost.DwellMin, cost.OverheadMin, cost.TotalMin, cost.DirectMin) + } + return nil +} + +func cmdOptimize(args []string) error { + fs := flag.NewFlagSet("optimize", flag.ExitOnError) + var c common + c.parse(fs) + var stopArgs []string + var k, budget int + fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error { + stopArgs = append(stopArgs, v) + return nil + }) + fs.IntVar(&k, "k", 2, "number of stops") + fs.IntVar(&budget, "budget", 0, "max total minutes (0 = none)") + fs.Parse(args) + if c.from == "" || c.to == "" || len(stopArgs) == 0 { + return fmt.Errorf("--from, --to, and --stop required") + } + stops := parseStops(stopArgs) + m, err := buildMatrix(c.router(), c.from, c.to, stops) + if err != nil { + return err + } + res := plan.OptimizeStops(m, stops, k, budget) + names := make([]string, len(res.Order)) + for i, si := range res.Order { + names[i] = stops[si].ID + } + fmt.Printf("order: %s\n", strings.Join(names, " -> ")) + fmt.Printf("detour: %d min\n", res.DetourMin) + fmt.Printf("total: %d min (budget %d, feasible=%v, relaxed=%v, exhaustive=%v)\n", + res.TotalMin, budget, res.Feasible, res.Relaxed, res.Exhaustive) + return nil +} + +// ---- helpers -------------------------------------------------------- + +func prof(walk bool) route.Profile { + if walk { + return route.ProfileWalk + } + return route.ProfileDrive +} + +// buildMatrix builds the (n+2)-layout matrix: stops, then A, then C. +func buildMatrix(r route.Router, fromS, toS string, stops []plan.Stop) (route.Matrix, error) { + pts := make([]geo.Point, 0, len(stops)+2) + for _, s := range stops { + pts = append(pts, s.At) + } + pts = append(pts, point(fromS), point(toS)) + // Politeness delay for hosted backends. + delay := 150 * time.Millisecond + if _, ok := r.(*route.OSRM); ok { + delay = 0 + } + return route.BuildMatrix(context.Background(), r, route.ProfileDrive, pts, delay) +} diff --git a/router/go.mod b/router/go.mod new file mode 100644 index 0000000..0c8d017 --- /dev/null +++ b/router/go.mod @@ -0,0 +1,3 @@ +module maps/router + +go 1.27 diff --git a/router/internal/geo/geo.go b/router/internal/geo/geo.go new file mode 100644 index 0000000..533d46d --- /dev/null +++ b/router/internal/geo/geo.go @@ -0,0 +1,70 @@ +// Package geo holds the small, dependency-free geodesy used by the +// router/plan layers. Kept tiny on purpose: anything heavier belongs +// in the router backend, not here. +package geo + +import "math" + +// Point is a lat/lon in degrees. +type Point struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +const R = 6371000.0 // meters + +func rad(d float64) float64 { return d * math.Pi / 180 } + +// Meters is great-circle distance in meters. +func Meters(a, b Point) float64 { + dLat := rad(b.Lat - a.Lat) + dLon := rad(b.Lon - a.Lon) + q := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2) + return 2 * R * math.Asin(math.Sqrt(q)) +} + +// MinAt walking minutes at the given km/h, rounded up to whole minutes. +func MinAt(meters float64, kmh float64) int { + if meters <= 0 { + return 0 + } + return int(math.Ceil(meters / 1000.0 / (kmh / 60.0))) +} + +// distToSeg is the planar (equirect, local) distance from p to segment ab, in meters. +// Accurate to <0.1% over the extents we use (tens of km). +func distToSeg(p, a, b Point) float64 { + x := func(q Point) [2]float64 { + lat0 := rad(a.Lat) + return [2]float64{rad(q.Lon) * math.Cos(lat0) * R, rad(q.Lat) * R} + } + px, ax, bx := x(p), x(a), x(b) + dx, dy := bx[0]-ax[0], bx[1]-ax[1] + l2 := dx*dx + dy*dy + t := 0.0 + if l2 > 0 { + t = ((px[0]-ax[0])*dx + (px[1]-ax[1])*dy) / l2 + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + } + cx, cy := ax[0]+t*dx, ax[1]+t*dy + return math.Hypot(px[0]-cx, px[1]-cy) +} + +// DistToPolylineMeters is the minimum distance from p to any segment of the polyline. +func DistToPolylineMeters(p Point, poly []Point) float64 { + best := float64(1e18) + for i := 0; i+1 < len(poly); i++ { + if d := distToSeg(p, poly[i], poly[i+1]); d < best { + best = d + } + } + if len(poly) == 1 { + best = Meters(p, poly[0]) + } + return best +} diff --git a/router/internal/geo/geo_test.go b/router/internal/geo/geo_test.go new file mode 100644 index 0000000..2cb4aeb --- /dev/null +++ b/router/internal/geo/geo_test.go @@ -0,0 +1,34 @@ +package geo + +import "testing" + +func TestMeters(t *testing.T) { + // NYC → Boston ≈ 305 km + d := Meters(Point{Lat: 40.7128, Lon: -74.006}, Point{Lat: 42.3601, Lon: -71.0589}) + if d < 300_000 || d > 320_000 { + t.Errorf("NYC-Boston = %.0f m, want ~305km", d) + } +} + +func TestDistToPolyline(t *testing.T) { + poly := []Point{{Lat: 0, Lon: 0}, {Lat: 10, Lon: 0}} // north-south at lon 0 + // 1 deg lon at equator ≈ 111.3 km + d := DistToPolylineMeters(Point{Lat: 5, Lon: 1}, poly) + if d < 110_000 || d > 115_000 { + t.Errorf("distance = %.0f m, want ~111km", d) + } + // on the line + if d := DistToPolylineMeters(Point{Lat: 5, Lon: 0}, poly); d > 100 { + t.Errorf("on-line distance = %.0f m, want ~0", d) + } + // past the end: distance to endpoint + if d := DistToPolylineMeters(Point{Lat: 12, Lon: 0}, poly); d < 200_000 || d > 240_000 { + t.Errorf("past-end distance = %.0f m, want ~222km", d) + } +} + +func TestMinAt(t *testing.T) { + if m := MinAt(4600, 4.6); m != 60 { + t.Errorf("MinAt(4600m, 4.6kmh) = %d, want 60", m) + } +} diff --git a/router/internal/plan/corridor.go b/router/internal/plan/corridor.go new file mode 100644 index 0000000..1154e72 --- /dev/null +++ b/router/internal/plan/corridor.go @@ -0,0 +1,69 @@ +package plan + +import ( + "maps/router/internal/geo" + "maps/router/internal/route" +) + +// Corridor is the spatial constraint for "along the route" search +// (DESIGN.md R2). It is a buffer band around the routed polyline. +// +// Quality depends on the router: local OSRM returns full geometry +// (exact corridor); hosted Valhalla does not, so we fall back to a +// band around the great-circle chord (coarse, flagged). +type Corridor struct { + Polyline []geo.Point // may be nil → chord fallback + Origin geo.Point + Dest geo.Point + BufferM float64 // band half-width + FromRoute *route.Route +} + +// NewCorridor wraps a computed route. BufferM is the corridor half-width. +func NewCorridor(r *route.Route, bufferM float64) *Corridor { + c := &Corridor{ + Polyline: r.Geometry, + BufferM: bufferM, + FromRoute: r, + } + if len(r.Legs) > 0 { + // endpoints aren't in the Route struct; caller sets them + } + return c +} + +// SetEndpoints records A and C (needed for the chord fallback). +func (c *Corridor) SetEndpoints(a, dst geo.Point) { + c.Origin = a + c.Dest = dst +} + +// Exact reports whether the corridor is built on the true route +// geometry (vs the chord fallback). +func (c *Corridor) Exact() bool { return len(c.Polyline) >= 2 } + +// Contains reports whether p lies within the corridor band. +func (c *Corridor) Contains(p geo.Point) bool { + if c.Exact() { + return geo.DistToPolylineMeters(p, c.Polyline) <= c.BufferM + } + // Chord fallback: distance to the A→C great-circle chord. + return geo.DistToPolylineMeters(p, []geo.Point{c.Origin, c.Dest}) <= c.BufferM +} + +// ChordSample returns a sampled chord polyline (for map drawing when +// the router gave no geometry). +func (c *Corridor) ChordSample(n int) []geo.Point { + if c.Exact() { + return c.Polyline + } + poly := make([]geo.Point, 0, n+1) + for i := 0; i <= n; i++ { + t := float64(i) / float64(n) + poly = append(poly, geo.Point{ + Lat: c.Origin.Lat + t*(c.Dest.Lat-c.Origin.Lat), + Lon: c.Origin.Lon + t*(c.Dest.Lon-c.Origin.Lon), + }) + } + return poly +} diff --git a/router/internal/plan/corridor_test.go b/router/internal/plan/corridor_test.go new file mode 100644 index 0000000..8689959 --- /dev/null +++ b/router/internal/plan/corridor_test.go @@ -0,0 +1,57 @@ +package plan + +import ( + "testing" + + "maps/router/internal/geo" + "maps/router/internal/route" +) + +func TestCorridorExact(t *testing.T) { + // route along a vertical line at lon=0, lat 0..10, buffer 100 km + r := &route.Route{Geometry: []geo.Point{ + {Lat: 0, Lon: 0}, + {Lat: 5, Lon: 0}, + {Lat: 10, Lon: 0}, + }} + c := NewCorridor(r, 100_000) + if !c.Exact() { + t.Fatal("expected exact corridor") + } + // 50 km off the line: ~0.45 deg lon at equator + if !c.Contains(geo.Point{Lat: 5, Lon: 0.45}) { + t.Error("point 50km off line should be inside 100km buffer") + } + // 150 km off: ~1.35 deg + if c.Contains(geo.Point{Lat: 5, Lon: 1.35}) { + t.Error("point 150km off line should be outside 100km buffer") + } + // 200 km north of the end + if c.Contains(geo.Point{Lat: 12, Lon: 0}) { + t.Error("point 200km past endpoint should be outside") + } +} + +func TestCorridorChordFallback(t *testing.T) { + r := &route.Route{} // no geometry + c := NewCorridor(r, 200_000) + c.SetEndpoints(geo.Point{Lat: 44, Lon: -71.65}, geo.Point{Lat: 40.7, Lon: -73.97}) + if c.Exact() { + t.Fatal("expected chord fallback") + } + // midpoint of the chord is inside + mid := geo.Point{Lat: 42.35, Lon: -72.81} + if !c.Contains(mid) { + t.Error("chord midpoint should be inside band") + } + // Boston (42.36,-71.06) is ~150km east of the chord midpoint... + // distance from Boston to the chord: chord at lat 42.36 → t≈0.498, lon ≈ -72.81 + // Boston lon -71.06 → Δlon 1.75 deg ≈ 148 km at cos(42.36) → inside 200km? + // Let's just assert a clearly-far point is outside: NYC (40.7,-74.0) is the endpoint. + far := geo.Point{Lat: 44.0, Lon: -70.0} // 150km NE of origin + if c.Contains(far) { + // distance to chord endpoint is sqrt? origin is (44,-71.65): Δlon 1.65 deg ≈ 132km, + // distance to segment ~132km < 200km → inside. This is expected behavior. + t.Log("far point inside chord band (expected: chord band is coarse)") + } +} diff --git a/router/internal/plan/integration_test.go b/router/internal/plan/integration_test.go new file mode 100644 index 0000000..99ccc3b --- /dev/null +++ b/router/internal/plan/integration_test.go @@ -0,0 +1,110 @@ +//go:build integration + +// Integration tests hit a live router (hosted Valhalla by default, or a +// local OSRM via ROUTER_URL for the osrm backend). They verify the +// client plumbing and the stop_cost end-to-end on the real road network. +// +// Run: go test -tags integration ./... +package plan + +import ( + "context" + "os" + "testing" + "time" + + "maps/router/internal/geo" + "maps/router/internal/route" +) + +func liveRouter(t *testing.T) route.Router { + backend := os.Getenv("ROUTER_BACKEND") + switch backend { + case "osrm": + return route.NewOSRM(os.Getenv("ROUTER_URL")) + default: + return route.NewValhalla("https://valhalla1.openstreetmap.de") + } +} + +var ( + lincoln = geo.Point{Lat: 44.054, Lon: -71.650} + queens = geo.Point{Lat: 40.729, Lon: -73.966} +) + +func TestLiveDirectRoute(t *testing.T) { + t.Parallel() + r := liveRouter(t) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, queens}) + if err != nil { + t.Fatalf("route: %v", err) + } + // Lincoln NH -> Queens NY is a 5.25-5.75h drive (I-93/90/495/290). + // Anything outside this band means the backend or the dataset is wrong. + mins := rt.Duration / 60 + if mins < 300 || mins > 400 { + t.Errorf("direct time = %.0f min, expected in [300, 400]", mins) + } + if rt.Distance < 480_000 || rt.Distance > 600_000 { + t.Errorf("distance = %.0f m, expected in [480km, 600km]", rt.Distance) + } + t.Logf("%s: %d min, %.0f km", r.Name(), int(mins), rt.Distance/1000) +} + +// TestLiveRouteAvoidsBoston is the regression test for the user's +// "the router, not the model, knows the route" finding: the fastest +// Lincoln->Queens drive goes via I-90/I-495/I-290 (Worcester corridor), +// NOT through Boston. +func TestLiveRouteAvoidsBoston(t *testing.T) { + t.Parallel() + r := liveRouter(t) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, queens}) + if err != nil { + t.Fatalf("route: %v", err) + } + boston := geo.Point{Lat: 42.36, Lon: -71.06} + var dist float64 + if len(rt.Geometry) >= 2 { + dist = geo.DistToPolylineMeters(boston, rt.Geometry) + } else if !rt.BBox.Empty() { + dist = rt.BBox.DistMeters(boston) + t.Logf("using bbox distance (no geometry from backend)") + } else { + t.Skip("no geometry or bbox available") + } + if dist < 15_000 { + t.Errorf("route comes within %.1f km of Boston; expected the I-495/I-290 corridor (>= 15 km)", dist/1000) + } + t.Logf("Boston clearance: %.1f km", dist/1000) +} + +// TestLiveStopCost exercises the full stop_cost pipeline on the real +// network: Cascade Falls (Worcester) sits on the I-495 corridor and +// must have a small detour; Mount Major (Alton NH) is far off it. +func TestLiveStopCost(t *testing.T) { + t.Parallel() + r := liveRouter(t) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + defer cancel() + + cascade := geo.Point{Lat: 42.2767, Lon: -71.806} + mtmajor := geo.Point{Lat: 43.642, Lon: -71.156} + pts := []geo.Point{cascade, mtmajor, lincoln, queens} + m, err := route.BuildMatrix(ctx, r, route.ProfileDrive, pts, 200*time.Millisecond) + if err != nil { + t.Fatalf("matrix: %v", err) + } + cascadeCost := StopCost(m, 0, Stop{ID: "cascade", DwellMin: 60}) + majorCost := StopCost(m, 1, Stop{ID: "mtmajor", DwellMin: 120}) + t.Logf("cascade detour = %d min; mtmajor detour = %d min", cascadeCost.DetourMin, majorCost.DetourMin) + if cascadeCost.DetourMin > 20 { + t.Errorf("Cascade Falls (on I-495 corridor) detour = %d min, want <= 20", cascadeCost.DetourMin) + } + if majorCost.DetourMin < 40 { + t.Errorf("Mount Major (off corridor) detour = %d min, want >= 40", majorCost.DetourMin) + } +} diff --git a/router/internal/plan/optimize.go b/router/internal/plan/optimize.go new file mode 100644 index 0000000..755c9e3 --- /dev/null +++ b/router/internal/plan/optimize.go @@ -0,0 +1,189 @@ +package plan + +import "maps/router/internal/route" + +// OptimizeResult is the best order of k stops. +type OptimizeResult struct { + Order []int // indices into the stops slice + DetourMin int + TotalMin int + Exhaustive bool // true when the optimum came from full search + Feasible bool // true when TotalMin <= budgetMin (when a budget was given) + Relaxed bool // true when we fell back to fewer than k stops +} + +// OptimizeStops picks the best ordered subset of k stops minimizing +// SeqCost, subject to TotalMin <= budgetMin when budgetMin > 0. +// m must be the (n+2)-layout matrix for stops. +// +// Selection semantics (k is the user's request, "give me 2 hikes"): +// 1. best feasible exactly-k +// 2. else best feasible with fewer stops (Relaxed) +// 3. else best exactly-k ignoring the budget (Feasible=false) +// +// n <= 10: full enumeration (this is also the ground truth for evals). +// n > 10: greedy best-insertion (exact for k=1). +func OptimizeStops(m route.Matrix, stops []Stop, k, budgetMin int) OptimizeResult { + n := len(stops) + if k < 0 || k > n { + k = n + } + if k == 0 { + return OptimizeResult{Feasible: true, Exhaustive: true} + } + if n <= 10 { + return optimizeExhaustive(m, stops, k, budgetMin) + } + return optimizeGreedy(m, stops, k, budgetMin) +} + +type cand struct { + order []int + total int + detour int +} + +func feas(c cand, budgetMin int) bool { return budgetMin <= 0 || c.total <= budgetMin } + +func lowerCost(a, b cand) bool { + if a.total != b.total { + return a.total < b.total + } + return a.detour < b.detour +} + +func pickBest(cands []cand, budgetMin, wantSize int) (cand, bool) { + var best cand + found := false + for _, c := range cands { + if wantSize >= 0 && len(c.order) != wantSize { + continue + } + if !feas(c, budgetMin) { + continue + } + if !found || lowerCost(c, best) { + best, found = c, true + } + } + return best, found +} + +func optimizeExhaustive(m route.Matrix, stops []Stop, k, budgetMin int) OptimizeResult { + n := len(stops) + // Enumerate ALL permutations of every subset of size k. + var all []cand + var rec func(order []int, used []bool) + rec = func(order []int, used []bool) { + if len(order) == k { + detour, total := SeqCost(m, order, stops) + if total >= 0 { + all = append(all, cand{order: append([]int(nil), order...), total: total, detour: detour}) + } + return + } + for i := 0; i < n; i++ { + if !used[i] { + used[i] = true + rec(append(order, i), used) + used[i] = false + } + } + } + rec(nil, make([]bool, n)) + + // 1. feasible exactly-k + if c, ok := pickBest(all, budgetMin, k); ok { + return OptimizeResult{Order: c.order, DetourMin: c.detour, TotalMin: c.total, Exhaustive: true, Feasible: true} + } + // 2. feasible with fewer: re-enumerate smaller sizes + if budgetMin > 0 { + var less []cand + var rec2 func(start, size int, order []int, used []bool) + rec2 = func(start, size int, order []int, used []bool) { + if len(order) == size { + detour, total := SeqCost(m, order, stops) + if total >= 0 && feas(cand{total: total}, budgetMin) { + less = append(less, cand{order: append([]int(nil), order...), total: total, detour: detour}) + } + return + } + for i := start; i < n; i++ { + if !used[i] { + used[i] = true + rec2(i+1, size, append(order, i), used) + used[i] = false + } + } + } + for size := 1; size < k; size++ { + rec2(0, size, nil, make([]bool, n)) + } + // best among feasible smaller (larger size preferred on tie? no — just lowest total) + if c, ok := pickBest(less, budgetMin, -1); ok { + return OptimizeResult{Order: c.order, DetourMin: c.detour, TotalMin: c.total, Exhaustive: true, Feasible: true, Relaxed: true} + } + } + // 3. best exactly-k ignoring budget + var bestK cand + found := false + for _, c := range all { + if !found || lowerCost(c, bestK) { + bestK, found = c, true + } + } + if !found { + return OptimizeResult{DetourMin: -1, TotalMin: -1, Exhaustive: true} + } + return OptimizeResult{Order: bestK.order, DetourMin: bestK.detour, TotalMin: bestK.total, Exhaustive: true, Feasible: budgetMin <= 0} +} + +func optimizeGreedy(m route.Matrix, stops []Stop, k, budgetMin int) OptimizeResult { + n := len(stops) + order := []int{} + used := make([]bool, n) + for len(order) < k { + _, curTotal := SeqCost(m, order, stops) + if curTotal < 0 { + break + } + bestTotal := int(^uint(0) >> 1) + bestStop, bestPos := -1, -1 + for s := 0; s < n; s++ { + if used[s] { + continue + } + for pos := 0; pos <= len(order); pos++ { + tri := make([]int, 0, len(order)+1) + tri = append(tri, order[:pos]...) + tri = append(tri, s) + tri = append(tri, order[pos:]...) + _, total := SeqCost(m, tri, stops) + if total < 0 { + continue + } + if budgetMin > 0 && total > budgetMin { + continue + } + if total < bestTotal { + bestTotal = total + bestStop, bestPos = s, pos + } + } + } + if bestStop < 0 { + break + } + used[bestStop] = true + order = append(order[:bestPos], append([]int{bestStop}, order[bestPos:]...)...) + } + detour, total := SeqCost(m, order, stops) + res := OptimizeResult{Order: order, DetourMin: detour, TotalMin: total, Exhaustive: false} + if len(order) < k { + res.Relaxed = true + } + if total >= 0 { + res.Feasible = budgetMin <= 0 || total <= budgetMin + } + return res +} diff --git a/router/internal/plan/plan_test.go b/router/internal/plan/plan_test.go new file mode 100644 index 0000000..38fbaf2 --- /dev/null +++ b/router/internal/plan/plan_test.go @@ -0,0 +1,145 @@ +package plan + +import ( + "testing" + + "maps/router/internal/route" +) + +// synthMatrix builds a (n+2)-layout matrix from a full (n+2)² symmetric +// matrix in seconds. +func synthMatrix(vals [][]float64) route.Matrix { + m := make(route.Matrix, len(vals)) + for i := range m { + m[i] = append([]float64(nil), vals[i]...) + } + return m +} + +// 3 stops, A=3, C=4. +// Travel times (seconds): +// +// A→C direct: 3600 (60 min) +// A→S0 600, S0→C 3600 → detour 600 (10 min) +// A→S1 1200, S1→C 3600 → detour 1200 (20 min) +// A→S2 1800, S2→C 3600 → detour 1800 (30 min) +// S0→S1 600, S1→S2 600, S0→S2 1200 +func testMatrix() route.Matrix { + return synthMatrix([][]float64{ + {0, 600, 1200, 600, 3600}, // S0 + {600, 0, 600, 1200, 3600}, // S1 + {1200, 600, 0, 1800, 3600}, // S2 + {600, 1200, 1800, 0, 3600}, // A + {3600, 3600, 3600, 3600, 0}, // C + }) +} + +var testStops = []Stop{ + {ID: "s0", Name: "S0", DwellMin: 30}, + {ID: "s1", Name: "S1", DwellMin: 30}, + {ID: "s2", Name: "S2", DwellMin: 30}, +} + +func TestStopCost(t *testing.T) { + m := testMatrix() + c := StopCost(m, 0, testStops[0]) + if c.DetourMin != 10 { + t.Errorf("S0 detour = %d, want 10", c.DetourMin) + } + if c.DirectMin != 60 { + t.Errorf("direct = %d, want 60", c.DirectMin) + } + if c.TotalMin != 10+30+10 { + t.Errorf("total = %d, want 50", c.TotalMin) + } + c2 := StopCost(m, 2, testStops[2]) + if c2.DetourMin != 30 { + t.Errorf("S2 detour = %d, want 30", c2.DetourMin) + } +} + +func TestSeqCostOrderMatters(t *testing.T) { + m := testMatrix() + // A→S0→S1→C: 600+600+3600 = 4800 vs 3600 → detour 1200 s = 20 min + d, total := SeqCost(m, []int{0, 1}, testStops) + if d != 20 { + t.Errorf("detour = %d, want 20", d) + } + if total != 20+30+10+30+10 { + t.Errorf("total = %d, want 100", total) + } +} + +func TestOptimizeExhaustivePicksmm(t *testing.T) { + m := testMatrix() + // k=1: best single stop is S0 (detour 10 + dwell 30 + ovh 10 = 50) + r := OptimizeStops(m, testStops, 1, 0) + if len(r.Order) != 1 || r.Order[0] != 0 { + t.Fatalf("k=1 order = %v, want [0]", r.Order) + } + if r.TotalMin != 50 { + t.Errorf("k=1 total = %d, want 50", r.TotalMin) + } + // k=2: best pair: [0,1]: detour 20, total 20+80 = 100 + // [0,2]: A→S0(600)+S0→S2(1200)+S2→C(3600)=5400 → detour 30 min, total 30+80=110 + // [1,2]: 1200+600+3600=5400 → detour 30, total 110 + r2 := OptimizeStops(m, testStops, 2, 0) + if len(r2.Order) != 2 || r2.Order[0] != 0 || r2.Order[1] != 1 { + t.Fatalf("k=2 order = %v, want [0 1]", r2.Order) + } + if r2.TotalMin != 100 { + t.Errorf("k=2 total = %d, want 100", r2.TotalMin) + } +} + +func TestOptimizeBudgetFeasibilityFirst(t *testing.T) { + m := testMatrix() + // budget 55 min: k=1 → S0 (50) feasible; k=2 → [0,1] (100) infeasible, + // so best feasible is k=1 S0 (50). + r := OptimizeStops(m, testStops, 2, 55) + if len(r.Order) != 1 || r.Order[0] != 0 { + t.Fatalf("budget 55: order = %v, want [0]", r.Order) + } + if r.TotalMin != 50 { + t.Errorf("total = %d, want 50", r.TotalMin) + } +} + +// TestOptimizeGreedyMatchesExhaustive checks the greedy fallback +// (n > 10) against the exhaustive optimum on a small-ish case forced +// through both code paths. +func TestOptimizeGreedyMatchesExhaustive(t *testing.T) { + // 12 stops on a line: A→C 60 min, stops evenly placed, dwell 20 each. + n := 12 + vals := make([][]float64, n+2) + // place on a line: position 0..13; A at 0, C at 13, stops at 1..12. + pos := make([]float64, n+2) + for i := 0; i < n; i++ { + pos[i] = float64(i + 1) + } + pos[n] = 0 + pos[n+1] = 13 + for i := 0; i < n+2; i++ { + vals[i] = make([]float64, n+2) + for j := 0; j < n+2; j++ { + d := pos[i] - pos[j] + if d < 0 { + d = -d + } + vals[i][j] = d * 600 // 10 min per unit + } + } + m := synthMatrix(vals) + stops := make([]Stop, n) + for i := range stops { + stops[i] = Stop{ID: string(rune('a' + i)), DwellMin: 20} + } + k := 3 + exp := optimizeExhaustive(m, stops, k, 0) + // force greedy by calling with n > exhaustiveMax + got := optimizeGreedy(m, stops, k, 0) + if exp.TotalMin != got.TotalMin { + t.Errorf("greedy total %d != exhaustive total %d (order %v vs %v)", + got.TotalMin, exp.TotalMin, got.Order, exp.Order) + } +} diff --git a/router/internal/plan/stopcost.go b/router/internal/plan/stopcost.go new file mode 100644 index 0000000..2089251 --- /dev/null +++ b/router/internal/plan/stopcost.go @@ -0,0 +1,110 @@ +// Package plan implements the route-aware planning primitives from +// DESIGN.md "Driving trips": stop_cost, optimize_stops, and the +// corridor. All numbers here are router arithmetic (computed +// provenance) — the LLM is never allowed to substitute estimates. +// +// Matrix convention used throughout: for n stops the matrix has n+2 +// rows/cols: indices 0..n-1 are the stops (in slice order), index n is +// the origin A, index n+1 is the destination C. +package plan + +import ( + "maps/router/internal/geo" + "maps/router/internal/route" +) + +// Stop is a candidate stop with a location and a dwell budget. +type Stop struct { + ID string + Name string + At geo.Point + DwellMin int // expected time spent at the stop (user-set or sourced) + OverheadMin int // park/walk-to-entrance/leave; WithDefaults fills 10 +} + +// WithDefaults applies the DESIGN.md default overhead (10 min) when unset. +func (s Stop) WithDefaults() Stop { + if s.OverheadMin == 0 { + s.OverheadMin = 10 + } + return s +} + +// Cost is the stop_cost result: pure router arithmetic. +// +// detour = route(A,C via B) − route(A,C) +// total = detour + dwell + overhead +// +// A stop is "free" along the corridor iff detour ≈ 0. +type Cost struct { + Stop Stop + DetourMin int // extra travel time vs direct A→C, minutes + DwellMin int + OverheadMin int + TotalMin int // DetourMin + DwellMin + OverheadMin + DirectMin int // direct A→C duration in minutes (context) +} + +// StopCost computes the cost of inserting one stop between A and C. +// m is the (n+2)-layout matrix; i is the stop's index. +func StopCost(m route.Matrix, i int, s Stop) Cost { + s = s.WithDefaults() + n := len(m) - 2 + aIdx, cIdx := n, n+1 + direct := m[aIdx][cIdx] + via := m[aIdx][i] + m[i][cIdx] + detour := 0.0 + if direct > 0 && via > 0 { + detour = via - direct + if detour < 0 && detour > -30 { // router noise, seconds + detour = 0 + } + } + dm := int(0.5 + detour/60) + if dm < 0 { + dm = 0 + } + return Cost{ + Stop: s, + DetourMin: dm, + DwellMin: s.DwellMin, + OverheadMin: s.OverheadMin, + TotalMin: dm + s.DwellMin + s.OverheadMin, + DirectMin: int(0.5 + direct/60), + } +} + +// SeqCost computes (detourMin, totalMin) for an ordered subset of stops +// between A and C. Returns (-1, -1) when any leg is unroutable. +func SeqCost(m route.Matrix, order []int, stops []Stop) (detourMin, totalMin int) { + n := len(m) - 2 + aIdx, cIdx := n, n+1 + direct := m[aIdx][cIdx] + if direct < 0 { + return -1, -1 + } + via := 0.0 + prev := aIdx + for _, si := range order { + d := m[prev][si] + if d < 0 { + return -1, -1 + } + via += d + prev = si + } + if d := m[prev][cIdx]; d < 0 { + return -1, -1 + } + via += m[prev][cIdx] + detour := int(0.5 + (via-direct)/60) + if detour < 0 { + detour = 0 + } + total := detour + for _, si := range order { + s := stops[si].WithDefaults() + total += s.DwellMin + s.OverheadMin + } + return detour, total +} diff --git a/router/internal/route/osrm.go b/router/internal/route/osrm.go new file mode 100644 index 0000000..48d1770 --- /dev/null +++ b/router/internal/route/osrm.go @@ -0,0 +1,136 @@ +package route + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "maps/router/internal/geo" +) + +// OSRM is a client for a local OSRM backend (osrm-routed), e.g. +// http://localhost:5000. Returns full route geometry. +type OSRM struct { + BaseURL string + HTTP *http.Client + profile string + name string +} + +func NewOSRM(baseURL string) *OSRM { + return &OSRM{ + BaseURL: baseURL, + HTTP: &http.Client{Timeout: 60 * time.Second}, + profile: "driving", + name: "osrm(" + baseURL + ")", + } +} + +func (o *OSRM) Name() string { return o.name } + +func (o *OSRM) osrmProfile(p Profile) string { + if p == ProfileWalk { + return "walking" + } + return "driving" +} + +type osrmRoute struct { + Code string `json:"code"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + Legs []struct { + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + } `json:"legs"` + Geometry string `json:"geometry"` +} + +func (o *OSRM) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) { + if len(pts) < 2 { + return nil, fmt.Errorf("osrm: need >= 2 points, got %d", len(pts)) + } + var coords []string + for _, p := range pts { + coords = append(coords, fmt.Sprintf("%.6f,%.6f", p.Lon, p.Lat)) + } + u := fmt.Sprintf("%s/route/v1/%s/%s?overview=false&alternatives=false&geometries=geojson", + o.BaseURL, o.osrmProfile(profile), strings.Join(coords, ";")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + resp, err := o.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var or osrmRoute + if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { + return nil, fmt.Errorf("osrm: bad response: %w", err) + } + if or.Code != "Ok" { + return nil, fmt.Errorf("osrm: %s", or.Code) + } + out := &Route{Duration: or.Duration, Distance: or.Distance} + for _, l := range or.Legs { + out.Legs = append(out.Legs, Leg{Duration: l.Duration, Distance: l.Distance}) + } + // OSRM geometries=geojson returns a GeoJSON LineString. + var gj struct { + Geometry struct { + Coords [][]float64 `json:"coordinates"` + } `json:"geometry"` + } + if or.Geometry != "" && json.Unmarshal([]byte(or.Geometry), &gj) == nil { + for _, c := range gj.Geometry.Coords { + out.Geometry = append(out.Geometry, geo.Point{Lat: c[1], Lon: c[0]}) + } + } + return out, nil +} + +// OSRMTable builds a matrix via the OSRM table service (one call) when +// available; the generic BuildMatrix (pairwise) works on any backend. +func (o *OSRM) Table(ctx context.Context, profile Profile, pts []geo.Point) (Matrix, error) { + var coords []string + for _, p := range pts { + coords = append(coords, fmt.Sprintf("%.6f,%.6f", p.Lon, p.Lat)) + } + u := fmt.Sprintf("%s/table/v1/%s/%s?annotations=duration", + o.BaseURL, o.osrmProfile(profile), strings.Join(coords, ";")) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + resp, err := o.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var tr struct { + Code string `json:"code"` + Durations [][]float64 `json:"durations"` + } + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return nil, err + } + if tr.Code != "Ok" { + return nil, fmt.Errorf("osrm table: %s", tr.Code) + } + m := make(Matrix, len(pts)) + for i := range m { + m[i] = make([]float64, len(pts)) + for j := range m[i] { + if tr.Durations[i][j] > 0 { + m[i][j] = tr.Durations[i][j] + } else { + m[i][j] = -1 + } + } + } + return m, nil +} + +var _ = url.Values{} // keep net/url import for future query building diff --git a/router/internal/route/route.go b/router/internal/route/route.go new file mode 100644 index 0000000..a2bc205 --- /dev/null +++ b/router/internal/route/route.go @@ -0,0 +1,126 @@ +// Package route defines the Router interface and the two backends: +// a hosted Valhalla (works today, no geometry) and local OSRM +// (full geometry, self-hosted — see scripts/setup-osrm.sh). +// +// Design rule (DESIGN.md R1/R4): the route is computed, never narrated. +// Every Duration/Distance value returned here is `computed` provenance. +package route + +import ( + "context" + "math" + "time" + + "maps/router/internal/geo" +) + +// Profile is the routing profile. +type Profile string + +const ( + ProfileDrive Profile = "drive" + ProfileWalk Profile = "walk" +) + +// Route is the result of routing through an ordered set of points. +type Route struct { + // Duration is total travel time in seconds (computed by the router). + Duration float64 + // Distance is total path length in meters. + Distance float64 + // BBox is the bounding box of the routed path (from the router's + // summary). Zero values if the backend doesn't provide one. + BBox BBox + // Geometry is the routed polyline. May be nil for backends that + // don't return it (hosted Valhalla) — callers must degrade + // gracefully (chord corridor, flagged low-confidence). + Geometry []geo.Point + // Legs holds per-segment durations for multi-point routes. + // len(Legs) == len(points)-1 when present. + Legs []Leg +} + +// Leg is one segment between consecutive waypoints. +type Leg struct { + Duration float64 // seconds + Distance float64 // meters +} + +// BBox is a latitude/longitude bounding box in degrees. +type BBox struct { + MinLat, MaxLat, MinLon, MaxLon float64 +} + +// Empty reports whether the box is unset. +func (b BBox) Empty() bool { return b == BBox{} } + +// DistMeters is the minimum distance from p to the box (0 if inside). +func (b BBox) DistMeters(p geo.Point) float64 { + dLat, dLon := 0.0, 0.0 + if p.Lat < b.MinLat { + dLat = b.MinLat - p.Lat + } else if p.Lat > b.MaxLat { + dLat = p.Lat - b.MaxLat + } + if p.Lon < b.MinLon { + dLon = b.MinLon - p.Lon + } else if p.Lon > b.MaxLon { + dLon = p.Lon - b.MaxLon + } + if dLat == 0 && dLon == 0 { + return 0 + } + mLat := dLat * 111190.0 + mLon := dLon * 111190.0 * math.Cos(rad(p.Lat)) + return math.Hypot(mLat, mLon) +} + +func rad(d float64) float64 { return d * math.Pi / 180 } + +// Router is the backend interface. +type Router interface { + // Route routes through pts in order and returns the combined trip. + Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) + // Name identifies the backend in reports ("valhalla-hosted", "osrm-local"). + Name() string +} + +// Matrix builds a full origin-destination matrix over pts using the +// router. m[i][j] is seconds; -1 means unroutable. Built with pairwise +// two-point Route calls — the single code path that works on both +// backends (Valhalla's sources_to_targets is capped at 150 km). +type Matrix [][]float64 + +func (m Matrix) From(i, j int) float64 { return m[i][j] } + +// BuildMatrix computes the matrix with a small politeness delay between +// calls when polite > 0 (hosted endpoints). +func BuildMatrix(ctx context.Context, r Router, profile Profile, pts []geo.Point, polite time.Duration) (Matrix, error) { + n := len(pts) + m := make(Matrix, n) + for i := range m { + m[i] = make([]float64, n) + m[i][i] = 0 + } + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + if i == j { + continue + } + if polite > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(polite): + } + } + rr, err := r.Route(ctx, profile, []geo.Point{pts[i], pts[j]}) + if err != nil { + m[i][j] = -1 + continue + } + m[i][j] = rr.Duration + } + } + return m, nil +} diff --git a/router/internal/route/valhalla.go b/router/internal/route/valhalla.go new file mode 100644 index 0000000..a2945be --- /dev/null +++ b/router/internal/route/valhalla.go @@ -0,0 +1,183 @@ +package route + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "maps/router/internal/geo" +) + +// Valhalla is a client for a Valhalla routing API +// (e.g. the hosted https://valhalla1.openstreetmap.de or a self-hosted +// valhalla instance). Note: hosted instances strip route geometry, so +// Route.Geometry will be nil. +type Valhalla struct { + BaseURL string + HTTP *http.Client + name string +} + +func NewValhalla(baseURL string) *Valhalla { + return &Valhalla{ + BaseURL: baseURL, + HTTP: &http.Client{Timeout: 60 * time.Second}, + name: "valhalla(" + baseURL + ")", + } +} + +func (v *Valhalla) Name() string { return v.name } + +func (v *Valhalla) costing(p Profile) string { + if p == ProfileWalk { + return "pedestrian" + } + return "auto" +} + +type vLocation struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type vRouteReq struct { + Locations []vLocation `json:"locations"` + Costing string `json:"costing"` + Polyline bool `json:"polyline"` +} + +type vLeg struct { + Summary struct { + Time float64 `json:"time"` + Length float64 `json:"length"` // km + } `json:"summary"` +} + +type vTrip struct { + Summary struct { + Time float64 `json:"time"` + Length float64 `json:"length"` // km + MinLat float64 `json:"min_lat"` + MaxLat float64 `json:"max_lat"` + MinLon float64 `json:"min_lon"` + MaxLon float64 `json:"max_lon"` + } `json:"summary"` + Legs []vLeg `json:"legs"` +} + +type vResp struct { + Trip vTrip `json:"trip"` + Message string `json:"message"` + Error string `json:"error"` +} + +func (v *Valhalla) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) { + if len(pts) < 2 { + return nil, fmt.Errorf("route: need >= 2 points, got %d", len(pts)) + } + req := vRouteReq{Costing: v.costing(profile), Polyline: true} + for _, p := range pts { + req.Locations = append(req.Locations, vLocation{p.Lat, p.Lon}) + } + body, _ := json.Marshal(req) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, v.BaseURL+"/route", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := v.HTTP.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return nil, err + } + var vr vResp + if err := json.Unmarshal(raw, &vr); err != nil { + return nil, fmt.Errorf("valhalla: bad response (%s): %q", resp.Status, string(raw[:min(len(raw), 200)])) + } + if vr.Message != "" { + return nil, fmt.Errorf("valhalla: %s", vr.Message) + } + if vr.Error != "" { + return nil, fmt.Errorf("valhalla: %s", vr.Error) + } + out := &Route{ + Duration: vr.Trip.Summary.Time, + Distance: vr.Trip.Summary.Length * 1000, + BBox: BBox{ + MinLat: vr.Trip.Summary.MinLat, MaxLat: vr.Trip.Summary.MaxLat, + MinLon: vr.Trip.Summary.MinLon, MaxLon: vr.Trip.Summary.MaxLon, + }, + } + for _, l := range vr.Trip.Legs { + out.Legs = append(out.Legs, Leg{Duration: l.Summary.Time, Distance: l.Summary.Length * 1000}) + } + // Geometry: decode encoded polyline if the instance returns it. + if pl, ok := decodePolylineField(raw); ok { + out.Geometry = pl + } + return out, nil +} + +// decodePolylineField checks for trip.polyline (encoded polyline v0) — +// hosted instances omit it; self-hosted valhalla with "polyline":true +// returns it. +func decodePolylineField(raw []byte) ([]geo.Point, bool) { + var probe struct { + Trip struct { + Polyline string `json:"polyline"` + } `json:"trip"` + } + if err := json.Unmarshal(raw, &probe); err != nil || probe.Trip.Polyline == "" { + return nil, false + } + return decodePolyline(probe.Trip.Polyline), true +} + +// decodePolyline decodes a Google encoded polyline (precision 1e-5 or 1e-6). +func decodePolyline(s string) []geo.Point { + var pts []geo.Point + lat, lon := 0, 0 + i := 0 + for i < len(s) { + var dlat, dlon int + shift := 0 + for { + b := int(s[i]) - 63 + i++ + dlat |= (b & 0x1f) << shift + if b&0x20 == 0 { + break + } + shift += 5 + } + shift = 0 + for { + b := int(s[i]) - 63 + i++ + dlon |= (b & 0x1f) << shift + if b&0x20 == 0 { + break + } + shift += 5 + } + lat += dlat&1 ^ dlat>>1 + lon += dlon&1 ^ dlon>>1 + pts = append(pts, geo.Point{Lat: float64(lat) / 1e5, Lon: float64(lon) / 1e5}) + } + return pts +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/router/scripts/setup-osrm.sh b/router/scripts/setup-osrm.sh new file mode 100644 index 0000000..d04f4df --- /dev/null +++ b/router/scripts/setup-osrm.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Self-host the OSRM router over the NH+MA+CT+NY extract. +# Prereqs: cmake, g++, make, >=12GB RAM for extract, >=10GB disk. +# +# The PBFs are pre-downloaded in ../osm/ (us-new-hampshire, us-connecticut, +# us-massachusetts, us-new-york .osm.pbf, from the geofabrik mirror on +# HuggingFace: NoeFlandre/osm-geofabrik-raw-pbf-extracts). +# +# Artifacts go to ../osm/build/. Router ends up on http://localhost:5000. +set -euo pipefail +cd "$(dirname "$0")/.." + +OSM_DIR="$(pwd)/osm" +BUILD_DIR="$OSM_DIR/build" +DATA_DIR="$BUILD_DIR/data" +OSRM_BIN="$BUILD_DIR/osrm-backend-26.4.1/build" +mkdir -p "$DATA_DIR" + +step() { echo; echo "=== $1"; } + +step "1/5 OSRM binaries" +if [ ! -x "$OSRM_BIN/osrm-extract" ]; then + if [ ! -d "$BUILD_DIR/osrm-backend-26.4.1" ]; then + curl -sL -o "$BUILD_DIR/osrm.tar.gz" https://github.com/Project-OSRM/osrm-backend/archive/refs/tags/v26.4.1.tar.gz + tar -C "$BUILD_DIR" -xzf "$BUILD_DIR/osrm.tar.gz" + fi + # Boost is header-usage only for OSRM; point BOOST_ROOT at a source tree. + if [ ! -d "$BUILD_DIR/boost_1_84_0" ]; then + curl -sL -o "$BUILD_DIR/boost.tgz" https://archives.boost.io/release/1.84.0/source/boost_1_84_0.tar.gz + tar -C "$BUILD_DIR" -xzf "$BUILD_DIR/boost.tgz" + fi + cmake -S "$BUILD_DIR/osrm-backend-26.4.1" -B "$OSRM_BIN" \ + -DCMAKE_BUILD_TYPE=Release -DWITH_TESTS=OFF -DWITH_EXAMPLES=OFF \ + -DBOOST_ROOT="$BUILD_DIR/boost_1_84_0" + cmake --build "$OSRM_BIN" -j"$(nproc)" +fi +ls -la "$OSRM_BIN"/osrm-extract "$OSRM_BIN"/osrm-routed + +step "2/5 OSM PBFs" +for f in us-new-hampshire us-connecticut us-massachusetts us-new-york; do + if [ ! -s "$OSM_DIR/$f.osm.pbf" ]; then + curl -sL -o "$OSM_DIR/$f.osm.pbf" \ + "https://huggingface.co/datasets/NoeFlandre/osm-geofabrik-raw-pbf-extracts/resolve/main/$f-latest.osm.pbf" + fi +done +ls -la "$OSM_DIR"/us-*.osm.pbf + +step "3/5 extract (needs ~12GB RAM; this is the slow part)" +touch "$DATA_DIR/timestamp" +if [ ! -s "$DATA_DIR/northeast.osrm" ] || [ ! -s "$DATA_DIR/northeast.osrm.tile" ]; then + "$OSRM_BIN/osrm-extract" \ + "$OSM_DIR/us-new-hampshire.osm.pbf" \ + "$OSM_DIR/us-connecticut.osm.pbf" \ + "$OSM_DIR/us-massachusetts.osm.pbf" \ + "$OSM_DIR/us-new-york.osm.pbf" \ + --profile-file "$OSRM_BIN/../profiles/driving.lua" \ + --timestamp-file "$DATA_DIR/timestamp" \ + --output-location "$DATA_DIR/northeast" \ + --ignore-waterway --ignore-tunnels 2>&1 | tail -5 +fi + +step "4/5 partition + customize" +"$OSRM_BIN/osrm-partition" --input-location "$DATA_DIR/northeast" +"$OSRM_BIN/osrm-customize" --input-location "$DATA_DIR/northeast" --profile-file "$OSRM_BIN/../profiles/driving.lua" + +step "5/5 serve" +echo "Starting osrm-routed on :5000 (Ctrl-C to stop)" +exec "$OSRM_BIN/osrm-routed" --algorithm mld --port 5000 "$DATA_DIR/northeast.osrm"