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
This commit is contained in:
Greg Pomerantz 2026-09-06 00:05:17 -04:00
commit efde2cc71b
24 changed files with 4921 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -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

1044
DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

127
SURVEY.md Normal file
View File

@ -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.

958
mock/app.js Normal file
View File

@ -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: `<div class="hotel-pin${booked ? '' : ' hotel-alt'}">🏨</div>`, 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: '<div class="hotel-pin">🚄</div>', 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 <b>placeholder</b> for ${slot} ${region.label} — it holds the slot and reserves time; travel times are <b>worst-case</b> 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
? `<div class="mk mkd">${i + 1}</div>`
: `<div class="mk">${i + 1}</div>`,
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', `<span class="leg-ico">🏨</span><span>back to hotel</span> `);
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 <b>${s.name}</b> from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so <b>${promoted.name}</b> 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: <b>${s.name}</b> is now your ${s.slot}; <b>${cur.name}</b> 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(`<b>${s.name}</b> 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 <b>${s.name}</b> 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 <b>~90 min</b> <i>(web)</i>, 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 23 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 theres a fallback if its 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 ? `⚠ <b>${active.length}</b> 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: '<div style="width:16px;height:16px;border-radius:50%;background:#b5533c;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)"></div>', 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 &lt;name&gt;”.`, 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 = `🔍 <b>${label}${focus.region ? ' · ' + focus.region.label : ''}</b> · ${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 = `<span class="pchip">↦ ${walkMin(anchor.at, c.at)} min walk from ${anchor.name}</span>`
+ (fromHotel ? `<span class="pchip">→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}</span>` : '');
const html = `<div class="focpop">
<div class="fp-name">${c.name}</div>
<div class="fp-sub">${c.pitch}</div>
<div class="fp-dist">${dist}</div>
<div class="fp-facts">${c.dur ? `<span class="tc t-search">~${fmt(c.dur)}</span>` : ''}<span class="pchip">${c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'}</span></div>
<div class="fp-actions">
<button class="btn primary small fp-choose">${isHotel ? 'Make this the hotel' : 'Choose this'}</button>
<button class="btn ghost small fp-maybe">${isHotel ? 'Keep as option' : 'Maybe'}</button>
</div>
</div>`;
const mk = L.marker(c.at, {
icon: L.divIcon({ className: '', html: `<div class="candwrap"><div class="cand"></div><div class="cand-lbl">${c.name}</div></div>`,
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 <b>${c.name}</b> 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: <b>${c.name}</b> is your ${slot} now — the placeholder is gone and the leg times are real.`
: `Locked in <b>${c.name}</b> for ${dayLabel()}${existing ? ` — <b>${existing.name}</b> 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 <b>${c.name}</b> 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 <b>${c.name}</b> 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 <b>${c.name}</b> 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}: <b>${closest.name}</b> (${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 <b>${s.name}</b> 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', '<span class="typing"><i></i><i></i><i></i></span>'); }
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 <b>${fmt(layout())}</b>.`);
} 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(`Theres 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 = `<td>${k}</td><td><b>${v}</b><span class="conf ${c}">${c === 'hi' ? 'confirmed' : 'verify'}</span><div style="font-size:11px;color:var(--ink2)">${note}</div></td>`;
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', `<span>🏨</span><span><span class="tt">${b.name}</span> · ${stayRange(b)} <span class="ts">· ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'}</span></span><span class="tc t-sched">base</span>`);
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 — <b>Hotel Palagio, 1215 Sep</b>; the 15ths 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(`<b>${s.name}</b> is in as the <b>alternative</b> for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
else ai(`Added <b>${s.name}</b> — Ill 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 <b>${s.name}</b> 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 <b>${s.name}</b> — 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. <b>' + ((layout() - day.startMin) / 60).toFixed(1) + ' h</b> 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();

264
mock/data.js Normal file
View File

@ -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 1215 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: '1215 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 23 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 familys riverside palazzo: the Galleria Palatina (Portraiture, the 500 Room) and the modern-art Galleria dArte Moderna. Rooms are small and crowded in peak season — the apartments alone run 1.52 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 Medicis 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 Marios 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 dellAccademia (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 Donatellos 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: 'Oltrarnos lazy square; Saturdays 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 Medicis private apartments — less crowded than the Uffizi, opens right at 9:30.',
enrich: 'open 09:3019: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:0014: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:1519:30 · last entry 18:15' },
{ stop: 'S7', pitch: 'The step-up lunch: set tables on the Arno, terrace across from Pitti. Worth holding if Marios queue looks long.',
enrich: 'lunch from 12:30 · book ahead in season' }
]
};

104
mock/index.html Normal file
View File

@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trip</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="map"></div>
<!-- ======================= LANDING ======================= -->
<section id="landing">
<div class="land-card">
<h1>Where would you like to go?</h1>
<div class="land-row">
<input id="dest" type="text" placeholder="A city, a region, a country… e.g. Florence" />
<button id="dest-go" class="btn primary">Plan it</button>
</div>
<div class="land-div"><span>or</span></div>
<div id="dropzone">
<div class="dz-icon">🎫</div>
<div class="dz-text"><b>Drop a travel booking to start</b><br>ticket, flight or hotel confirmation</div>
<button id="dz-sample" class="btn ghost small">or start from an example booking</button>
</div>
</div>
</section>
<!-- booking parse modal -->
<div id="parse-modal" class="modal hidden">
<div class="modal-card">
<h2>Parsed booking</h2>
<table id="parse-table"></table>
<div class="modal-actions">
<button class="btn ghost" id="parse-cancel">Cancel</button>
<button class="btn primary" id="parse-confirm">Build plan around it</button>
</div>
</div>
</div>
<!-- ======================= APP ======================= -->
<header id="topbar" class="hidden">
<div class="tb-left">
<span class="crumb" id="crumb">Florence · 1215 Sep</span>
<div class="daytabs" id="daytabs"></div>
</div>
<div class="tb-right">
<span class="badge" id="issue-badge" title="Plan issues"><b id="issue-count">0</b> issues</span>
</div>
</header>
<div id="app" class="hidden">
<aside id="rail">
<div class="rail-block" id="transit-block"></div>
<div id="rail-body"></div>
<div class="rail-foot">
<div class="budget">
<div class="budget-label"><span id="budget-text"></span></div>
<div class="budget-bar"><div id="budget-fill"></div></div>
</div>
<div id="issues-panel"></div>
<div class="legend">
<span class="tc t-user">·</span> you
<span class="tc t-sched">·</span> booked
<span class="tc t-computed">·</span> computed
<span class="tc t-search">~</span> web
<span class="tc t-llm">·</span> est.
</div>
</div>
</aside>
<div id="mapwrap">
<div id="focusbar" class="hidden">
<span id="focusbar-title"></span>
<span class="fb-hint">talk to filter</span>
<button id="focusbar-x" class="fb-x" title="Exit (Esc)"></button>
</div>
<div id="l3-editor" class="hidden">
<div class="l3-title" id="l3-title">Route</div>
<div class="l3-hint">Drag the ● waypoint (or the endpoints) — the duration updates live.</div>
<div class="l3-time">leg time: <span id="l3-time" class="tc t-computed"></span></div>
<button class="btn ghost small" id="l3-close">Done</button>
</div>
</div>
<aside id="chat">
<div id="chat-body"></div>
<div id="chat-input-row">
<input id="chat-input" type="text" placeholder="Ask, direct — or “find a lunch spot”…" />
<button class="btn primary small" id="chat-send"></button>
</div>
</aside>
</div>
<nav id="mobiletabs" aria-label="Views">
<button data-t="plan" class="on">🗓️ Plan</button>
<button data-t="map">🗺️ Map</button>
<button data-t="chat">💬 Chat</button>
</nav>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>

58
mock/server.js Normal file
View File

@ -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)`));

338
mock/styles.css Normal file
View File

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

114
router/README.md Normal file
View File

@ -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.

385
router/cmd/bench/main.go Normal file
View File

@ -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)
}

218
router/cmd/routectl/main.go Normal file
View File

@ -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)
}

3
router/go.mod Normal file
View File

@ -0,0 +1,3 @@
module maps/router
go 1.27

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)")
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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"