mock: wire to the real local OSRM router + L0 trip view, version history, offline cues
- server.js: proxy /route, /table, /router-status to the local OSRM on :5000
- app.js: routeClient (polyline-5 decode, ordered route fetch); legs start as
straight-line estimates (conf 1) and are upgraded in the background to real
road geometry + duration (conf 3); out-of-coverage trips (Florence) fall
back gracefully with a 'estimate — outside router coverage' source
- L3 leg editor: dragging a waypoint now genuinely re-routes through the
router (was silently no-op — #app covered the map with pointer-events:auto,
so no map drag ever landed; #app is now pointer-events:none, children opt in)
- data.js: second demo city (Boston 14–16 Sep, inside the router's NE-US
extract) via ?city=boston; nudges + multimodal legs are now dataset-driven
- L0: Day/Trip scope toggle — per-day summary cards (pacing bar, stops,
end time, price, base) with click-to-jump; map fits the whole trip
- version history: commit() snapshots days+stays on every mutation; topbar
vN pill with undo/redo + dropdown timeline showing a diff label per version;
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y; restore() re-enriches legs (snapshots may
predate route enrichment)
- offline: staged 'prepare for offline' card in the trip rail (itinerary →
POI details → vector tiles) that flips the topbar badge to '📦 offline ready'
- chat/scripts/nudges: Florence-specific strings generalized (stay name/dates,
vibes, pacing summary) so both demo cities read naturally
This commit is contained in:
parent
146c7e168c
commit
30f3dd9cbf
413
mock/app.js
413
mock/app.js
|
|
@ -21,6 +21,80 @@ const deep = o => JSON.parse(JSON.stringify(o));
|
|||
const WALK_KMH = 4.6;
|
||||
const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60)));
|
||||
|
||||
// ---------------- route client (local OSRM via the /route proxy) ----------
|
||||
// Legs start life as straight-line estimates (conf 1) and are upgraded in the
|
||||
// background to real road-network times + geometry (conf 3) when the point
|
||||
// pair is inside the router's extract. Out of coverage → stays an estimate.
|
||||
let routerOn = false;
|
||||
async function checkRouter() {
|
||||
try { const r = await fetch('/router-status'); routerOn = !!(await r.json()).router; }
|
||||
catch { routerOn = false; }
|
||||
}
|
||||
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
|
||||
// OSRM encodes with precision 5 (unlike Google’s default 6)
|
||||
function decodePolyline(str, precision = 5) {
|
||||
const out = [];
|
||||
let p = 0, lat = 0, lng = 0;
|
||||
const next = () => { // one zigzag varint component
|
||||
let result = 0, shift = 0, b;
|
||||
do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32);
|
||||
return (result & 1) ? ~(result >> 1) : (result >> 1);
|
||||
};
|
||||
while (p < str.length) {
|
||||
lat += next();
|
||||
lng += next();
|
||||
out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat]
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// route an ordered list of [lat,lng] points; null when unroutable (out of coverage)
|
||||
async function routePts(points, mode = 'foot') {
|
||||
if (!routerOn) return null;
|
||||
const q = points.map(p => `${p[1]},${p[0]}`).join(';');
|
||||
try {
|
||||
const r = await fetch(`/route?mode=${mode}&points=${encodeURIComponent(q)}`);
|
||||
const j = await r.json();
|
||||
const route = j?.routes?.[0];
|
||||
if (!route || !route.distance) return null;
|
||||
return {
|
||||
durMin: Math.max(1, Math.round(route.duration / 60)),
|
||||
distance: route.distance,
|
||||
geometry: decodePolyline(route.geometry).map(p => [p[1], p[0]]), // → [lat, lng]
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
let legToken = 0;
|
||||
// background enrichment: upgrade each straight-line leg to a real route
|
||||
async function enrichLegs(d = day) {
|
||||
if (!routerOn) return;
|
||||
const token = ++legToken;
|
||||
for (let i = 0; i < d.stops.length - 1; i++) {
|
||||
const a = d.stops[i], b = d.stops[i + 1];
|
||||
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
|
||||
const res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode);
|
||||
if (token !== legToken) return; // the day changed mid-flight
|
||||
const leg = d.legs[i];
|
||||
if (res) {
|
||||
const changed = leg.dur !== res.durMin || !leg.geometry;
|
||||
leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry;
|
||||
leg.src = 'local router';
|
||||
if (changed) renderAll();
|
||||
} else {
|
||||
leg.conf = 1; leg.src = 'estimate — outside router coverage';
|
||||
renderAll();
|
||||
}
|
||||
}
|
||||
// return-to-hotel leg when a single stay anchors the day
|
||||
if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) {
|
||||
const last = d.stops[d.stops.length - 1];
|
||||
if (last.kind !== 'region' && last.kind !== 'transit') {
|
||||
const res = await routePts([last.at, dayBase(d).at], 'foot');
|
||||
if (token !== legToken) return;
|
||||
if (res) { d.retRouted = res; renderAll(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- state ----------------
|
||||
const days = {};
|
||||
M.days.forEach(d => days[d.id] = deep(d));
|
||||
|
|
@ -78,7 +152,8 @@ function renderHotelMks() {
|
|||
let hotelMks = [], trainMks = [];
|
||||
|
||||
function initMap() {
|
||||
map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 14);
|
||||
const c = M.trip.places?.[0]?.center || [43.769, 11.252];
|
||||
map = L.map('map', { zoomControl: false }).setView(c, 14);
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
L.tileLayer('/tiles/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19
|
||||
|
|
@ -113,6 +188,8 @@ 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;
|
||||
// a single stay anchored the day and the router has a real walk → use it
|
||||
if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin;
|
||||
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)));
|
||||
|
|
@ -124,20 +201,26 @@ function layout(d = day) {
|
|||
}
|
||||
function rebuildLegs(d = day) {
|
||||
d.legs = [];
|
||||
delete d.retRouted;
|
||||
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 {
|
||||
const mm = (M.multimodal || []).find(x => x.into === b.id);
|
||||
if (mm) {
|
||||
d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub });
|
||||
} 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 });
|
||||
// straight-line estimate until the router upgrades it (or proves out of coverage)
|
||||
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
enrichLegs(d);
|
||||
}
|
||||
|
||||
// ---------------- vague / region placeholder stops ----------------
|
||||
const REGION_MAP = {
|
||||
|
|
@ -182,14 +265,18 @@ function renderMap() {
|
|||
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]], {
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
// real road geometry once the router has answered, straight line until then
|
||||
const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]];
|
||||
legEls[i] = L.polyline(g, {
|
||||
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);
|
||||
const g = (obj.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at];
|
||||
retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map);
|
||||
otherLayers.push(retLine);
|
||||
retLines.push(retLine);
|
||||
});
|
||||
|
|
@ -207,12 +294,15 @@ function renderMap() {
|
|||
});
|
||||
});
|
||||
const cur = days[curDay];
|
||||
if (cur.stops.length) {
|
||||
const pts = scope === 'trip'
|
||||
? M.days.flatMap(md => days[md.id].stops.map(s => s.at))
|
||||
: cur.stops.map(s => s.at);
|
||||
if (pts.length) {
|
||||
const discOpen = !$('#discover').classList.contains('hidden');
|
||||
const pad = matchMedia('(max-width: 860px)').matches
|
||||
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
|
||||
: { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] };
|
||||
map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...cur.stops.map(s => s.at)]), pad);
|
||||
map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...pts]), pad);
|
||||
}
|
||||
}
|
||||
function highlightStop(id, on) {
|
||||
|
|
@ -425,6 +515,7 @@ function promoteCand(c, slot, state = 'planned', flash = true) {
|
|||
if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
|
||||
}
|
||||
rebuildLegs();
|
||||
commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`);
|
||||
renderAll(flash ? [stop.id] : []);
|
||||
return { stop, incumbent: stop.slot === slot && stop.state === 'planned' ? day.stops.find(x => x.slot === slot && x.state === 'alt' && x !== stop) : null };
|
||||
}
|
||||
|
|
@ -437,7 +528,7 @@ function chooseCand(c, slot) {
|
|||
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();
|
||||
closeDiscover(); renderAll();
|
||||
closeDiscover(); commit(`hotel → ${c.name}`); renderAll();
|
||||
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;
|
||||
}
|
||||
|
|
@ -460,7 +551,7 @@ function holdIdea(c, slot) {
|
|||
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();
|
||||
closeDiscover(); renderAll();
|
||||
closeDiscover(); commit(`hotel option: ${c.name}`); renderAll();
|
||||
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;
|
||||
}
|
||||
|
|
@ -662,7 +753,7 @@ function renderStopCard(body, s, i, flashIds) {
|
|||
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
|
||||
} else {
|
||||
day.stops.push(makeStop(cand, slot, 'maybe'));
|
||||
rebuildLegs(); renderAll([cand.id]);
|
||||
rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]);
|
||||
ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
|
||||
}
|
||||
if (disc) closeDiscover();
|
||||
|
|
@ -673,6 +764,7 @@ function renderStopCard(body, s, i, flashIds) {
|
|||
const [moved] = day.stops.splice(from, 1);
|
||||
day.stops.splice(i, 0, moved);
|
||||
rebuildLegs();
|
||||
commit(`moved ${moved.name}`);
|
||||
renderAll([moved.id]);
|
||||
});
|
||||
c.addEventListener('mouseenter', () => highlightStop(s.id, true));
|
||||
|
|
@ -739,7 +831,7 @@ function setState(s, state) {
|
|||
if (cur) { cur.state = 'alt'; ai(`Swapped: <b>${s.name}</b> is now your ${s.slot}; <b>${cur.name}</b> becomes the backup.`, 600); }
|
||||
}
|
||||
s.state = state;
|
||||
rebuildLegs(); renderAll([s.id]);
|
||||
rebuildLegs(); commit(`${s.name} → ${stateLabel(state)}`); renderAll([s.id]);
|
||||
if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt')))
|
||||
ai(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400);
|
||||
}
|
||||
|
|
@ -748,7 +840,7 @@ function moveStopToDay(s, targetId) {
|
|||
const t = days[targetId];
|
||||
t.stops.push(s);
|
||||
rebuildLegs(); if (t !== day) rebuildLegs(t);
|
||||
renderAll();
|
||||
commit(`${s.name} → ${t.label}`); renderAll();
|
||||
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
|
||||
}
|
||||
const dayLabel = () => M.days.find(d => d.id === day.id).label;
|
||||
|
|
@ -771,26 +863,9 @@ function renderBudget() {
|
|||
}
|
||||
|
||||
// ---------------- nudges (issues, but gentle) ----------------
|
||||
const nudgesByDay = {
|
||||
D1: [
|
||||
{ id: 'N1', fixed: false,
|
||||
title: 'Lunch may be a little tight',
|
||||
body: '45 min at Trattoria Mario — a meal there usually runs <b>~90 min</b> <i>(web)</i>, and the local queue adds to that.',
|
||||
fix: { label: 'Extend to ~90 min',
|
||||
apply() { const s = stopById('S4'); if (s) s.dur = 90; },
|
||||
msg: 'Lunch extended to 90 min — everything after it slides later; the day still fits.' } },
|
||||
{ id: 'N2', fixed: false,
|
||||
title: 'Pitti: 1 h (you) vs ~2.5 h usually',
|
||||
body: 'Guides suggest 2–3 h for the full complex. We kept your 1 h — no need to change anything.',
|
||||
fix: { label: 'Keep as is', apply() {} } }
|
||||
],
|
||||
D2: [], D3: [
|
||||
{ id: 'N3', fixed: false,
|
||||
title: 'Dinner is still an idea',
|
||||
body: 'Il Latte is an option, not a plan — worth deciding by this evening so there’s a fallback if it’s full.',
|
||||
fix: { label: 'Keep as is', apply() {} } }
|
||||
]
|
||||
};
|
||||
// Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags.
|
||||
const nudgesByDay = deep(M.nudges || {});
|
||||
Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false));
|
||||
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
|
||||
function renderNudges() {
|
||||
const p = $('#nudges'); if (!p) return; p.innerHTML = '';
|
||||
|
|
@ -802,7 +877,9 @@ function renderNudges() {
|
|||
d.append(el('div', 'nb', n.body));
|
||||
const b = el('button', 'btn ghost small', n.fix.label);
|
||||
b.onclick = () => {
|
||||
n.fixed = true; n.fix.apply();
|
||||
n.fixed = true;
|
||||
if (n.fix.stop && n.fix.dur) { const st = stopById(n.fix.stop); if (st) st.dur = n.fix.dur; }
|
||||
commit(`nudge: ${n.fix.label}`);
|
||||
renderAll();
|
||||
if (n.fix.msg) ai(n.fix.msg, 300);
|
||||
};
|
||||
|
|
@ -944,13 +1021,23 @@ function openL3(legIdx) {
|
|||
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');
|
||||
let rt = 0; // debounce token for the router round-trip
|
||||
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));
|
||||
leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate
|
||||
leg.conf = 1;
|
||||
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
|
||||
renderBudget();
|
||||
const t = ++rt;
|
||||
routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], 'foot').then(res => {
|
||||
if (t !== rt || !l3) return;
|
||||
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); }
|
||||
box.innerHTML = 'leg time: ' + (res ? '' : 'est. ');
|
||||
box.append(tchip(leg.dur, leg.conf));
|
||||
renderBudget();
|
||||
});
|
||||
};
|
||||
[m1, m2, m3].forEach(m => m.on('drag', upd));
|
||||
$('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`;
|
||||
|
|
@ -961,6 +1048,7 @@ function openL3(legIdx) {
|
|||
const obs = setInterval(() => {
|
||||
if (!$('#l3-editor').classList.contains('hidden')) return;
|
||||
clearInterval(obs);
|
||||
commit('edited a leg route');
|
||||
renderAll();
|
||||
}, 250);
|
||||
}
|
||||
|
|
@ -970,6 +1058,79 @@ function closeL3() {
|
|||
}
|
||||
$('#l3-close').onclick = closeL3;
|
||||
|
||||
// ---------------- version history (every edit = a version) ----------------
|
||||
// Both editors (user + chat) funnel through commit(): the snapshot captures
|
||||
// days + stays, so undo/redo/revert is a single code path.
|
||||
let version = 0;
|
||||
let history = []; // [{v, label, at, days, stays}]
|
||||
let hIdx = -1; // pointer into history
|
||||
const commit = (label) => {
|
||||
history = history.slice(0, hIdx + 1); // drop any redo branch
|
||||
history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) });
|
||||
hIdx = history.length - 1;
|
||||
if (history.length > 60) { history.shift(); hIdx--; }
|
||||
renderVersionPill();
|
||||
};
|
||||
function restore(idx, silent) {
|
||||
const s = history[idx]; if (!s) return;
|
||||
hIdx = idx;
|
||||
Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); });
|
||||
stays = deep(s.stays);
|
||||
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
|
||||
closeDiscover(); closeL3();
|
||||
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
|
||||
renderHotelMks(); renderBaseChip();
|
||||
renderAll();
|
||||
renderVersionPill();
|
||||
if (!silent) ai(`↩ Reverted to <b>v${s.v}</b> — ${s.label}. Later edits are gone from the branch; redo can’t bring them back.`, 400);
|
||||
}
|
||||
function undo() { if (hIdx > 0) restore(hIdx - 1); }
|
||||
function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); }
|
||||
function renderVersionPill() {
|
||||
const elV = $('#verlabel'); if (!elV) return;
|
||||
elV.textContent = 'v' + version;
|
||||
$('#ver-undo').classList.toggle('off', hIdx <= 0);
|
||||
$('#ver-redo').classList.toggle('off', hIdx >= history.length - 1);
|
||||
}
|
||||
// what changed between that version and the current plan (stop-level, per day)
|
||||
function diffVsCurrent(snap) {
|
||||
const parts = [];
|
||||
M.days.forEach(md => {
|
||||
const A = (snap.days[md.id] || {}).stops || [];
|
||||
const B = (days[md.id] || {}).stops || [];
|
||||
const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id));
|
||||
const added = B.filter(x => !aSet.has(x.id)).map(x => x.name);
|
||||
const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name);
|
||||
if (added.length || removed.length)
|
||||
parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' −' + removed.join(', −') : ''}`);
|
||||
});
|
||||
return parts.slice(0, 3);
|
||||
}
|
||||
function toggleVerList() {
|
||||
const p = $('#verlist');
|
||||
if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; }
|
||||
p.innerHTML = '';
|
||||
const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse();
|
||||
rows.forEach(({ s, i }) => {
|
||||
const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : ''));
|
||||
const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const d = diffVsCurrent(s);
|
||||
r.append(el('div', 'vr-main', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
|
||||
if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · ')));
|
||||
r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); };
|
||||
p.append(r);
|
||||
});
|
||||
p.classList.remove('hidden');
|
||||
}
|
||||
$('#ver-undo').onclick = undo;
|
||||
$('#ver-redo').onclick = redo;
|
||||
$('#verlabel').onclick = toggleVerList;
|
||||
document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
|
||||
});
|
||||
|
||||
// ---------------- day tabs ----------------
|
||||
function buildDayTabs() {
|
||||
const tabs = $('#daytabs'); tabs.innerHTML = '';
|
||||
|
|
@ -1001,7 +1162,107 @@ function setDay(id) {
|
|||
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
|
||||
renderAll();
|
||||
}
|
||||
function renderAll(flashIds = []) { renderRail(flashIds); renderMap(); }
|
||||
let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview)
|
||||
function renderAll(flashIds = []) {
|
||||
if (scope === 'trip') renderTripRail();
|
||||
else renderRail(flashIds);
|
||||
renderMap();
|
||||
}
|
||||
function setScope(sc) {
|
||||
scope = sc;
|
||||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc));
|
||||
$('#daystrip').classList.toggle('hidden', sc === 'trip');
|
||||
$('#rail-foot').classList.toggle('hidden', sc === 'trip');
|
||||
renderAll();
|
||||
}
|
||||
$('#scope-day').onclick = () => setScope('day');
|
||||
$('#scope-trip').onclick = () => setScope('trip');
|
||||
|
||||
// ---------------- L0 trip overview (macro scale) ----------------
|
||||
function dayStats(d) {
|
||||
const planned = d.stops.filter(s => s.state === 'planned');
|
||||
const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0)
|
||||
+ (planned.length && !d.isDeparture ? returnDur(d) : 0);
|
||||
return { count: planned.length, dur, end: layout(d),
|
||||
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
|
||||
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
|
||||
}
|
||||
function renderTripRail() {
|
||||
const body = $('#rail-body'); body.innerHTML = '';
|
||||
const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0);
|
||||
const head = el('div', 'trip-head');
|
||||
head.append(el('div', 'trip-title', M.trip.title));
|
||||
head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`));
|
||||
body.append(head);
|
||||
M.days.forEach(md => {
|
||||
const d = days[md.id];
|
||||
const st = dayStats(d);
|
||||
const pct = Math.min(100, st.dur / st.cap * 100);
|
||||
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
|
||||
const top = el('div', 'td-top');
|
||||
top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : '')));
|
||||
top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
|
||||
card.append(top);
|
||||
const bar = el('div', 'td-bar');
|
||||
const fill = el('div', 'td-fill');
|
||||
fill.style.width = pct + '%';
|
||||
fill.classList.toggle('warn', pct > 92);
|
||||
bar.append(fill);
|
||||
card.append(bar);
|
||||
const meta = el('div', 'td-meta');
|
||||
meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`));
|
||||
meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`));
|
||||
const cur = (d.stops.find(x => x.price)?.price)?.cur || '';
|
||||
if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`));
|
||||
card.append(meta);
|
||||
const names = d.stops.filter(s => s.state === 'planned').map(s => s.name);
|
||||
card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : '')));
|
||||
card.onclick = () => { setDay(md.id); setScope('day'); };
|
||||
body.append(card);
|
||||
});
|
||||
body.append(offlineCard());
|
||||
}
|
||||
|
||||
// ---------------- on-trip offline bundle (simulated) ----------------
|
||||
let offlineState = 'idle'; // idle | working | ready
|
||||
function offlineCard() {
|
||||
const c = el('div', 'offline-card');
|
||||
if (offlineState === 'ready') {
|
||||
c.append(el('div', 'oc-title', '📦 Offline bundle ready'));
|
||||
c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`));
|
||||
} else if (offlineState === 'working') {
|
||||
c.append(el('div', 'oc-title', '📦 Preparing offline bundle…'));
|
||||
c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…'));
|
||||
} else {
|
||||
c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle'));
|
||||
c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.'));
|
||||
const b = el('button', 'btn ghost small', 'Prepare for trip');
|
||||
b.onclick = () => prepareOffline();
|
||||
c.append(b);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function prepareOffline() {
|
||||
offlineState = 'working';
|
||||
renderTripRail();
|
||||
const steps = [
|
||||
['itinerary ✓ → POI details…', 500],
|
||||
['POI details (hours, tags) ✓ → vector tiles…', 1200],
|
||||
[`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100],
|
||||
];
|
||||
steps.forEach(([txt, t]) => setTimeout(() => {
|
||||
if (offlineState !== 'working') return;
|
||||
const p = document.querySelector('.oc-progress');
|
||||
if (p) p.textContent = txt;
|
||||
}, t));
|
||||
setTimeout(() => {
|
||||
offlineState = 'ready';
|
||||
const badge = $('#offline-badge');
|
||||
if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; }
|
||||
if (scope === 'trip') renderTripRail();
|
||||
ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ---------------- chat ----------------
|
||||
function msg(role, html) {
|
||||
|
|
@ -1046,15 +1307,18 @@ function handleUser(v) {
|
|||
}
|
||||
if (s.includes('later') || s.includes('morning')) {
|
||||
day.startMin += 30;
|
||||
commit('start day 30 min later');
|
||||
renderAll(day.stops.map(x => x.id));
|
||||
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 n = activeNudges()[0];
|
||||
const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned');
|
||||
if (n && !n.fixed && lunch) {
|
||||
n.fixed = true; lunch.dur = 90;
|
||||
renderAll([lunch.id]);
|
||||
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`);
|
||||
const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
|
||||
const st = n ? stopById(n.fix.stop) : null;
|
||||
if (n && !n.fixed && st) {
|
||||
n.fixed = true; st.dur = n.fix.dur;
|
||||
commit(`nudge: ${n.fix.label}`);
|
||||
rebuildLegs(day);
|
||||
renderAll([st.id]);
|
||||
ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`);
|
||||
} else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`);
|
||||
} else {
|
||||
ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop card’s <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
|
||||
|
|
@ -1064,16 +1328,23 @@ $('#chat-send').onclick = chatSend;
|
|||
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
|
||||
|
||||
// ---------------- landing → parse → plan ----------------
|
||||
const SAMPLE = [
|
||||
// parsed-booking preview, derived from the active dataset (city-aware)
|
||||
function sampleRows() {
|
||||
const hb = M.trip.bookings.find(b => b.type === 'hotel');
|
||||
const tb = M.trip.bookings.find(b => b.type === 'train');
|
||||
const rows = [
|
||||
['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'],
|
||||
['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'],
|
||||
['dates', hb.dates + ' ' + (M.trip.places[0].dates[0] || '').slice(0, 4), 'booked stay', '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']
|
||||
['ref', `Confirmation ${hb.ref}`, 'read from footer image — worth a double-check', 'mid'],
|
||||
];
|
||||
if (tb) rows.push(['train', tb.name, `${tb.from} → ${tb.to} · dep ${tb.depart}`, 'hi']);
|
||||
return rows;
|
||||
}
|
||||
function showParse() {
|
||||
const t = $('#parse-table'); t.innerHTML = '';
|
||||
SAMPLE.forEach(([k, v, note, c]) => {
|
||||
sampleRows().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);
|
||||
|
|
@ -1092,10 +1363,12 @@ function startApp() {
|
|||
$('#landing').classList.add('hidden');
|
||||
$('#topbar').classList.remove('hidden');
|
||||
$('#app').classList.remove('hidden');
|
||||
$('#crumb').textContent = M.trip.title;
|
||||
renderBaseChip();
|
||||
buildDayTabs();
|
||||
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
|
||||
renderAll();
|
||||
commit('initial plan');
|
||||
script1();
|
||||
}
|
||||
|
||||
|
|
@ -1171,6 +1444,7 @@ function addStopToDay(id, requested) {
|
|||
copy.state = state;
|
||||
day.stops.push(copy);
|
||||
rebuildLegs();
|
||||
commit(`${copy.name} → ${stateLabel(state)}`);
|
||||
renderAll([id]);
|
||||
return state;
|
||||
}
|
||||
|
|
@ -1178,22 +1452,22 @@ function addStopToDay(id, requested) {
|
|||
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. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?', 900);
|
||||
{ const b0 = dayBase(); ai(`Anchored your stay — <b>${b0.name}, ${stayRange(b0)}</b>; bookings are pinned as hard anchors. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — 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(); };
|
||||
c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(v.label); };
|
||||
chips.append(c);
|
||||
});
|
||||
m.append(chips);
|
||||
}, 1800);
|
||||
}, 300);
|
||||
}
|
||||
function script2() {
|
||||
function script2(vibe) {
|
||||
scriptBusy = true;
|
||||
ai('Noted — relaxed, food-first. A few ideas for your first day:', 900);
|
||||
ai(`Noted — ${vibe || M.vibes[0].label}. A few ideas for your first day:`, 900);
|
||||
setTimeout(() => {
|
||||
const m = msg('ai', '');
|
||||
const cards = el('div', 'cards');
|
||||
|
|
@ -1270,15 +1544,10 @@ function script2() {
|
|||
}
|
||||
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();
|
||||
rebuildLegs(day);
|
||||
renderAll(day.stops.map(s => s.id));
|
||||
ai('Day sequenced, gelato after the gardens. <b>' + ((layout() - day.startMin) / 60).toFixed(1) + ' h</b> of 11 h waking — comfortably paced. Two gentle nudges below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 1100);
|
||||
const st = dayStats(day);
|
||||
ai(`Day sequenced — <b>${(st.dur / 60).toFixed(1)} h</b> of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100);
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
|
|
@ -1288,13 +1557,16 @@ const dz = $('#dropzone');
|
|||
['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-go').onclick = () => { showParse(); };
|
||||
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
|
||||
// ?city= preselects the demo city on the landing screen; the demo chips switch dataset
|
||||
(function () {
|
||||
const c = new URLSearchParams(location.search).get('city');
|
||||
if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name;
|
||||
document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => {
|
||||
location.href = location.pathname + '?city=' + b.dataset.city;
|
||||
});
|
||||
})();
|
||||
|
||||
// drop a candidate onto the plan panel itself → hold it as an idea
|
||||
(function () {
|
||||
|
|
@ -1308,10 +1580,15 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c
|
|||
if (!found) return;
|
||||
const { c, slot } = found; dragCand = null;
|
||||
day.stops.push(makeStop(c, slot, 'maybe'));
|
||||
rebuildLegs(); renderAll([c.id]);
|
||||
rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]);
|
||||
if (disc) closeDiscover();
|
||||
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
|
||||
});
|
||||
})();
|
||||
|
||||
initMap();
|
||||
checkRouter().then(() => { // once router status is known, enrich the visible day's legs
|
||||
if (routerOn) enrichLegs(day);
|
||||
const b = $('#offline-badge');
|
||||
if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online';
|
||||
});
|
||||
|
|
|
|||
263
mock/data.js
263
mock/data.js
|
|
@ -2,7 +2,11 @@
|
|||
// 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 = {
|
||||
//
|
||||
// Two demo cities: Florence (rich itinerary demo) and Boston (inside the
|
||||
// local OSRM extract, so legs are routed on the real road network — see
|
||||
// server.js /route proxy and app.js routeClient). ?city=florence|boston
|
||||
const FLORENCE = {
|
||||
trip: {
|
||||
id: 'trip-2026-09',
|
||||
version: 1,
|
||||
|
|
@ -244,6 +248,26 @@ window.MOCK = {
|
|||
]
|
||||
},
|
||||
|
||||
nudges: {
|
||||
D1: [
|
||||
{ id: 'N1', title: 'Lunch may be a little tight',
|
||||
body: '45 min at Trattoria Mario — a meal there usually runs <b>~90 min</b> <i>(web)</i>, and the local queue adds to that.',
|
||||
fix: { label: 'Extend to ~90 min', stop: 'S4', dur: 90,
|
||||
msg: 'Lunch extended to 90 min — everything after it slides later; the day still fits.' } },
|
||||
{ id: 'N2', title: 'Pitti: 1 h (you) vs ~2.5 h usually',
|
||||
body: 'Guides suggest 2–3 h for the full complex. We kept your 1 h — no need to change anything.',
|
||||
fix: { label: 'Keep as is' } }
|
||||
],
|
||||
D2: [],
|
||||
D3: [
|
||||
{ id: 'N3', title: 'Dinner is still an idea',
|
||||
body: 'Il Latte is an option, not a plan — worth deciding by this evening so there’s a fallback if it’s full.',
|
||||
fix: { label: 'Keep as is' } }
|
||||
]
|
||||
},
|
||||
multimodal: [
|
||||
{ into: 'S5', dur: 18, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] }
|
||||
],
|
||||
vibes: [
|
||||
{ id: 'v1', label: 'art & food · relaxed pace' },
|
||||
{ id: 'v2', label: 'gardens & walking' },
|
||||
|
|
@ -262,3 +286,240 @@ window.MOCK = {
|
|||
enrich: 'lunch from 12:30 · book ahead in season' }
|
||||
]
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boston — same shapes, but INSIDE the local OSRM extract (NE-US), so the
|
||||
// routeClient upgrades legs to real road-network times + geometry.
|
||||
// ---------------------------------------------------------------------------
|
||||
const BOSTON = {
|
||||
trip: {
|
||||
id: 'trip-2026-09-b',
|
||||
version: 1,
|
||||
title: 'Boston 14–16 Sep',
|
||||
stays: [
|
||||
{ id: 'ST1', name: 'Hotel Commonwealth', at: [42.3495, -71.0960], place: 'Boston',
|
||||
checkIn: '2026-09-14', checkOut: '2026-09-16', state: 'booked', price: 165 },
|
||||
],
|
||||
places: [
|
||||
{ id: 'PL1', name: 'Boston', bbox: [42.33, -71.12, 42.375, -71.04],
|
||||
center: [42.357, -71.063], vibe: 'history + harbor, food-first',
|
||||
dates: ['2026-09-14', '2026-09-16'] }
|
||||
],
|
||||
bookings: [
|
||||
{ type: 'hotel', name: 'Hotel Commonwealth', ref: 'H-55210',
|
||||
dates: '14–16 Sep', where: [42.3495, -71.0960], status: 'booked', source: 'user_booking' },
|
||||
{ type: 'train', name: 'Amtrak 700', ref: 'A-33421',
|
||||
from: 'Back Bay', to: 'Penn Station, NYC', date: '2026-09-16',
|
||||
depart: '17:00', arrive: '19:40', status: 'booked', source: 'user_booking',
|
||||
stations: { from: [42.3519, -71.0654], to: [40.7506, -73.9935] } }
|
||||
]
|
||||
},
|
||||
days: [
|
||||
{ id: 'D1', date: '2026-09-14', label: 'Day 1 · Mon 14',
|
||||
startMin: 8 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [42.3495, -71.0960], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S2', name: 'Museum of Fine Arts', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.mfa.org',
|
||||
at: [42.3394, -71.0949], dur: 120, durConf: 3,
|
||||
suggested: { value: 150, conf: 2, src: 'mfa.org' },
|
||||
price: { amount: 25, cur: '$', conf: 2, src: 'mfa.org' },
|
||||
img: ['🖼️', 'MFA Boston', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)',
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/MFA%2C_Boston%2C_MA.jpg/1280px-MFA%2C_Boston%2C_MA.jpg'],
|
||||
detail: {
|
||||
summary: 'One of the largest art museums in the US, next to the hotel. The Impressionist wing and the Asian galleries are the anchors — allow 2 h for highlights, 3 h if you linger in the Japanese scroll wing. Thursday/Friday the light in the skylit atrium is the best.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.mfa.org' },
|
||||
{ label: 'Tickets', href: 'https://www.mfa.org/visit/tickets' },
|
||||
{ label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g1146335-d183805-Reviews-Museum_of_Fine_Arts-Boston_MA.html' }
|
||||
] } },
|
||||
{ id: 'S4', name: 'Lunch — State Street Fare', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.statestreetfare.com',
|
||||
at: [42.3410, -71.0935], dur: 60, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'statestreetfare.com' },
|
||||
price: { amount: 30, cur: '$', conf: 2, src: 'statestreetfare.com' },
|
||||
img: ['🍽️', 'State Street Fare', 'linear-gradient(135deg,#d08b3c,#9c5f1e)'],
|
||||
detail: {
|
||||
summary: 'The MFA’s own café with a garden terrace — New England seasonal plates. It’s the sensible choice after a museum morning; the terrace gets crowded by 13:00 on weekends.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.statestreetfare.com' },
|
||||
{ label: 'Menu', href: 'https://www.statestreetfare.com/menu' }
|
||||
] } },
|
||||
{ id: 'S5', name: 'Boston Public Library (Coppa Wing)', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.bpl.org',
|
||||
at: [42.3499, -71.0769], dur: 60, durConf: 3,
|
||||
suggested: { value: 60, conf: 2, src: 'bpl.org' },
|
||||
price: { amount: 0, cur: '$', conf: 2, src: 'bpl.org' },
|
||||
img: ['📚', 'BPL', 'linear-gradient(135deg,#5f7a4f,#3a4f2c)'],
|
||||
detail: {
|
||||
summary: 'The Beaux-Arts free reading room and the Domitian & Apollo statues in the courtyard. A good 45–60 min breather on the walk toward the North End — the architecture alone is worth it.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.bpl.org' }
|
||||
] } },
|
||||
{ id: 'S6', name: 'Faneuil Hall & Quincy Market', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.boston.gov/departments/parks/faneuil-hall',
|
||||
at: [42.3601, -71.0555], dur: 90, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'boston.gov' },
|
||||
price: null,
|
||||
img: ['🏛️', 'Faneuil Hall', 'linear-gradient(135deg,#b5533c,#7a3a2c)'],
|
||||
detail: {
|
||||
summary: 'The “Cradle of Liberty” and the covered food market. Best worked top-down: the market stalls first (clam chowder, lemonade), then the hall’s free exhibits. Weekends are busy — September afternoons are kinder.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.boston.gov/departments/parks/faneuil-hall' },
|
||||
{ label: 'Quincy Market', href: 'https://www.quincymarket.com' }
|
||||
] } }
|
||||
]
|
||||
},
|
||||
{ id: 'D2', date: '2026-09-15', label: 'Day 2 · Tue 15',
|
||||
startMin: 8 * 60 + 30, wakingHours: 11,
|
||||
base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S8', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [42.3495, -71.0960], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S9', name: 'Paul Revere House', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.boston.gov/departments/parks/paul-revere-house',
|
||||
at: [42.3616, -71.0631], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: 'boston.gov' },
|
||||
price: { amount: 10, cur: '$', conf: 2, src: 'boston.gov' },
|
||||
img: ['🏠', 'Paul Revere House', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'],
|
||||
detail: {
|
||||
summary: 'The oldest house in Boston, a tight 30–45 min with the resident docent. Timed entry; the North End around it is the walk.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.boston.gov/departments/parks/paul-revere-house' }
|
||||
] } },
|
||||
{ id: 'S10', name: 'Lunch — Union Oyster House', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.unionoysterhouse.com',
|
||||
at: [42.3621, -71.0604], dur: 60, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'unionoysterhouse.com' },
|
||||
price: { amount: 40, cur: '$', conf: 2, src: 'unionoysterhouse.com' },
|
||||
img: ['🦪', 'Union Oyster House', 'linear-gradient(135deg,#4f7a9e,#2c4f6b)'],
|
||||
detail: {
|
||||
summary: 'Claimed oldest restaurant in the US (1826). Clam chowder and a short, serious menu. Book ahead for the lunch hour — the room is small.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://www.unionoysterhouse.com' }
|
||||
] } },
|
||||
{ id: 'S11', name: 'New England Aquarium', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://neaq.org',
|
||||
at: [42.3598, -71.0483], dur: 120, durConf: 3,
|
||||
suggested: { value: 120, conf: 2, src: 'neaq.org' },
|
||||
price: { amount: 35, cur: '$', conf: 2, src: 'neaq.org' },
|
||||
img: ['🐋', 'NE Aquarium', 'linear-gradient(135deg,#2f6fd6,#1d4f9c)'],
|
||||
detail: {
|
||||
summary: 'The whale tank and the Harborwalk beyond it. September is a good time — the new whale migration is winding down but the penguin and jelly exhibits are year-round. Allow 2 h.',
|
||||
links: [
|
||||
{ label: 'Official site', href: 'https://neaq.org' }
|
||||
] } }
|
||||
]
|
||||
},
|
||||
{ id: 'D3', date: '2026-09-16', label: 'Day 3 · Wed 16', isDeparture: true,
|
||||
startMin: 8 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S15', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [42.3495, -71.0960], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S12', name: 'Boston Common & Public Garden', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [42.3547, -71.0717], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 1, src: null },
|
||||
price: null,
|
||||
img: ['🌳', 'Public Garden', 'linear-gradient(135deg,#4f9e63,#2c6b40)'] },
|
||||
{ id: 'S16', name: 'Train → Penn Station NYC', kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: [42.3519, -71.0654], dur: 30, durConf: 4,
|
||||
transit: { name: 'Amtrak 700', ref: 'A-33421',
|
||||
from: 'Back Bay', to: 'Penn Station, NYC',
|
||||
depart: '17:00', arrive: '19:40', arriveBy: '16:30',
|
||||
buffer: '30 min before departure' },
|
||||
suggested: null,
|
||||
price: { amount: 65, cur: '$', conf: 4, src: 'booking' },
|
||||
img: ['🚄', 'Amtrak 700', 'linear-gradient(135deg,#274b8f,#16294d)'] }
|
||||
]
|
||||
}
|
||||
],
|
||||
candidates: {
|
||||
hotel: [
|
||||
{ id: 'H1', name: 'Hotel Commonwealth', at: [42.3495, -71.0960], price: 165, dur: null,
|
||||
tags: ['by the mfa', 'quiet', 'refundable'], pitch: 'Where you are now — one block from the MFA, refundable until the 12th.', url: null },
|
||||
{ id: 'H2', name: 'The Liberty Hotel', at: [42.3580, -71.0530], price: 240, dur: null,
|
||||
tags: ['north end', 'rooftop', 'grand'], pitch: 'Rooftop bar over the North End — closer to the food, pricier.', url: null },
|
||||
{ id: 'H3', name: 'Haven Guest House', at: [42.3470, -71.0900], price: 120, dur: null,
|
||||
tags: ['value', 'small', 'near mfa'], pitch: 'Six rooms off the MFA’s garden — cheaper, a short walk to everything.', url: null }
|
||||
],
|
||||
lunch: [
|
||||
{ id: 'C1', name: 'State Street Fare', at: [42.3410, -71.0935], price: 30, dur: 75,
|
||||
tags: ['by the mfa', 'garden', 'seasonal'], pitch: 'The MFA’s café — garden terrace, New England seasonal plates.',
|
||||
url: 'https://www.statestreetfare.com', img: ['🍽️', 'State Street Fare', 'linear-gradient(135deg,#d08b3c,#9c5f1e)'] },
|
||||
{ id: 'C2', name: 'Union Oyster House', at: [42.3621, -71.0604], price: 40, dur: 75,
|
||||
tags: ['historic', 'oysters', 'book ahead'], pitch: 'Claimed oldest restaurant in the US — clam chowder, a short serious menu.',
|
||||
url: 'https://www.unionoysterhouse.com', img: ['🦪', 'Union Oyster', 'linear-gradient(135deg,#4f7a9e,#2c4f6b)'] },
|
||||
{ id: 'C3', name: 'Eataly Boston', at: [42.3595, -71.0540], price: 35, dur: 60,
|
||||
tags: ['market', 'fast', 'central'], pitch: 'The food-hall option — a dozen kitchens, grab and go, by Quincy Market.',
|
||||
url: 'https://boston.eataly.net', img: ['🍕', 'Eataly', 'linear-gradient(135deg,#b0563c,#7a3a2c)'] },
|
||||
{ id: 'C4', name: 'Kazuo', at: [42.3540, -71.0620], price: 32, dur: 60,
|
||||
tags: ['japanese', 'counter', 'near the common'], pitch: 'Japanese counter near the Common — quiet, good, a little pricier.',
|
||||
url: 'https://kazooboston.com', img: ['🍣', 'Kazuo', 'linear-gradient(135deg,#5f6b7a,#3a434f)'] }
|
||||
],
|
||||
visit: [
|
||||
{ id: 'V1', name: 'John F. Kennedy Presidential Library', at: [42.3599, -71.0590], price: 15, dur: 90,
|
||||
tags: ['history', 'harbor view', 'indoor'], pitch: 'The JFK archives with a harbor view — a good 90 min, indoor if it turns.',
|
||||
url: 'https://www.jfklibrary.org', img: ['🎙️', 'JFK Library', 'linear-gradient(135deg,#4a5a78,#2f3a50)'] },
|
||||
{ id: 'V2', name: 'Harborwalk', at: [42.3590, -71.0420], price: 0, dur: 60,
|
||||
tags: ['outdoor', 'harbor', 'walk'], pitch: 'The esplanade along the harbor — sunset is the point in September.',
|
||||
url: 'https://www.boston.gov/departments/parks/harborwalk', img: ['🌅', 'Harborwalk', 'linear-gradient(135deg,#c97a3c,#8a4f1a)'] },
|
||||
{ id: 'V3', name: 'T. F. Green Memorial', at: [42.3530, -71.0470], price: 0, dur: 45,
|
||||
tags: ['views', 'free', 'calm'], pitch: 'The memorial at the harbor’s edge — a quiet, photo-friendly 30–45 min.',
|
||||
url: 'https://www.boston.gov', img: ['⛵', 'T. F. Green', 'linear-gradient(135deg,#7a8a9e,#4f5a6b)'] },
|
||||
{ id: 'V4', name: 'Boston Tea Party Ships & Museum', at: [42.3555, -71.0535], price: 30, dur: 75,
|
||||
tags: ['history', 'interactive', 'harbor'], pitch: 'A 75-min interactive re-enactment on the harbor — fun, a little kitsch.',
|
||||
url: 'https://www.bostonteachallenge.org', img: ['🫖', 'Tea Party', 'linear-gradient(135deg,#8f4f4f,#5c2f2f)'] }
|
||||
]
|
||||
},
|
||||
nudges: {
|
||||
D1: [
|
||||
{ id: 'N1', title: 'Lunch may be a little tight',
|
||||
body: '60 min at State Street Fare — the terrace lunch usually runs <b>~75 min</b> <i>(web)</i>, and it crowds by 13:00.',
|
||||
fix: { label: 'Extend to ~75 min', stop: 'S4', dur: 75,
|
||||
msg: 'Lunch extended to 75 min — the afternoon slides a little later; the day still fits.' } },
|
||||
{ id: 'N2', title: 'MFA: 2 h (you) vs ~2.5 h usually',
|
||||
body: 'Guides suggest 2.5–3 h for the highlights. We kept your 2 h — the Impressionist wing alone runs that long.',
|
||||
fix: { label: 'Keep as is' } }
|
||||
],
|
||||
D2: [
|
||||
{ id: 'N3', title: 'Union Oyster House at the lunch hour',
|
||||
body: 'The room is small and booked solid 12:00–13:30 — a 12:00 seat beats a 12:30 walk-in.',
|
||||
fix: { label: 'Keep as is' } }
|
||||
],
|
||||
D3: []
|
||||
},
|
||||
multimodal: [],
|
||||
vibes: [
|
||||
{ id: 'v1', label: 'history & harbor · relaxed pace' },
|
||||
{ id: 'v2', label: 'museums & art' },
|
||||
{ id: 'v3', label: 'food-first, North End' },
|
||||
{ id: 'v4', label: 'views & sunset walk' }
|
||||
],
|
||||
suggestions: [
|
||||
{ stop: 'S2', pitch: 'The MFA’s Impressionist wing and the Japanese scroll room — 2 h for highlights, 3 h if you linger.',
|
||||
enrich: 'open 10:00–17:00 · free first Thu 4–9 · one block from the hotel' },
|
||||
{ stop: 'S4', pitch: 'The MFA’s own café — the garden terrace gets crowded by 13:00 on weekends.',
|
||||
enrich: 'lunch 12:00–15:00 · ~1 h · terrace seats' },
|
||||
{ stop: 'S5', pitch: 'The Beaux-Arts free reading room and the courtyard’s Domitian & Apollo — a good breather toward the North End.',
|
||||
enrich: 'open 10:00–18:00 · free · ~45 min' },
|
||||
{ stop: 'S6', pitch: 'Work the market stalls first (chowder, lemonade), then the free hall exhibits — weekends are busy.',
|
||||
enrich: 'stalls 10:00–17:00 · hall free · ~90 min' }
|
||||
]
|
||||
};
|
||||
|
||||
// pick the demo city from ?city= (default Florence)
|
||||
const _city = (new URLSearchParams(location.search).get('city') || 'florence').toLowerCase();
|
||||
window.MOCK = _city === 'boston' ? BOSTON : FLORENCE;
|
||||
window.MOCK_CITY = _city;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
<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 class="land-demo">
|
||||
<span>or explore a demo trip</span>
|
||||
<button class="chip" data-city="florence">Florence</button>
|
||||
<button class="chip" data-city="boston">Boston <i>· live road routing</i></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -42,10 +47,21 @@
|
|||
<!-- ======================= APP ======================= -->
|
||||
<header id="topbar" class="hidden">
|
||||
<div class="tb-left">
|
||||
<span class="crumb" id="crumb">Florence · 12–15 Sep</span>
|
||||
<span class="crumb" id="crumb">…</span>
|
||||
<div class="scope-toggle" title="Day = working view · Trip = macro overview of all days">
|
||||
<button id="scope-day" class="sct on" data-sc="day">Day</button>
|
||||
<button id="scope-trip" class="sct" data-sc="trip">Trip</button>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<div class="verpill" id="verpill">
|
||||
<button id="ver-undo" class="vp-btn off" title="Undo (Ctrl+Z)">↩</button>
|
||||
<span id="verlabel" class="vp-label" title="Version history — recent edits, click to compare/revert">v0</span>
|
||||
<button id="ver-redo" class="vp-btn off" title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<div id="verlist" class="verlist hidden"></div>
|
||||
<span class="badge" id="offline-badge" title="On-trip offline bundle">📶 online</span>
|
||||
<span class="badge" id="nudge-badge" title="Plan nudges">✓ looking good</span>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -56,7 +72,7 @@
|
|||
<div class="rail-block" id="transit-block"></div>
|
||||
<div class="rail-block" id="sugg-block"></div>
|
||||
<div id="rail-body"></div>
|
||||
<div class="rail-foot">
|
||||
<div class="rail-foot" id="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>
|
||||
|
|
@ -83,7 +99,7 @@
|
|||
</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-hint">Drag the ● waypoint (or the endpoints) — the duration updates live; the local router re-routes on the road network when the area is covered.</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached).
|
||||
// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached)
|
||||
// + routing backend (proxy -> local OSRM on :5000, NE-US extract).
|
||||
// The UI only ever talks to localhost; swap the upstream for a real
|
||||
// tileserver-gl later without touching the frontend.
|
||||
// tileserver-gl / OSRM later without touching the frontend.
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
|
@ -8,6 +9,8 @@ const { Readable } = require('stream');
|
|||
|
||||
const PORT = 8077;
|
||||
const ROOT = __dirname;
|
||||
const OSRM = process.env.OSRM_URL || 'http://localhost:5000';
|
||||
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
|
||||
const CACHE = path.join(__dirname, '.tilecache');
|
||||
fs.mkdirSync(CACHE, { recursive: true });
|
||||
|
||||
|
|
@ -19,6 +22,41 @@ const mime = {
|
|||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
|
||||
// ---- routing backend (proxy -> local OSRM) ------------------------
|
||||
// /route?mode=foot&points=lng,lat;lng,lat[;...]
|
||||
// /table?mode=foot&points=lng,lat;lng,lat[;...]
|
||||
if (u.pathname === '/route' || u.pathname === '/table') {
|
||||
const kind = u.pathname.slice(1);
|
||||
const mode = u.searchParams.get('mode') || 'foot';
|
||||
const points = u.searchParams.get('points') || '';
|
||||
const profile = PROFILE[mode] || 'driving';
|
||||
let osrmPath = `/${kind}/v1/${profile}/${encodeURIComponent(points)}`;
|
||||
if (kind === 'route') osrmPath += '?overview=full&alternatives=false&steps=false';
|
||||
else {
|
||||
// table: durations from the first point (the anchor) to every point
|
||||
const n = points.split(';').filter(Boolean).length;
|
||||
const targets = Array.from({ length: n }, (_, i) => i).join(',');
|
||||
osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`;
|
||||
}
|
||||
const fetchUp = fetch(OSRM + osrmPath).then(r => r.text()).catch(() => null);
|
||||
fetchUp.then(body => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (body == null) { res.writeHead(502); return res.end(JSON.stringify({ code: 'ProxyError' })); }
|
||||
res.writeHead(200); res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// routing availability probe (so the UI can label router vs estimate)
|
||||
if (u.pathname === '/router-status') {
|
||||
const probe = '-71.06,42.35;-71.07,42.36'; // must span >0 distance for the health check
|
||||
fetch(OSRM + '/route/v1/walking/' + probe + '?overview=false').then(r => r.text())
|
||||
.then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {}
|
||||
res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: OSRM })); })
|
||||
.catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: OSRM })); });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- tile backend -------------------------------------------------
|
||||
if (u.pathname.startsWith('/tiles/')) {
|
||||
const parts = u.pathname.split('/');
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ button { font: inherit; }
|
|||
|
||||
/* ---------------- app grid ---------------- */
|
||||
#app { position: fixed; top: 46px; left: 0; right: 0; bottom: 0; z-index: 10; display: grid;
|
||||
grid-template-columns: 354px 1fr 350px; }
|
||||
grid-template-columns: 354px 1fr 350px;
|
||||
pointer-events: none; } /* map underneath must receive drags/clicks; children opt back in */
|
||||
#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; }
|
||||
|
|
@ -356,6 +357,65 @@ button { font: inherit; }
|
|||
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; }
|
||||
|
||||
/* topbar width relief: drop the verbose badges first */
|
||||
@media (max-width: 1560px) { #offline-badge { display: none; } }
|
||||
@media (max-width: 1320px) { #nudge-badge { display: none; } .crumb { display: none; } }
|
||||
|
||||
/* ---------------- landing demo chips ---------------- */
|
||||
.land-demo { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: var(--ink2); flex-wrap: wrap; }
|
||||
.land-demo .chip { font-size: 12px; padding: 6px 12px; }
|
||||
.land-demo .chip i { font-style: normal; font-size: 10px; opacity: .75; }
|
||||
|
||||
/* ---------------- scope toggle (Day | Trip) ---------------- */
|
||||
.scope-toggle { display: flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; margin-left: 12px; }
|
||||
.sct { border: 0; background: #fff; padding: 4px 12px; font-size: 12px; font-weight: 600; color: var(--ink2); cursor: pointer; }
|
||||
.sct + .sct { border-left: 1px solid var(--line); }
|
||||
.sct:hover { color: var(--accent); }
|
||||
.sct.on { background: #f3ede9; color: var(--accent); }
|
||||
|
||||
/* ---------------- version pill + history ---------------- */
|
||||
.verpill { display: flex; align-items: center; gap: 2px; background: #f2f4f8; border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 2px 4px; position: relative; }
|
||||
.vp-btn { border: 0; background: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer;
|
||||
font-size: 12px; color: var(--ink); }
|
||||
.vp-btn:hover { background: #e4e8ef; }
|
||||
.vp-btn.off { opacity: .3; cursor: default; }
|
||||
.vp-btn.off:hover { background: none; }
|
||||
.vp-label { font-size: 12px; font-weight: 700; color: var(--ink2); cursor: pointer; padding: 0 6px; font-variant-numeric: tabular-nums; }
|
||||
.vp-label:hover { color: var(--accent); }
|
||||
.verlist { position: absolute; top: 52px; right: 16px; z-index: 50; width: 340px; max-height: 60vh; overflow-y: auto;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 6px; }
|
||||
.ver-row { padding: 8px 10px; border-radius: 10px; cursor: pointer; font-size: 12px; line-height: 1.4; }
|
||||
.ver-row:hover { background: #f2f4f8; }
|
||||
.ver-row.cur { background: #f3ede9; }
|
||||
.vr-main { color: var(--ink); }
|
||||
.vr-cur { font-size: 10px; color: var(--accent); font-weight: 700; }
|
||||
.vr-diff { color: var(--ink2); font-size: 11px; margin-top: 3px; }
|
||||
|
||||
/* ---------------- L0 trip overview ---------------- */
|
||||
.trip-head { padding: 4px 2px 10px; }
|
||||
.trip-title { font-size: 16px; font-weight: 800; }
|
||||
.trip-sub { font-size: 11.5px; color: var(--ink2); margin-top: 3px; }
|
||||
.trip-day { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 8px 0; cursor: pointer;
|
||||
background: var(--card); transition: border-color .15s, box-shadow .15s; }
|
||||
.trip-day:hover { border-color: #c9cfda; box-shadow: 0 2px 8px rgba(20,24,35,.06); }
|
||||
.trip-day.cur { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(181,83,60,.12); }
|
||||
.td-top { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.td-label { font-weight: 700; font-size: 13px; }
|
||||
.td-end { font-size: 11px; color: var(--ink2); font-variant-numeric: tabular-nums; }
|
||||
.td-bar { height: 6px; background: #e7ebf2; border-radius: 99px; overflow: hidden; margin: 7px 0 6px; }
|
||||
.td-fill { height: 100%; background: var(--ok); border-radius: 99px; transition: width .5s ease; }
|
||||
.td-fill.warn { background: var(--warn); }
|
||||
.td-meta { display: flex; gap: 8px; align-items: center; font-size: 11.5px; color: var(--ink2); flex-wrap: wrap; }
|
||||
.td-names { font-size: 11px; color: var(--ink2); margin-top: 5px; opacity: .85; }
|
||||
|
||||
/* ---------------- offline bundle card ---------------- */
|
||||
.offline-card { border: 1px solid var(--line); border-radius: 12px; padding: 11px 13px; margin: 12px 0 6px; background: #f7f9fc; }
|
||||
.oc-title { font-weight: 700; font-size: 12.5px; }
|
||||
.oc-sub { font-size: 11.5px; color: var(--ink2); margin-top: 5px; line-height: 1.5; }
|
||||
.oc-progress { font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
.offline-card .btn { margin-top: 9px; }
|
||||
|
||||
/* ---------------- mobile: single pane + bottom tab bar ---------------- */
|
||||
#mobiletabs { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
|
|
@ -381,6 +441,9 @@ button { font: inherit; }
|
|||
.msg { font-size: 13px; padding: 9px 11px; }
|
||||
#chat-input-row { padding: 10px; }
|
||||
.tc { font-size: 11px; }
|
||||
.scope-toggle { margin-left: 8px; }
|
||||
.verpill { transform: scale(.92); }
|
||||
.verlist { width: min(320px, calc(100vw - 24px)); right: 8px; }
|
||||
/* map overlays */
|
||||
#l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; }
|
||||
#discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user