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:
commit
efde2cc71b
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
|
||||
127
SURVEY.md
Normal file
127
SURVEY.md
Normal 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
958
mock/app.js
Normal 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 2–3 h for the full complex. Kept at your 1 h.',
|
||||
fix: 'Keep as is' }
|
||||
],
|
||||
D2: [], D3: [
|
||||
{ id: 'I3', kind: 'info', fixed: false,
|
||||
title: 'Dinner is “maybe”',
|
||||
body: 'Il Latte is still an option, not a plan — decide by this evening so there’s a fallback if it’s full.',
|
||||
fix: 'Keep as is' }
|
||||
]
|
||||
};
|
||||
let showResolved = false;
|
||||
function renderIssues() {
|
||||
const list = issuesByDay[day.id] || [];
|
||||
const p = $('#issues-panel'); p.innerHTML = '';
|
||||
const active = list.filter(i => !i.fixed);
|
||||
const resolved = list.filter(i => i.fixed);
|
||||
$('#issue-badge').classList.toggle('ok', !active.length);
|
||||
$('#issue-badge').innerHTML = active.length ? `⚠ <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 <name>”.`, 700);
|
||||
}
|
||||
function exitFocus(note) {
|
||||
if (!focus) return;
|
||||
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
|
||||
focus = null;
|
||||
$('#rail').classList.remove('dimmed');
|
||||
$('#focusbar').classList.add('hidden');
|
||||
if (note) ai(note, 400);
|
||||
}
|
||||
const WALK_KMH = 4.6;
|
||||
function walkMin(from, to) { return Math.max(1, Math.round(haversine(from, to) / 1000 / (WALK_KMH / 60))); }
|
||||
// where the user is when they make this choice: the stop right before the slot, else the last planned stop, else the hotel
|
||||
function focusAnchor() {
|
||||
const idx = day.stops.findIndex(x => x.slot === focus.slot);
|
||||
if (idx > 0) return day.stops[idx - 1];
|
||||
if (idx < 0) {
|
||||
const planned = day.stops.filter(x => x.state === 'planned');
|
||||
if (planned.length) return planned[planned.length - 1];
|
||||
}
|
||||
return { name: dayBase().name, at: dayBase().at };
|
||||
}
|
||||
|
||||
function renderFocus() {
|
||||
const label = focus.slot === 'visit' ? 'afternoon' : focus.slot;
|
||||
$('#focusbar-title').innerHTML = `🔍 <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(`There’s no time issue on ${dayLabel()} right now — the issues panel is clear.`);
|
||||
} else {
|
||||
ai('I can shift times, swap stops, move things between days, or re-route a leg — e.g. “start the day later” or “make lunch longer”. You can also drag stops by the ⠿ grip, drop one on a day tab, or click a leg to edit its route.');
|
||||
}
|
||||
}
|
||||
$('#chat-send').onclick = chatSend;
|
||||
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
|
||||
|
||||
// ---------------- landing → parse → plan ----------------
|
||||
const SAMPLE = [
|
||||
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
|
||||
['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'],
|
||||
['dates', '12 – 15 Sep 2026', '3 nights', 'hi'],
|
||||
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'],
|
||||
['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid']
|
||||
];
|
||||
function showParse() {
|
||||
const t = $('#parse-table'); t.innerHTML = '';
|
||||
SAMPLE.forEach(([k, v, note, c]) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<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, 12–15 Sep</b>; the 15th’s train (09:12) is pinned as well. What kind of trip are you after?', 900);
|
||||
setTimeout(() => {
|
||||
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
|
||||
const chips = el('div', 'chips');
|
||||
M.vibes.forEach(v => {
|
||||
const c = el('button', 'chip', v.label);
|
||||
c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(); };
|
||||
chips.append(c);
|
||||
});
|
||||
m.append(chips);
|
||||
}, 1800);
|
||||
}, 300);
|
||||
}
|
||||
function script2() {
|
||||
scriptBusy = true;
|
||||
ai('Noted — relaxed, food-first. A few ideas for your first day:', 900);
|
||||
setTimeout(() => {
|
||||
const m = msg('ai', '');
|
||||
const cards = el('div', 'cards');
|
||||
m.append(cards);
|
||||
let sequenced = false;
|
||||
M.suggestions.forEach((sg, i) => {
|
||||
const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop);
|
||||
const card = el('div', 'scard');
|
||||
const imgBox = el('div', 'scard-img');
|
||||
imgBox.style.background = s.img[2];
|
||||
const skel = el('div', 'skel');
|
||||
const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); };
|
||||
imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons'));
|
||||
if (s.img[3]) {
|
||||
const im = document.createElement('img');
|
||||
im.src = s.img[3]; im.alt = s.img[1];
|
||||
im.onload = ready; im.onerror = ready;
|
||||
imgBox.append(im);
|
||||
} else {
|
||||
imgBox.append(el('div', 'ph', s.img[0]));
|
||||
setTimeout(ready, 1400 + i * 500);
|
||||
}
|
||||
const body = el('div', 'scard-body');
|
||||
const title = el('div', 'scard-name');
|
||||
const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener';
|
||||
title.append(tl);
|
||||
body.append(title);
|
||||
body.append(el('div', 'scard-pitch', sg.pitch));
|
||||
const foot = el('div', 'scard-foot');
|
||||
const add = el('button', 'btn primary small', 'Add to day');
|
||||
const maybeB = el('button', 'btn ghost small', 'Maybe');
|
||||
const pass = el('button', 'btn ghost small', 'Not for me');
|
||||
const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : '');
|
||||
const st = { done: false };
|
||||
const sheetBtns = [];
|
||||
const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; });
|
||||
const doAdd = () => { if (st.done) return; st.done = true;
|
||||
card.classList.add('added');
|
||||
finish(['Added ✓', '', '', 'Added ✓', '', '']);
|
||||
const state = addStopToDay(s.id, null);
|
||||
if (state === 'alt') ai(`<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> — I’ll sequence the day once the set is done.`, 500);
|
||||
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
|
||||
};
|
||||
const doMaybe = () => { if (st.done) return; st.done = true;
|
||||
card.classList.add('maybe-card');
|
||||
finish(['', 'Holding', '', '', 'Holding', '']);
|
||||
addStopToDay(s.id, 'maybe');
|
||||
ai(`Holding <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
264
mock/data.js
Normal 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 12–15 Sep',
|
||||
// stays, not a single hotel: {hotel, place, checkIn, checkOut, state}.
|
||||
// Days resolve their anchor per date; >1 candidate stay covering the
|
||||
// same nights → worst-case anchoring for those nights.
|
||||
// Multi-city trips = more stays, each anchoring its own dates.
|
||||
stays: [
|
||||
{ id: 'ST1', name: 'Hotel Palagio', at: [43.7684, 11.2566], place: 'Florence',
|
||||
checkIn: '2026-09-12', checkOut: '2026-09-15', state: 'booked', price: 180 },
|
||||
],
|
||||
places: [
|
||||
{ id: 'PL1', name: 'Florence', bbox: [43.73, 11.21, 43.80, 11.29],
|
||||
center: [43.769, 11.255], vibe: 'art + food, relaxed pace',
|
||||
dates: ['2026-09-12', '2026-09-15'] }
|
||||
],
|
||||
bookings: [
|
||||
{ type: 'hotel', name: 'Hotel Palagio', ref: 'H-77821',
|
||||
dates: '12–15 Sep', where: [43.7684, 11.2566],
|
||||
status: 'booked', source: 'user_booking' },
|
||||
{ type: 'train', name: 'Frecciarossa 9512', ref: 'F-55812',
|
||||
from: 'Firenze SMN', to: 'Pisa Centrale', date: '2026-09-15',
|
||||
depart: '09:12', arrive: '09:58', status: 'booked', source: 'user_booking',
|
||||
stations: { from: [43.7726, 11.2597], to: [43.7157, 10.3922] } }
|
||||
]
|
||||
},
|
||||
|
||||
days: [
|
||||
{ id: 'D1', date: '2026-09-12', label: 'Day 1 · Fri 12',
|
||||
startMin: 8 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [43.7684, 11.2566], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'triptadvisor.com' } },
|
||||
{ id: 'S2', name: 'Palazzo Pitti', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.palazzopittiturin.it',
|
||||
at: [43.7639, 11.2518], dur: 60, durConf: 5, // user override (sticky)
|
||||
suggested: { value: 150, conf: 2, src: 'wikivoyage.org' },
|
||||
price: { amount: 14, cur: '€', conf: 2, src: 'uffizi.it' },
|
||||
note: 'guides suggest 2–3 h', img: ['🏛️', 'Palazzo Pitti', 'linear-gradient(135deg,#b5533c,#7a3a2c)',
|
||||
'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/Palacio_Pitti%2C_Florencia%2C_Italia%2C_2022-09-18%2C_DD_197.jpg/1280px-Palacio_Pitti%2C_Florencia%2C_Italia%2C_2022-09-18%2C_DD_197.jpg'],
|
||||
detail: {
|
||||
summary: 'The Medici family’s riverside palazzo: the Galleria Palatina (Portraiture, the 500 Room) and the modern-art Galleria d’Arte Moderna. Rooms are small and crowded in peak season — the apartments alone run 1.5–2 h if you linger, ~1 h for the highlights. September afternoons are the sweet spot: light is good for photos and lines thin after 16:00.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.palazzopittiturin.it' },
|
||||
{ label: 'Tickets', href: 'https://www.uffizi.it/en/visit/tickets' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183726-Reviews-Palazzo_Pitti-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=palazzo+pitti&restrict_sr=1' }
|
||||
] } },
|
||||
{ id: 'S4', name: 'Lunch — Trattoria Mario', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.trattoriamario.it',
|
||||
at: [43.7667, 11.2544], dur: 45, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'trattoriamario.it' },
|
||||
price: { amount: 25, cur: '€', conf: 2, src: 'trattoriamario.it' },
|
||||
img: ['🍝', 'Trattoria Mario', 'linear-gradient(135deg,#d08b3c,#9c5f1e)',
|
||||
'https://upload.wikimedia.org/wikipedia/commons/1/12/Bistecca_alla_fiorentina-01.jpg'],
|
||||
detail: {
|
||||
summary: 'A no-frills Florentine kitchen on Via dei Neri, open since 1954. Counter seating, short menu (bistecca, lampredotto, tripe soup), and the queue is real — arriving as lunch opens (12:00) is the difference between 10 minutes and 40. Budget ~1 h including eating.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.trattoriamario.it' },
|
||||
{ label: 'Menu', href: 'https://www.trattoriamario.it/menu' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d310951-Reviews-Trattoria_Mario-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=trattoria+mario+florence&restrict_sr=1' }
|
||||
] } },
|
||||
{ id: 'S5', name: 'Boboli Gardens', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.boboli.it',
|
||||
at: [43.7579, 11.2463], dur: 120, durConf: 3,
|
||||
suggested: { value: 120, conf: 2, src: 'boboli.it' },
|
||||
price: { amount: 10, cur: '€', conf: 2, src: 'uffizi.it' },
|
||||
img: ['🌿', 'Boboli Gardens', 'linear-gradient(135deg,#4f9e63,#2c6b40)',
|
||||
'https://thumb.wikimedia.org/wikipedia/commons/thumb/d/d2/Obelisco%2C_Boboli_Gardens%2C_Florence_%2826408174180%29.jpg/1280px-Obelisco%2C_Boboli_Gardens%2C_Florence_%2826408174180%29.jpg'],
|
||||
detail: {
|
||||
summary: 'The Medici’s formal garden behind Pitti — statues, the Apollo fountain, a hilltop view over the city. The full loop with the Accademia annex runs 2 h; the upper terraces alone are a fine 45-minute pace-break. Pairs naturally with Pitti: the entrance is the courtyard behind it.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.boboli.it' },
|
||||
{ label: 'Tickets', href: 'https://www.uffizi.it/en/visit/tickets' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183872-Reviews-Giardini_Di_Boboli-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=boboli&restrict_sr=1' }
|
||||
] } },
|
||||
{ id: 'S6', name: 'Gelato — Vivoli', kind: 'meal', mealKind: 'snack',
|
||||
slot: 'snack', state: 'planned',
|
||||
at: [43.7680, 11.2537], dur: 20, durConf: 3,
|
||||
suggested: { value: 20, conf: 1, src: null },
|
||||
price: null,
|
||||
img: ['🍦', 'Vivoli gelato', 'linear-gradient(135deg,#e0a5c0,#a56a92)'] },
|
||||
{ id: 'S7', name: 'Lunch — Ristorante Santini', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'alt', url: 'https://www.santini.it',
|
||||
at: [43.7646, 11.2528], dur: 75, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'santini.it' },
|
||||
price: { amount: 45, cur: '€', conf: 2, src: 'santini.it' },
|
||||
img: ['🍷', 'Ristorante Santini', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)',
|
||||
'https://thumb.wikimedia.org/wikipedia/commons/thumb/c/c0/View_from_Giotto%27s_Bell_Tower_-_Florence.jpg/1280px-View_from_Giotto%27s_Bell_Tower_-_Florence.jpg'],
|
||||
detail: {
|
||||
summary: 'A classic Florentine restaurant in a 16th-century building on the Arno — the terrace looks across to Pitti. Set tables, longer menus, lunch from 12:30. The step-up option: worth holding if Mario’s queue looks long, or if the day goes well and you want to mark it.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.santini.it' },
|
||||
{ label: 'Menu', href: 'https://www.santini.it/en/menu' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d1442007-Reviews-Ristorante_Santini-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=santini+florence&restrict_sr=1' }
|
||||
] } }
|
||||
]
|
||||
},
|
||||
{ id: 'D2', date: '2026-09-13', label: 'Day 2 · Sat 13',
|
||||
startMin: 8 * 60 + 30, wakingHours: 11,
|
||||
base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S8', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [43.7684, 11.2566], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S9', name: 'Galleria dell’Accademia (David)', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.galleriaaccademia.firenze.it',
|
||||
at: [43.7734, 11.2628], dur: 90, durConf: 3,
|
||||
suggested: { value: 120, conf: 2, src: 'wikivoyage.org' },
|
||||
price: { amount: 16, cur: '€', conf: 2, src: 'galleriaaccademia.firenze.it' },
|
||||
img: ['🗿', 'Accademia', 'linear-gradient(135deg,#6b7280,#3f4650)'],
|
||||
detail: {
|
||||
summary: 'Home of the original David plus Donatello’s Prigione. Timed-entry tickets, and the David room moves in batches — a 09:30 slot is noticeably calmer than midday. Allow ~90 min; 2 h if you do the whole collection.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.galleriaaccademia.firenze.it' },
|
||||
{ label: 'Tickets', href: 'https://www.galleriaaccademia.firenze.it/en/tickets' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g187791-d183692-Reviews-Galleria_dell_Accademia-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/Florence', href: 'https://www.reddit.com/r/Florence/search/?q=accademia+david&restrict_sr=1' }
|
||||
] } },
|
||||
{ id: 'S10', name: 'Lunch — Trattoria Sostanza', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.sostanza.it',
|
||||
at: [43.7609, 11.2469], dur: 75, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'tripadvisor.com' },
|
||||
price: { amount: 35, cur: '€', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🍕', 'Trattoria Sostanza', 'linear-gradient(135deg,#b0563c,#7a3a2c)'],
|
||||
detail: {
|
||||
summary: 'Oltrarno kitchen a short walk across the Arno from the Accademia: Florentine classics, a proper wine list, and a roomy back — the kind of lunch that runs long. Reservations recommended on Saturdays.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.sostanza.it' },
|
||||
{ label: 'Menu', href: 'https://www.sostanza.it/menu' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Restaurant_Review-g187791-d559983-Reviews-Trattoria_Sostanza-Florence_Tuscany.html' },
|
||||
{ label: 'Reddit r/food', href: 'https://www.reddit.com/r/food/search/?q=sostanza+florence&restrict_sr=1' }
|
||||
] } },
|
||||
{ id: 'S11', name: 'Oltrarno stroll · San Niccolò', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [43.7613, 11.2497], dur: 60, durConf: 3,
|
||||
suggested: { value: 60, conf: 1, src: null },
|
||||
price: null,
|
||||
img: ['⛪', 'Oltrarno', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'] }
|
||||
]
|
||||
},
|
||||
{ id: 'D3', date: '2026-09-14', label: 'Day 3 · Sun 14',
|
||||
startMin: 9 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S12', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [43.7684, 11.2566], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S13', name: 'San Marco & cloister', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [43.7723, 11.2597], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 1, src: null },
|
||||
price: { amount: 8, cur: '€', conf: 1, src: null },
|
||||
img: ['🖼️', 'San Marco', 'linear-gradient(135deg,#7a6a4f,#4d422e)'] },
|
||||
{ id: 'S14', name: 'Aperitivo — Il Latte', kind: 'meal', mealKind: 'dinner',
|
||||
slot: 'dinner', state: 'maybe',
|
||||
url: 'https://www.illatte.it',
|
||||
at: [43.7700, 11.2560], dur: 60, durConf: 3,
|
||||
suggested: { value: 60, conf: 1, src: null },
|
||||
price: { amount: 12, cur: '€', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🥂', 'Il Latte', 'linear-gradient(135deg,#c9a13c,#8f6f1e)'] }
|
||||
]
|
||||
},
|
||||
{ id: 'D4', date: '2026-09-15', label: 'Day 4 · Mon 15', isDeparture: true,
|
||||
startMin: 8 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Palagio', at: [43.7684, 11.2566] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S15', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [43.7684, 11.2566], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S16', name: 'Train → Pisa Centrale', kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: [43.7726, 11.2597], dur: 30, durConf: 4,
|
||||
transit: { name: 'Frecciarossa 9512', ref: 'F-55812',
|
||||
from: 'Firenze SMN', to: 'Pisa Centrale',
|
||||
depart: '09:12', arrive: '09:58', arriveBy: '08:42',
|
||||
buffer: '30 min before departure' },
|
||||
suggested: null,
|
||||
price: { amount: 45, cur: '€', conf: 4, src: 'booking' },
|
||||
img: ['🚄', 'Frecciarossa 9512', 'linear-gradient(135deg,#274b8f,#16294d)'] }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// focus-mode candidate pools (what a slot search returns)
|
||||
candidates: {
|
||||
hotel: [
|
||||
{ id: 'H1', name: 'Hotel Palagio', at: [43.7684, 11.2566], price: 180, dur: null,
|
||||
tags: ['central', 'quiet', 'refundable'], pitch: 'Where you are now — refundable until the 8th.', url: null },
|
||||
{ id: 'H2', name: 'Hotel Savoy', at: [43.7726, 11.2565], price: 210, dur: null,
|
||||
tags: ['near SMN', 'grand', 'rooftop'], pitch: 'Grand hotel by the station — handy for the 15th, 10 min from the centre.', url: null },
|
||||
{ id: 'H3', name: 'Residenza Oltrarno', at: [43.7604, 11.2510], price: 140, dur: null,
|
||||
tags: ['oltrarno', 'small', 'value'], pitch: 'Six rooms above a bookshop — cheaper, and puts you on the south side.', url: null },
|
||||
],
|
||||
lunch: [
|
||||
{ id: 'C1', name: 'Trattoria Mario', at: [43.7667, 11.2544], price: 25, dur: 90,
|
||||
tags: ['counter', 'local', 'queue'], pitch: 'No-frills counter since 1954 — the queue is the point.',
|
||||
url: 'https://www.trattoriamario.it', img: ['🍝', 'Trattoria Mario', 'linear-gradient(135deg,#d08b3c,#9c5f1e)', 'https://upload.wikimedia.org/wikipedia/commons/1/12/Bistecca_alla_fiorentina-01.jpg'] },
|
||||
{ id: 'C2', name: 'Ristorante Santini', at: [43.7646, 11.2528], price: 45, dur: 90,
|
||||
tags: ['views', 'terrace', 'splurge'], pitch: '16th-century set tables on the Arno, terrace across from Pitti.',
|
||||
url: 'https://www.santini.it', img: ['🍷', 'Santini', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)'] },
|
||||
{ id: 'C3', name: 'Trattoria Sostanza', at: [43.7609, 11.2469], price: 35, dur: 90,
|
||||
tags: ['oltrarno', 'roomy', 'wine'], pitch: 'Oltrarno kitchen, proper wine list, a roomy back — runs long.',
|
||||
url: 'https://www.sostanza.it', img: ['🍕', 'Sostanza', 'linear-gradient(135deg,#b0563c,#7a3a2c)'] },
|
||||
{ id: 'C4', name: 'Vendemmia', at: [43.7735, 11.2585], price: 30, dur: 75,
|
||||
tags: ['vegetarian', 'garden', 'near SMN'], pitch: 'Garden terrace by SMN, strong vegetarian menu, calmer pace.',
|
||||
url: 'https://www.vendemmia.it', img: ['🥗', 'Vendemmia', 'linear-gradient(135deg,#5f9e6b,#33613c)'] },
|
||||
{ id: 'C5', name: 'La Cantina di San Niccolò', at: [43.7615, 11.2490], price: 22, dur: 75,
|
||||
tags: ['cellar', 'cozy', 'oltrarno'], pitch: 'Stone cellar, candlelight, short honest menu — book ahead.',
|
||||
url: 'https://www.lacantina.it', img: ['🕯️', 'La Cantina', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'] },
|
||||
{ id: 'C6', name: 'Buonami Pizza', at: [43.7680, 11.2550], price: 15, dur: 45,
|
||||
tags: ['fast', 'casual', 'near hotel'], pitch: 'Two-stop, fast, good — the reset-button lunch.',
|
||||
url: 'https://www.buonami.it', img: ['🍕', 'Buonami', 'linear-gradient(135deg,#c98a2c,#8a5f1a)'] }
|
||||
],
|
||||
visit: [
|
||||
{ id: 'V1', name: 'Piazzale Michelangelo', at: [43.7669, 11.2455], price: 0, dur: 60,
|
||||
tags: ['views', 'sunset', 'free'], pitch: 'The postcard view — best from 17:00 as the light warms.',
|
||||
url: 'https://en.wikipedia.org/wiki/Piazzale_Michelangelo', img: ['🌇', 'Piazzale Michelangelo', 'linear-gradient(135deg,#c97a3c,#8a4f1a)'] },
|
||||
{ id: 'V2', name: 'San Miniato al Monte', at: [43.7620, 11.2521], price: 4, dur: 60,
|
||||
tags: ['views', 'romanesque', 'calm'], pitch: 'Romanesque basilica on the hill — quiet, cool in the afternoon.',
|
||||
url: 'https://en.wikipedia.org/wiki/San_Miniato_al_Monte', img: ['⛪', 'San Miniato', 'linear-gradient(135deg,#8f9a7a,#5c644d)'] },
|
||||
{ id: 'V3', name: 'Santo Spirito square', at: [43.7603, 11.2496], price: 0, dur: 45,
|
||||
tags: ['square', 'lazier', 'market'], pitch: 'Oltrarno’s lazy square; Saturday’s market is the event.',
|
||||
url: 'https://en.wikipedia.org/wiki/Santo_Spirito,_Florence', img: ['☕', 'Santo Spirito', 'linear-gradient(135deg,#a58a6a,#6d5840)'] },
|
||||
{ id: 'V4', name: 'Terme di Caracalla (ruins)', at: [43.7589, 11.2707], price: 10, dur: 75,
|
||||
tags: ['ruins', 'open', 'afternoon'], pitch: 'Third-century baths in open fields — cool, empty, odd.',
|
||||
url: 'https://en.wikipedia.org/wiki/Baths_of_Caracalla', img: ['🏛️', 'Terme di Caracalla', 'linear-gradient(135deg,#b0a58a,#756a4f)'] }
|
||||
]
|
||||
},
|
||||
|
||||
vibes: [
|
||||
{ id: 'v1', label: 'art & food · relaxed pace' },
|
||||
{ id: 'v2', label: 'gardens & walking' },
|
||||
{ id: 'v3', label: 'day trip · Fiesole' },
|
||||
{ id: 'v4', label: 'views & aperitivo' }
|
||||
],
|
||||
|
||||
suggestions: [
|
||||
{ stop: 'S2', pitch: 'The Palazzo Medici’s private apartments — less crowded than the Uffizi, opens right at 9:30.',
|
||||
enrich: 'open 09:30–19:30 · skip-the-line available' },
|
||||
{ stop: 'S4', pitch: 'No-frills Florentine kitchen the locals queue for. Expect a wait without a reservation.',
|
||||
enrich: 'lunch 12:00–14:30 · dinner from 19:30 · ~1 h typically' },
|
||||
{ stop: 'S5', pitch: 'Renaissance gardens behind Pitti — a good pace-break in the heat; ~2 h is the sweet spot.',
|
||||
enrich: 'open 08:15–19:30 · last entry 18:15' },
|
||||
{ stop: 'S7', pitch: 'The step-up lunch: set tables on the Arno, terrace across from Pitti. Worth holding if Mario’s queue looks long.',
|
||||
enrich: 'lunch from 12:30 · book ahead in season' }
|
||||
]
|
||||
};
|
||||
104
mock/index.html
Normal file
104
mock/index.html
Normal 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 · 12–15 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
58
mock/server.js
Normal 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
338
mock/styles.css
Normal 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
114
router/README.md
Normal 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
385
router/cmd/bench/main.go
Normal 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
218
router/cmd/routectl/main.go
Normal 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
3
router/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module maps/router
|
||||
|
||||
go 1.27
|
||||
70
router/internal/geo/geo.go
Normal file
70
router/internal/geo/geo.go
Normal 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
|
||||
}
|
||||
34
router/internal/geo/geo_test.go
Normal file
34
router/internal/geo/geo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
router/internal/plan/corridor.go
Normal file
69
router/internal/plan/corridor.go
Normal 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
|
||||
}
|
||||
57
router/internal/plan/corridor_test.go
Normal file
57
router/internal/plan/corridor_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
110
router/internal/plan/integration_test.go
Normal file
110
router/internal/plan/integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
189
router/internal/plan/optimize.go
Normal file
189
router/internal/plan/optimize.go
Normal 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
|
||||
}
|
||||
145
router/internal/plan/plan_test.go
Normal file
145
router/internal/plan/plan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
110
router/internal/plan/stopcost.go
Normal file
110
router/internal/plan/stopcost.go
Normal 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
|
||||
}
|
||||
136
router/internal/route/osrm.go
Normal file
136
router/internal/route/osrm.go
Normal 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
|
||||
126
router/internal/route/route.go
Normal file
126
router/internal/route/route.go
Normal 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
|
||||
}
|
||||
183
router/internal/route/valhalla.go
Normal file
183
router/internal/route/valhalla.go
Normal 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
|
||||
}
|
||||
68
router/scripts/setup-osrm.sh
Normal file
68
router/scripts/setup-osrm.sh
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user