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 WALK_KMH = 4.6;
|
||||||
const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60)));
|
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 ----------------
|
// ---------------- state ----------------
|
||||||
const days = {};
|
const days = {};
|
||||||
M.days.forEach(d => days[d.id] = deep(d));
|
M.days.forEach(d => days[d.id] = deep(d));
|
||||||
|
|
@ -78,7 +152,8 @@ function renderHotelMks() {
|
||||||
let hotelMks = [], trainMks = [];
|
let hotelMks = [], trainMks = [];
|
||||||
|
|
||||||
function initMap() {
|
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.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||||
L.tileLayer('/tiles/{z}/{x}/{y}.png', {
|
L.tileLayer('/tiles/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19
|
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
|
if (d.isDeparture) return 0; // departure day: the train is the end of the day
|
||||||
const last = d.stops[d.stops.length - 1];
|
const last = d.stops[d.stops.length - 1];
|
||||||
if (!last) return 0;
|
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);
|
const list = staysForDay(d);
|
||||||
// worst case across every candidate stay for these nights (equals the real walk when one)
|
// 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)));
|
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) {
|
function rebuildLegs(d = day) {
|
||||||
d.legs = [];
|
d.legs = [];
|
||||||
|
delete d.retRouted;
|
||||||
for (let i = 0; i < d.stops.length - 1; i++) {
|
for (let i = 0; i < d.stops.length - 1; i++) {
|
||||||
const a = d.stops[i], b = d.stops[i + 1];
|
const a = d.stops[i], b = d.stops[i + 1];
|
||||||
// a vague (region) stop at either end → worst-case leg until refined
|
// a vague (region) stop at either end → worst-case leg until refined
|
||||||
if (a.kind === 'region' || b.kind === 'region') {
|
if (a.kind === 'region' || b.kind === 'region') {
|
||||||
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
|
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
|
||||||
} else if (b.id === 'S5') {
|
} else {
|
||||||
d.legs.push({ mode: 'multimodal', dur: 18, conf: 3, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] });
|
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 {
|
} else {
|
||||||
let h = 0; const key = a.name + '→' + b.name;
|
let h = 0; const key = a.name + '→' + b.name;
|
||||||
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997;
|
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 ----------------
|
// ---------------- vague / region placeholder stops ----------------
|
||||||
const REGION_MAP = {
|
const REGION_MAP = {
|
||||||
|
|
@ -182,14 +265,18 @@ function renderMap() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pts = obj.stops.map(s => s.at);
|
const pts = obj.stops.map(s => s.at);
|
||||||
for (let i = 0; i < pts.length - 1; i++)
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
legEls[i] = L.polyline([pts[i], pts[i + 1]], {
|
// 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',
|
color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b',
|
||||||
weight: 5, opacity: .85
|
weight: 5, opacity: .85
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
}
|
||||||
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
|
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
|
||||||
staysForDay(obj).forEach(o => {
|
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);
|
otherLayers.push(retLine);
|
||||||
retLines.push(retLine);
|
retLines.push(retLine);
|
||||||
});
|
});
|
||||||
|
|
@ -207,12 +294,15 @@ function renderMap() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const cur = days[curDay];
|
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 discOpen = !$('#discover').classList.contains('hidden');
|
||||||
const pad = matchMedia('(max-width: 860px)').matches
|
const pad = matchMedia('(max-width: 860px)').matches
|
||||||
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
|
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
|
||||||
: { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] };
|
: { 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) {
|
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);
|
if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
|
||||||
}
|
}
|
||||||
rebuildLegs();
|
rebuildLegs();
|
||||||
|
commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`);
|
||||||
renderAll(flash ? [stop.id] : []);
|
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 };
|
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,
|
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 });
|
checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price });
|
||||||
renderHotelMks(); renderBaseChip();
|
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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -460,7 +551,7 @@ function holdIdea(c, slot) {
|
||||||
stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place,
|
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 });
|
checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price });
|
||||||
renderHotelMks(); renderBaseChip();
|
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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +753,7 @@ function renderStopCard(body, s, i, flashIds) {
|
||||||
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
|
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
|
||||||
} else {
|
} else {
|
||||||
day.stops.push(makeStop(cand, slot, 'maybe'));
|
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);
|
ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
|
||||||
}
|
}
|
||||||
if (disc) closeDiscover();
|
if (disc) closeDiscover();
|
||||||
|
|
@ -673,6 +764,7 @@ function renderStopCard(body, s, i, flashIds) {
|
||||||
const [moved] = day.stops.splice(from, 1);
|
const [moved] = day.stops.splice(from, 1);
|
||||||
day.stops.splice(i, 0, moved);
|
day.stops.splice(i, 0, moved);
|
||||||
rebuildLegs();
|
rebuildLegs();
|
||||||
|
commit(`moved ${moved.name}`);
|
||||||
renderAll([moved.id]);
|
renderAll([moved.id]);
|
||||||
});
|
});
|
||||||
c.addEventListener('mouseenter', () => highlightStop(s.id, true));
|
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); }
|
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;
|
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')))
|
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);
|
ai(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -748,7 +840,7 @@ function moveStopToDay(s, targetId) {
|
||||||
const t = days[targetId];
|
const t = days[targetId];
|
||||||
t.stops.push(s);
|
t.stops.push(s);
|
||||||
rebuildLegs(); if (t !== day) rebuildLegs(t);
|
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);
|
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;
|
const dayLabel = () => M.days.find(d => d.id === day.id).label;
|
||||||
|
|
@ -771,26 +863,9 @@ function renderBudget() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- nudges (issues, but gentle) ----------------
|
// ---------------- nudges (issues, but gentle) ----------------
|
||||||
const nudgesByDay = {
|
// Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags.
|
||||||
D1: [
|
const nudgesByDay = deep(M.nudges || {});
|
||||||
{ id: 'N1', fixed: false,
|
Object.values(nudgesByDay).forEach(list => list.forEach(n => n.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() {} } }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
|
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
|
||||||
function renderNudges() {
|
function renderNudges() {
|
||||||
const p = $('#nudges'); if (!p) return; p.innerHTML = '';
|
const p = $('#nudges'); if (!p) return; p.innerHTML = '';
|
||||||
|
|
@ -802,7 +877,9 @@ function renderNudges() {
|
||||||
d.append(el('div', 'nb', n.body));
|
d.append(el('div', 'nb', n.body));
|
||||||
const b = el('button', 'btn ghost small', n.fix.label);
|
const b = el('button', 'btn ghost small', n.fix.label);
|
||||||
b.onclick = () => {
|
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();
|
renderAll();
|
||||||
if (n.fix.msg) ai(n.fix.msg, 300);
|
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 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 m1 = mk(a), m2 = mk(mid), m3 = mk(b);
|
||||||
const box = $('#l3-editor .l3-time');
|
const box = $('#l3-editor .l3-time');
|
||||||
|
let rt = 0; // debounce token for the router round-trip
|
||||||
const upd = () => {
|
const upd = () => {
|
||||||
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
|
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
|
||||||
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]);
|
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]);
|
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
|
leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate
|
||||||
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
|
leg.conf = 1;
|
||||||
|
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
|
||||||
renderBudget();
|
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));
|
[m1, m2, m3].forEach(m => m.on('drag', upd));
|
||||||
$('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`;
|
$('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`;
|
||||||
|
|
@ -961,6 +1048,7 @@ function openL3(legIdx) {
|
||||||
const obs = setInterval(() => {
|
const obs = setInterval(() => {
|
||||||
if (!$('#l3-editor').classList.contains('hidden')) return;
|
if (!$('#l3-editor').classList.contains('hidden')) return;
|
||||||
clearInterval(obs);
|
clearInterval(obs);
|
||||||
|
commit('edited a leg route');
|
||||||
renderAll();
|
renderAll();
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
@ -970,6 +1058,79 @@ function closeL3() {
|
||||||
}
|
}
|
||||||
$('#l3-close').onclick = 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 ----------------
|
// ---------------- day tabs ----------------
|
||||||
function buildDayTabs() {
|
function buildDayTabs() {
|
||||||
const tabs = $('#daytabs'); tabs.innerHTML = '';
|
const tabs = $('#daytabs'); tabs.innerHTML = '';
|
||||||
|
|
@ -1001,7 +1162,107 @@ function setDay(id) {
|
||||||
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
|
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
|
||||||
renderAll();
|
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 ----------------
|
// ---------------- chat ----------------
|
||||||
function msg(role, html) {
|
function msg(role, html) {
|
||||||
|
|
@ -1046,15 +1307,18 @@ function handleUser(v) {
|
||||||
}
|
}
|
||||||
if (s.includes('later') || s.includes('morning')) {
|
if (s.includes('later') || s.includes('morning')) {
|
||||||
day.startMin += 30;
|
day.startMin += 30;
|
||||||
|
commit('start day 30 min later');
|
||||||
renderAll(day.stops.map(x => x.id));
|
renderAll(day.stops.map(x => x.id));
|
||||||
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`);
|
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')) {
|
} else if (s.includes('lunch') || s.includes('longer')) {
|
||||||
const n = activeNudges()[0];
|
const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
|
||||||
const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned');
|
const st = n ? stopById(n.fix.stop) : null;
|
||||||
if (n && !n.fixed && lunch) {
|
if (n && !n.fixed && st) {
|
||||||
n.fixed = true; lunch.dur = 90;
|
n.fixed = true; st.dur = n.fix.dur;
|
||||||
renderAll([lunch.id]);
|
commit(`nudge: ${n.fix.label}`);
|
||||||
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`);
|
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(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`);
|
||||||
} else {
|
} 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);
|
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());
|
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
|
||||||
|
|
||||||
// ---------------- landing → parse → plan ----------------
|
// ---------------- 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'],
|
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
|
||||||
['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'],
|
['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'],
|
||||||
['dates', '12 – 15 Sep 2026', '3 nights', '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'],
|
['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() {
|
function showParse() {
|
||||||
const t = $('#parse-table'); t.innerHTML = '';
|
const t = $('#parse-table'); t.innerHTML = '';
|
||||||
SAMPLE.forEach(([k, v, note, c]) => {
|
sampleRows().forEach(([k, v, note, c]) => {
|
||||||
const tr = document.createElement('tr');
|
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>`;
|
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);
|
t.append(tr);
|
||||||
|
|
@ -1092,10 +1363,12 @@ function startApp() {
|
||||||
$('#landing').classList.add('hidden');
|
$('#landing').classList.add('hidden');
|
||||||
$('#topbar').classList.remove('hidden');
|
$('#topbar').classList.remove('hidden');
|
||||||
$('#app').classList.remove('hidden');
|
$('#app').classList.remove('hidden');
|
||||||
|
$('#crumb').textContent = M.trip.title;
|
||||||
renderBaseChip();
|
renderBaseChip();
|
||||||
buildDayTabs();
|
buildDayTabs();
|
||||||
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
|
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
|
||||||
renderAll();
|
renderAll();
|
||||||
|
commit('initial plan');
|
||||||
script1();
|
script1();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1171,6 +1444,7 @@ function addStopToDay(id, requested) {
|
||||||
copy.state = state;
|
copy.state = state;
|
||||||
day.stops.push(copy);
|
day.stops.push(copy);
|
||||||
rebuildLegs();
|
rebuildLegs();
|
||||||
|
commit(`${copy.name} → ${stateLabel(state)}`);
|
||||||
renderAll([id]);
|
renderAll([id]);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -1178,22 +1452,22 @@ function addStopToDay(id, requested) {
|
||||||
function script1() {
|
function script1() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scriptBusy = true;
|
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(() => {
|
setTimeout(() => {
|
||||||
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
|
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
|
||||||
const chips = el('div', 'chips');
|
const chips = el('div', 'chips');
|
||||||
M.vibes.forEach(v => {
|
M.vibes.forEach(v => {
|
||||||
const c = el('button', 'chip', v.label);
|
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);
|
chips.append(c);
|
||||||
});
|
});
|
||||||
m.append(chips);
|
m.append(chips);
|
||||||
}, 1800);
|
}, 1800);
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
function script2() {
|
function script2(vibe) {
|
||||||
scriptBusy = true;
|
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(() => {
|
setTimeout(() => {
|
||||||
const m = msg('ai', '');
|
const m = msg('ai', '');
|
||||||
const cards = el('div', 'cards');
|
const cards = el('div', 'cards');
|
||||||
|
|
@ -1270,15 +1544,10 @@ function script2() {
|
||||||
}
|
}
|
||||||
function script3() {
|
function script3() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 };
|
rebuildLegs(day);
|
||||||
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();
|
|
||||||
renderAll(day.stops.map(s => s.id));
|
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);
|
}, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1288,13 +1557,16 @@ const dz = $('#dropzone');
|
||||||
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('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.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); });
|
||||||
$('#dz-sample').onclick = showParse;
|
$('#dz-sample').onclick = showParse;
|
||||||
$('#dest-go').onclick = () => {
|
$('#dest-go').onclick = () => { showParse(); };
|
||||||
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());
|
$('#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
|
// drop a candidate onto the plan panel itself → hold it as an idea
|
||||||
(function () {
|
(function () {
|
||||||
|
|
@ -1308,10 +1580,15 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
const { c, slot } = found; dragCand = null;
|
const { c, slot } = found; dragCand = null;
|
||||||
day.stops.push(makeStop(c, slot, 'maybe'));
|
day.stops.push(makeStop(c, slot, 'maybe'));
|
||||||
rebuildLegs(); renderAll([c.id]);
|
rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]);
|
||||||
if (disc) closeDiscover();
|
if (disc) closeDiscover();
|
||||||
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
|
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
initMap();
|
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.
|
// 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
|
// Only one `planned` stop per slot per day; `alt`/`maybe` don't count against
|
||||||
// the day's time budget.
|
// 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: {
|
trip: {
|
||||||
id: 'trip-2026-09',
|
id: 'trip-2026-09',
|
||||||
version: 1,
|
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: [
|
vibes: [
|
||||||
{ id: 'v1', label: 'art & food · relaxed pace' },
|
{ id: 'v1', label: 'art & food · relaxed pace' },
|
||||||
{ id: 'v2', label: 'gardens & walking' },
|
{ id: 'v2', label: 'gardens & walking' },
|
||||||
|
|
@ -262,3 +286,240 @@ window.MOCK = {
|
||||||
enrich: 'lunch from 12:30 · book ahead in season' }
|
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>
|
<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>
|
<button id="dz-sample" class="btn ghost small">or start from an example booking</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -42,10 +47,21 @@
|
||||||
<!-- ======================= APP ======================= -->
|
<!-- ======================= APP ======================= -->
|
||||||
<header id="topbar" class="hidden">
|
<header id="topbar" class="hidden">
|
||||||
<div class="tb-left">
|
<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 class="daytabs" id="daytabs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tb-right">
|
<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>
|
<span class="badge" id="nudge-badge" title="Plan nudges">✓ looking good</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -56,7 +72,7 @@
|
||||||
<div class="rail-block" id="transit-block"></div>
|
<div class="rail-block" id="transit-block"></div>
|
||||||
<div class="rail-block" id="sugg-block"></div>
|
<div class="rail-block" id="sugg-block"></div>
|
||||||
<div id="rail-body"></div>
|
<div id="rail-body"></div>
|
||||||
<div class="rail-foot">
|
<div class="rail-foot" id="rail-foot">
|
||||||
<div class="budget">
|
<div class="budget">
|
||||||
<div class="budget-label"><span id="budget-text">…</span></div>
|
<div class="budget-label"><span id="budget-text">…</span></div>
|
||||||
<div class="budget-bar"><div id="budget-fill"></div></div>
|
<div class="budget-bar"><div id="budget-fill"></div></div>
|
||||||
|
|
@ -83,7 +99,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="l3-editor" class="hidden">
|
<div id="l3-editor" class="hidden">
|
||||||
<div class="l3-title" id="l3-title">Route</div>
|
<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>
|
<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>
|
<button class="btn ghost small" id="l3-close">Done</button>
|
||||||
</div>
|
</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
|
// 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 http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -8,6 +9,8 @@ const { Readable } = require('stream');
|
||||||
|
|
||||||
const PORT = 8077;
|
const PORT = 8077;
|
||||||
const ROOT = __dirname;
|
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');
|
const CACHE = path.join(__dirname, '.tilecache');
|
||||||
fs.mkdirSync(CACHE, { recursive: true });
|
fs.mkdirSync(CACHE, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -19,6 +22,41 @@ const mime = {
|
||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
const u = new URL(req.url, 'http://x');
|
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 -------------------------------------------------
|
// ---- tile backend -------------------------------------------------
|
||||||
if (u.pathname.startsWith('/tiles/')) {
|
if (u.pathname.startsWith('/tiles/')) {
|
||||||
const parts = u.pathname.split('/');
|
const parts = u.pathname.split('/');
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ button { font: inherit; }
|
||||||
|
|
||||||
/* ---------------- app grid ---------------- */
|
/* ---------------- app grid ---------------- */
|
||||||
#app { position: fixed; top: 46px; left: 0; right: 0; bottom: 0; z-index: 10; display: 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, #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 { 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, #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; }
|
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; }
|
.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 ---------------- */
|
/* ---------------- mobile: single pane + bottom tab bar ---------------- */
|
||||||
#mobiletabs { display: none; }
|
#mobiletabs { display: none; }
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
|
|
@ -381,6 +441,9 @@ button { font: inherit; }
|
||||||
.msg { font-size: 13px; padding: 9px 11px; }
|
.msg { font-size: 13px; padding: 9px 11px; }
|
||||||
#chat-input-row { padding: 10px; }
|
#chat-input-row { padding: 10px; }
|
||||||
.tc { font-size: 11px; }
|
.tc { font-size: 11px; }
|
||||||
|
.scope-toggle { margin-left: 8px; }
|
||||||
|
.verpill { transform: scale(.92); }
|
||||||
|
.verlist { width: min(320px, calc(100vw - 24px)); right: 8px; }
|
||||||
/* map overlays */
|
/* map overlays */
|
||||||
#l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; }
|
#l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; }
|
||||||
#discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; }
|
#discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user