/* Interaction demo. All data is fake (data.js). */
const M = window.MOCK;
const $ = s => document.querySelector(s);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0');
const deep = o => JSON.parse(JSON.stringify(o));
// ---------------- state ----------------
const days = {};
M.days.forEach(d => days[d.id] = deep(d));
let curDay = 'D1';
let day = days[curDay];
const stopById = id => day.stops.find(s => s.id === id);
const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id);
let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [];
let map, l3 = null, scriptBusy = false;
// ---------------- mobile: single pane + bottom tabs ----------------
(function () {
const tabs = $('#mobiletabs'); if (!tabs) return;
const panes = { plan: $('#rail'), map: $('#mapwrap'), chat: $('#chat') };
const setTab = t => {
Object.entries(panes).forEach(([k, e]) => e.classList.toggle('pane-hidden', k !== t));
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.t === t));
if (t === 'map') setTimeout(() => map.invalidateSize(), 80);
};
tabs.querySelectorAll('button').forEach(b => b.onclick = () => setTab(b.dataset.t));
if (window.matchMedia('(max-width: 860px)').matches) setTab('plan');
})();
// ---------------- map ----------------
let hotelMks = [], trainMks = [];
// stays, not a single hotel: each stay anchors the dates it covers.
// >1 candidate stay covering the same dates → worst-case anchoring for those dates.
// Multi-city trips = more stays, each anchoring its own dates (per-night granularity).
let stays = deep(M.trip.stays);
let staySeq = 1;
const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; // checkout day included
const staysForDay = (d = day) => stays.filter(s => covers(s, d.date));
const dayBase = (d = day) => {
const list = staysForDay(d);
return list.find(s => s.state === 'booked') || list[0] || stays[0];
};
const hotelWalkMin = (at, d = day) => {
const list = staysForDay(d);
return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1;
};
const stayRange = s => `${s.checkIn.slice(8)}–${s.checkOut.slice(8)} Sep`;
function renderHotelMks() {
hotelMks.forEach(m => map.removeLayer(m)); hotelMks = [];
stays.forEach(o => {
const booked = o.state === 'booked';
hotelMks.push(L.marker(o.at, {
icon: L.divIcon({ className: 'hotel-ico', html: `
🏨
`, iconSize: [0, 0] })
}).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'option (worst-case anchor)'}`));
});
}
function initMap() {
map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 14);
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.tileLayer('/tiles/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19
}).addTo(map);
renderHotelMks();
// booked trains: origin + destination markers only, no route line
M.trip.bookings.filter(b => b.type === 'train').forEach(b => {
const mk = (pt, label) => L.marker(pt, {
icon: L.divIcon({ className: 'hotel-ico', html: '🚄
', iconSize: [0, 0] })
}).addTo(map).bindTooltip(label);
trainMks.push(mk(b.stations.from, `${b.name} · dep ${b.depart} · ${b.from}`));
trainMks.push(mk(b.stations.to, `${b.name} · arr ${b.arrive} · ${b.to}`));
});
}
function flashMarker(mk, on) {
const e = mk.getElement()?.querySelector('.hotel-pin');
if (e) e.style.transform = on ? 'translate(-50%,-90%) scale(1.35)' : 'translate(-50%,-90%)';
if (on) mk.openTooltip(); else mk.closeTooltip();
if (on && !map.getBounds().contains(mk.getLatLng())) map.flyTo(mk.getLatLng(), 15, { duration: .5 });
}
const CONF_CLS = { 5: 't-user', 4: 't-sched', 3: 't-computed', 2: 't-search', 1: 't-llm' };
function tchip(mins, conf, src) {
const e = el('span', 'tc ' + CONF_CLS[conf], fmt(mins) + ' min');
if (src) e.title = 'source: ' + src;
return e;
}
const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌' };
// ---------------- layout / legs ----------------
// every day ends by returning to the hotel anchor
function returnDur(d = day) {
if (d.isDeparture) return 0; // departure day: the train is the end of the day
const last = d.stops[d.stops.length - 1];
if (!last) return 0;
const list = staysForDay(d);
// worst case across every candidate stay for these nights (equals the real walk when one)
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60)));
}
function layout(d = day) {
let t = d.startMin;
d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; });
return t + returnDur(d);
}
function rebuildLegs(d = day) {
d.legs = [];
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined
if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
} else if (b.id === 'S5') {
d.legs.push({ mode: 'multimodal', dur: 18, conf: 3, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] });
} else {
let h = 0; const key = a.name + '→' + b.name;
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997;
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 3 });
}
}
}
// ---------------- vague / region placeholder stops ----------------
const REGION_MAP = {
oltrarno: { label: 'Oltrarno', at: [43.7606, 11.2485], r: 520 },
smn: { label: 'near SMN', at: [43.7731, 11.2563], r: 620 },
duomo: { label: 'around the Duomo', at: [43.7731, 11.2556], r: 620 },
};
let nextStopId = 30;
function addRegionStop(slot, region) {
const existing = day.stops.find(x => x.slot === slot && x.state === 'planned');
const stop = { id: 'S' + (nextStopId++),
name: `${slot[0].toUpperCase() + slot.slice(1)} — somewhere ${region.label}`,
kind: 'region', mealKind: slot, slot, state: 'planned',
at: region.at, region: { ...region },
dur: slot === 'breakfast' ? 30 : 90, durConf: 1,
suggested: { value: slot === 'breakfast' ? 30 : 90, conf: 1, src: null } };
if (existing) { existing.state = 'alt'; day.stops[day.stops.indexOf(existing)] = stop; }
else day.stops.push(stop);
rebuildLegs(); renderRail([stop.id]); renderMap();
ai(`Placed a placeholder for ${slot} ${region.label} — it holds the slot and reserves time; travel times are worst-case until you pick a specific place. Say “find a ${slot} ${region.label}” or tap the card to refine.`, 900);
}
function renderMap() {
Object.values(markers).forEach(m => map.removeLayer(m)); markers = {};
Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {};
otherLayers.forEach(l => map.removeLayer(l)); otherLayers = [];
retLine = null; retLines = [];
// every day, faintly — the whole trip is always visible
M.days.forEach(d => {
const obj = days[d.id];
if (!obj.stops.length) return;
if (d.id !== curDay) {
const pts = obj.stops.map(s => s.at);
for (let i = 0; i < pts.length - 1; i++)
otherLayers.push(L.polyline([pts[i], pts[i + 1]], { color: '#b6bcc8', weight: 3, opacity: .8, dashArray: '4 7' }).addTo(map));
if (pts.length && !obj.isDeparture)
staysForDay(obj).forEach(o =>
otherLayers.push(L.polyline([pts[pts.length - 1], o.at], { color: '#c8cdd6', weight: 2.5, opacity: .8, dashArray: '2 6' }).addTo(map)));
obj.stops.forEach(s => otherLayers.push(L.circleMarker(s.at, { radius: 5, color: '#9aa3b2', weight: 2, fillColor: '#fff', fillOpacity: 1 })
.addTo(map).bindTooltip(`${d.label} · ${s.name}`)));
return;
}
const pts = obj.stops.map(s => s.at);
for (let i = 0; i < pts.length - 1; i++)
legEls[i] = L.polyline([pts[i], pts[i + 1]], {
color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b',
weight: 5, opacity: .85
}).addTo(map);
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
staysForDay(obj).forEach(o => {
retLine = L.polyline([pts[pts.length - 1], o.at], { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map);
otherLayers.push(retLine);
retLines.push(retLine);
});
}
obj.stops.forEach((s, i) => {
const dim = s.state !== 'planned';
markers[s.id] = L.marker(s.at, {
icon: L.divIcon({ className: '', html: dim
? `${i + 1}
`
: `${i + 1}
`,
iconSize: [26, 26], iconAnchor: [13, 13] })
}).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + s.state : ''}`);
if (s.region) // vague stop: show the search area, not a point
otherLayers.push(L.circle(s.region.at, { radius: s.region.r, color: '#b5533c', weight: 2, dashArray: '6 6', fillColor: '#b5533c', fillOpacity: .08 }).addTo(map));
});
});
const cur = days[curDay];
if (cur.stops.length) {
const pad = matchMedia('(max-width: 860px)').matches
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
: { paddingTopLeft: [370, 70], paddingBottomRight: [410, 70] };
map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...cur.stops.map(s => s.at)]), pad);
}
}
function highlightStop(id, on) {
const m = markers[id]; if (!m) return;
m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : '');
if (on) m.openTooltip(); else m.closeTooltip();
}
// pulse in place; only reposition the map if the point is out of view
function focusPoint(latlng, zoom = 16) {
if (!map.getBounds().contains(latlng)) map.flyTo(latlng, Math.max(map.getZoom(), zoom), { duration: .5 });
const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map);
setTimeout(() => map.removeLayer(ring), 1500);
}
const flyTo = s => focusPoint(s.at);
function tempShow(s) {
if (stopById(s.id)) return;
const m = L.circleMarker(s.at, { radius: 8, color: '#5b6472', weight: 2, fillColor: '#8b93a3', fillOpacity: .6 })
.addTo(map).bindTooltip(s.name);
tempMarkers.push(m);
m.openTooltip();
if (!map.getBounds().contains(s.at)) map.flyTo(s.at, Math.max(map.getZoom(), 15), { duration: .4 });
}
function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; }
// ---------------- rail ----------------
function renderRail(flashIds = []) {
layout(); // assign start times before the cards render them
const body = $('#rail-body'); body.innerHTML = '';
day.stops.forEach((s, i) => {
if (s.state === 'alt') body.append(el('div', 'or-row', 'or'));
const c = el('div', 'stop-card st-' + s.state);
if (flashIds.includes(s.id)) c.classList.add('flash');
const top = el('div', 'sc-top');
top.append(el('span', 'num', String(i + 1)));
if (s.kind === 'transit') {
// booked external transport: fixed, shown only on its own day
top.append(el('span', 'sc-name', `${s.transit.name} → ${s.transit.to}`));
top.append(el('span', 'sc-times', `${s.transit.arriveBy}–${s.transit.depart}`));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', 'booked'));
meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`));
c.append(meta);
c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy} — ${s.transit.buffer}`));
c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true));
c.addEventListener('mouseleave', () => flashMarker(trainMks[0], false));
c.addEventListener('click', () => focusPoint(s.at));
body.append(c);
return;
}
// drag & drop reordering within the day
c.draggable = true;
c.addEventListener('dragstart', e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); c.classList.add('dragging'); });
c.addEventListener('dragend', () => c.classList.remove('dragging'));
c.addEventListener('dragover', e => e.preventDefault());
c.addEventListener('drop', e => {
e.preventDefault();
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isNaN(from) || from === i) return;
const [moved] = day.stops.splice(from, 1);
day.stops.splice(i, 0, moved);
rebuildLegs();
renderRail([moved.id]); renderMap();
});
top.append(el('span', 'grip', '⠿'));
top.append(el('span', 'sc-name', s.name));
top.append(el('span', 'sc-times', `${fmt(s.start)}–${fmt(s.start + s.dur)}`));
c.append(top);
const meta = el('div', 'sc-meta');
const stateChip = el('span', 'tc t-' + s.state + ' sc-state', s.state);
stateChip.title = 'Change state';
stateChip.onclick = e => { e.stopPropagation(); openStateMenu(s, stateChip); };
meta.append(stateChip);
meta.append(tchip(s.dur, s.durConf));
if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned')
meta.append(el('span', 'sug-note', `sug. ~${fmt(s.suggested.value)}`));
if (s.price) meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`));
c.append(meta);
if (s.region) {
c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined'));
const rf = el('button', 'refine-btn', 'Find a specific place →');
rf.onclick = e => { e.stopPropagation(); enterFocus(s.slot, s.region); };
c.append(rf);
}
c.addEventListener('mouseenter', () => highlightStop(s.id, true));
c.addEventListener('mouseleave', () => highlightStop(s.id, false));
c.addEventListener('click', e => { if (!e.target.closest('.sc-state, .pop')) flyTo(s); });
body.append(c);
if (day.legs[i]) {
const g = day.legs[i];
const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : ''));
lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•'));
if (g.sub) lr.append(el('span', 'leg-sub', g.sub.map(x => `${MODE_ICO[x.mode]} ${x.dur}′`).join(' + ')));
if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined'));
lr.append(document.createTextNode(' '));
lr.append(tchip(g.dur, g.conf));
const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', weight: 5, opacity: .85 };
lr.addEventListener('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 }));
lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base));
lr.onclick = () => openL3(i);
body.append(lr);
}
});
if (day.stops.length && !day.isDeparture) {
const multi = staysForDay().length > 1;
const ret = el('div', 'leg-row', `🏨back to hotel `);
ret.append(tchip(returnDur(), multi ? 1 : 3));
if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null };
const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' };
ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on)));
ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
body.append(ret);
}
renderBudget(); renderIssues();
}
// state menu (planned ⇄ maybe ⇄ alt, move day, remove)
function openStateMenu(s, anchor) {
closeMenus();
const menu = el('div', 'pop');
const mk = (label, fn, danger) => { const b = el('button', danger ? 'danger' : '', label); b.onclick = e => { e.stopPropagation(); closeMenus(); fn(); }; menu.append(b); };
if (s.state === 'planned') {
mk('→ Maybe', () => setState(s, 'maybe'));
mk('→ Alternative', () => setState(s, 'alt'));
} else if (s.state === 'maybe') {
mk('→ Planned', () => setState(s, 'planned'));
mk('→ Alternative', () => setState(s, 'alt'));
} else {
mk('→ Planned (swap in)', () => setState(s, 'planned'));
mk('→ Maybe', () => setState(s, 'maybe'));
}
M.days.filter(d => d.id !== day.id).forEach(d => {
mk(`Move to ${d.label}`, () => moveStopToDay(s, d.id));
});
mk('Remove from day', () => {
const slot = s.slot;
day.stops.splice(day.stops.indexOf(s), 1);
let promoted = null;
if (!day.stops.some(x => x.slot === slot && x.state === 'planned')) {
promoted = day.stops.find(x => x.slot === slot && x.state === 'alt');
if (promoted) promoted.state = 'planned'; // last candidate in the slot becomes the plan
}
rebuildLegs(); renderRail(); renderMap();
ai(`Removed ${s.name} from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so ${promoted.name} is now your ${slot}.` : '.'}`, 500);
}, true);
anchor.parentElement.parentElement.append(menu); // card is position:relative
}
function closeMenus() { document.querySelectorAll('.pop').forEach(p => p.remove()); }
document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMenus(); });
function setState(s, state) {
if (state === 'planned' && s.state !== 'planned') {
const cur = day.stops.find(x => x.slot === s.slot && x.state === 'planned' && x !== s);
if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} is the alternative.`, 600); }
}
s.state = state;
rebuildLegs(); renderRail([s.id]); renderMap();
if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt')))
ai(`${s.name} is now “${state}”.`, 400);
}
function moveStopToDay(s, targetId) {
day.stops.splice(day.stops.indexOf(s), 1);
const t = days[targetId];
t.stops.push(s);
rebuildLegs(); if (t !== day) rebuildLegs(t);
renderRail(); renderMap();
ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500);
}
function dayLabel() { return M.days.find(d => d.id === day.id).label; }
function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length;
const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0)
+ (prim.length ? returnDur() : 0);
const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0);
const end = layout();
const cap = day.wakingHours * 60;
const pct = Math.min(100, planned / cap * 100);
const fill = $('#budget-fill');
fill.style.width = pct + '%';
fill.classList.toggle('warn', pct > 92);
$('#budget-text').textContent = `${(planned / 60).toFixed(1)} h planned${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h flex' : ''} · ${day.wakingHours} h waking (ends ${fmt(end)})`;
}
// ---------------- issues ----------------
const issuesByDay = {
D1: [
{ id: 'I1', kind: 'warn', fixed: false,
title: 'Lunch may be too short',
body: '45 min at Trattoria Mario — a meal there typically runs ~90 min (web), plus the local queue.',
fix: 'Extend to ~90 min' },
{ id: 'I2', kind: 'info', fixed: false,
title: 'Pitti: 1 h (you) vs ~2.5 h suggested',
body: 'Guides suggest 2–3 h for the full complex. Kept at your 1 h.',
fix: 'Keep as is' }
],
D2: [], D3: [
{ id: 'I3', kind: 'info', fixed: false,
title: 'Dinner is “maybe”',
body: 'Il Latte is still an option, not a plan — decide by this evening so there’s a fallback if it’s full.',
fix: 'Keep as is' }
]
};
let showResolved = false;
function renderIssues() {
const list = issuesByDay[day.id] || [];
const p = $('#issues-panel'); p.innerHTML = '';
const active = list.filter(i => !i.fixed);
const resolved = list.filter(i => i.fixed);
$('#issue-badge').classList.toggle('ok', !active.length);
$('#issue-badge').innerHTML = active.length ? `⚠ ${active.length} issue${active.length > 1 ? 's' : ''}` : '✓ plan checks out';
[...(showResolved ? list : active)].forEach(it => {
const d = el('div', 'issue' + (it.fixed ? ' fixed' : ''));
d.append(el('div', 'it', it.title));
d.append(el('div', null, it.body));
if (!it.fixed) {
const row = el('div', 'fix');
const b = el('button', 'btn ghost small', it.fix);
b.onclick = () => {
it.fixed = true;
if (it.id === 'I1' && stopById('S4')) { stopById('S4').dur = 90; renderRail(['S4']); renderMap(); ai('Lunch extended to 90 min — everything after it slides later; the day still fits.', 300); }
renderIssues();
};
row.append(b); d.append(row);
} else d.append(el('div', 'fix', '✓ resolved'));
p.append(d);
});
if (resolved.length) {
const t = el('button', 'resolved-toggle', showResolved ? 'Hide resolved' : `Show resolved (${resolved.length})`);
t.onclick = () => { showResolved = !showResolved; renderIssues(); };
p.append(t);
}
}
// ---------------- L3 route editor ----------------
function haversine(a, b) {
const R = 6371e3, toR = d => d * Math.PI / 180;
const dLat = toR(b[0] - a[0]), dLon = toR(b[1] - a[1]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toR(a[0])) * Math.cos(toR(b[0])) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function openL3(legIdx) {
closeL3();
const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at;
const leg = day.legs[legIdx];
const mid = [(a[0] + b[0]) / 2 + 0.0012, (a[1] + b[1]) / 2 - 0.0018];
const layer = L.layerGroup().addTo(map);
const line = L.polyline([a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).addTo(layer);
const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer);
const m1 = mk(a), m2 = mk(mid), m3 = mk(b);
const box = $('#l3-editor .l3-time');
const upd = () => {
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]);
const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]);
leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // 4.8 km/h walking
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
renderBudget();
};
[m1, m2, m3].forEach(m => m.on('drag', upd));
$('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`;
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
$('#l3-editor').classList.remove('hidden');
map.fitBounds(L.latLngBounds([a, b]).pad(0.4));
l3 = { layer };
const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs);
renderRail(); renderMap();
}, 250);
}
function closeL3() {
if (l3) { map.removeLayer(l3.layer); l3 = null; }
$('#l3-editor').classList.add('hidden');
}
$('#l3-close').onclick = closeL3;
// ---------------- focus mode (slot search, Google-Maps-style) ----------------
const excluded = new Set(); // the exclusion list — survives across searches
let focus = null; // {slot, results}
let candMks = {};
function isExcluded(c) { return [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase())); }
function enterFocus(slot, region) {
if (focus) exitFocus();
let pool = (MOCK.candidates[slot] || []).filter(c => !isExcluded(c));
if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r);
const gone = (MOCK.candidates[slot] || []).length - pool.length;
focus = { slot, region: region || null, results: pool };
$('#rail').classList.add('dimmed');
$('#focusbar').classList.remove('hidden');
renderFocus();
ai(`Here are ${pool.length} ${slot === 'visit' ? 'afternoon ideas' : slot + ' options'}${region ? ` in ${region.label}` : ''} on the map${gone ? ` — ${gone} outside the area or excluded` : ''}. Click a pin to open it; keep talking to filter: “cheaper”, “views”, “no <name>”.`, 700);
}
function exitFocus(note) {
if (!focus) return;
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
focus = null;
$('#rail').classList.remove('dimmed');
$('#focusbar').classList.add('hidden');
if (note) ai(note, 400);
}
const WALK_KMH = 4.6;
function walkMin(from, to) { return Math.max(1, Math.round(haversine(from, to) / 1000 / (WALK_KMH / 60))); }
// where the user is when they make this choice: the stop right before the slot, else the last planned stop, else the hotel
function focusAnchor() {
const idx = day.stops.findIndex(x => x.slot === focus.slot);
if (idx > 0) return day.stops[idx - 1];
if (idx < 0) {
const planned = day.stops.filter(x => x.state === 'planned');
if (planned.length) return planned[planned.length - 1];
}
return { name: dayBase().name, at: dayBase().at };
}
function renderFocus() {
const label = focus.slot === 'visit' ? 'afternoon' : focus.slot;
$('#focusbar-title').innerHTML = `🔍 ${label}${focus.region ? ' · ' + focus.region.label : ''} · ${dayLabel()} — ${focus.results.length} option${focus.results.length !== 1 ? 's' : ''}${excluded.size ? ` · ${excluded.size} excluded` : ''}`;
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
const anchor = focusAnchor();
const fromHotel = anchor.name !== dayBase().name;
const isHotel = focus.slot === 'hotel';
focus.results.forEach(c => {
const dist = `↦ ${walkMin(anchor.at, c.at)} min walk from ${anchor.name}`
+ (fromHotel ? `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}` : '');
const html = `
${c.name}
${c.pitch}
${dist}
${c.dur ? `~${fmt(c.dur)}` : ''}${c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'}
`;
const mk = L.marker(c.at, {
icon: L.divIcon({ className: '', html: ``,
iconSize: [26, 36], iconAnchor: [13, 34] })
}).addTo(map);
mk.bindPopup(html, { maxWidth: 280, closeButton: true });
mk.on('popupopen', e => {
const p = e.popup.getElement();
p.querySelector('.fp-choose').onclick = () => chooseCand(c);
p.querySelector('.fp-maybe').onclick = () => maybeCand(c);
});
candMks[c.id] = mk;
});
}
function makeStop(c, state) {
return { id: c.id, name: c.name, kind: focus.slot === 'visit' ? 'visit' : 'meal',
mealKind: focus.slot === 'visit' ? undefined : focus.slot, slot: focus.slot, state,
at: c.at, dur: c.dur, durConf: 3,
suggested: { value: c.dur, conf: 2, src: 'tripadvisor.com' },
price: c.price ? { amount: c.price, cur: '€', conf: 2, src: 'tripadvisor.com' } : null,
img: c.img, url: c.url };
}
function chooseCand(c) {
if (focus.slot === 'hotel') {
const cur = dayBase();
const inRange = s => s.checkIn <= cur.checkOut && s.checkOut >= cur.checkIn; // same nights
stays = stays.filter(s => !inRange(s));
stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place,
checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price });
renderHotelMks(); renderBaseChip();
exitFocus(); renderRail(); renderMap();
ai(`Now staying at ${c.name} for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700);
return;
}
const slot = focus.slot;
const loose = day.stops.find(x => x.slot === slot && x.kind === 'region');
const existing = loose || day.stops.find(x => x.slot === slot && x.state === 'planned');
const stop = makeStop(c, 'planned');
if (existing) {
if (existing.kind !== 'region') existing.state = 'alt'; // a placeholder is replaced, not demoted
day.stops[day.stops.indexOf(existing)] = stop;
} else day.stops.push(stop);
rebuildLegs();
exitFocus();
renderRail([stop.id]); renderMap();
ai(loose
? `Refined: ${c.name} is your ${slot} now — the placeholder is gone and the leg times are real.`
: `Locked in ${c.name} for ${dayLabel()}${existing ? ` — ${existing.name} drops to alternative: one ${slot}, one plan.` : '. See it in the day.'}`, 600);
}
function maybeCand(c) {
if (focus.slot === 'hotel') {
if (stays.some(o => o.name === c.name)) { exitFocus(); return; }
const cur = dayBase();
stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place,
checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price });
renderHotelMks(); renderBaseChip();
exitFocus(); renderRail(); renderMap();
ai(`Keeping ${c.name} open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800);
return;
}
const loose = day.stops.find(x => x.slot === focus.slot && x.kind === 'region');
if (loose) {
const stop = makeStop(c, 'maybe');
day.stops[day.stops.indexOf(loose)] = stop;
rebuildLegs(); exitFocus(); renderRail([stop.id]); renderMap();
ai(`Holding ${c.name} as a maybe — the placeholder became a specific option, off the time budget.`, 600);
return;
}
if (day.stops.some(x => x.id === c.id)) { exitFocus(); return; }
day.stops.push(makeStop(c, 'maybe'));
rebuildLegs();
exitFocus();
renderRail([c.id]); renderMap();
ai(`Holding ${c.name} as an option — dashed, off the time budget.`, 500);
}
function focusFilter(text) {
const t = text.toLowerCase();
let res = [...focus.results];
const notes = [];
if (/cheaper|cheap|less|budget|under/.test(t)) {
const m = t.match(/under\s*€?(\d+)/);
const cap = m ? +m[1] : (focus.slot === 'hotel' ? 220 : 25); // “cheaper” means different things per slot
res = res.filter(c => c.price <= cap);
notes.push(`≤ €${cap}`);
}
if (/boboli/.test(t)) { res = res.filter(c => haversine(c.at, [43.7579, 11.2463]) < 1200); notes.push('near Boboli'); }
if (/pitti|accademia/.test(t)) { res = res.filter(c => haversine(c.at, [43.7639, 11.2518]) < 900); notes.push('near the sights you have planned'); }
if (/views|terrace|rooftop/.test(t)) { res = res.filter(c => c.tags.some(x => /view|terrace/.test(x))); notes.push('views'); }
if (/veg/.test(t)) { res = res.filter(c => c.tags.some(x => /veg|garden/.test(x))); notes.push('vegetarian-friendly'); }
if (/quiet|calm|cozy|cellar/.test(t)) { res = res.filter(c => c.tags.some(x => /quiet|calm|cozy|cellar/.test(x))); notes.push('quieter'); }
if (/free/.test(t)) { res = res.filter(c => !c.price); notes.push('free'); }
if (/fast|quick|casual/.test(t)) { res = res.filter(c => c.tags.some(x => /fast|casual/.test(x))); notes.push('fast & casual'); }
const noM = text.match(/\b(?:no|not|without|skip)\s+([a-zàé'. ]{3,30})/i);
if (noM) {
const nm = noM[1].trim();
const hit = focus.results.find(c => c.name.toLowerCase().includes(nm));
if (hit) { excluded.add(hit.name); res = res.filter(c => c !== hit); notes.push(`excluded ${hit.name}`); }
}
focus.results = res;
renderFocus();
if (res.length) {
const a = focusAnchor();
const closest = res.reduce((m, c) => walkMin(a.at, c.at) < walkMin(a.at, m.at) ? c : m, res[0]);
ai(`${res.length} left${notes.length ? ' — ' + notes.join(', ') : ''}. Closest to ${a.name}: ${closest.name} (${walkMin(a.at, closest.at)} min walk). Keep filtering or click a pin.`, 600);
} else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600);
}
$('#focusbar-x').onclick = () => exitFocus('Back to the plan.');
document.addEventListener('keydown', e => { if (e.key === 'Escape') exitFocus(); });
// ---------------- day tabs ----------------
function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = '';
M.days.forEach(d => {
const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label);
t.onclick = () => setDay(d.id);
t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); });
t.addEventListener('dragleave', () => t.classList.remove('over'));
t.addEventListener('drop', e => {
e.preventDefault(); t.classList.remove('over');
const i = parseInt(e.dataTransfer.getData('text/plain'), 10);
const s = day.stops[i]; if (!s) return;
day.stops.splice(i, 1);
const tDay = days[d.id];
tDay.stops.push(s);
rebuildLegs(); if (tDay !== day) rebuildLegs(tDay);
setDay(curDay);
ai(`Moved ${s.name} to ${tDay.label}.`, 400);
});
tabs.append(t);
});
}
function setDay(id) {
curDay = id; day = days[id];
if (!day.legs.length) rebuildLegs(day);
document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', M.days[i] && M.days[i].id === id));
closeL3(); // a selected route from another day is stale
tempClear();
renderRail(); renderMap();
}
// ---------------- chat ----------------
function msg(role, html) {
const m = el('div', 'msg ' + role, html);
$('#chat-body').append(m);
m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
function typing() { return msg('ai', ''); }
function ai(html, delay = 700) {
const t = typing();
setTimeout(() => { t.remove(); msg('ai', html); scriptBusy = false; }, delay);
}
function chatSend() {
const v = $('#chat-input').value.trim();
if (!v) return;
$('#chat-input').value = '';
msg('user', v);
handleUser(v);
}
function handleUser(v) {
const s = v.toLowerCase();
if (focus) {
if (/reset/.test(s)) { excluded.clear(); enterFocus(focus.slot, focus.region); return; }
if (/done|exit|never ?mind|back to/.test(s)) { exitFocus('Back to the plan.'); return; }
focusFilter(v);
return;
}
if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { enterFocus('hotel'); return; }
const slotWord = s.match(/(lunch|dinner|breakfast|snack)/);
const regWord = s.match(/\b(oltrarno|smn|duomo|city centre)\b/);
if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → refine
if (/find|looking|search|options/.test(s)) enterFocus(slotWord[1], REGION_MAP[regWord[1]]);
else addRegionStop(slotWord[1], REGION_MAP[regWord[1]]);
return;
}
if (/find|looking|want|search|options|another|different/.test(s) && slotWord) { enterFocus(slotWord[1]); return; }
if (/find|looking|want|search|options|another|different/.test(s) && /activit|afternoon|things to do/.test(s)) { enterFocus('visit'); return; }
if (s.includes('later') || s.includes('morning')) {
day.startMin += 30;
renderRail(day.stops.map(x => x.id)); renderMap();
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at ${fmt(layout())}.`);
} else if (s.includes('lunch') || s.includes('longer')) {
const it = (issuesByDay[day.id] || [])[0];
const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned');
if (it && !it.fixed && lunch) {
it.fixed = true; lunch.dur = 90;
renderRail([lunch.id]); renderMap(); renderIssues();
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`);
} else ai(`There’s no time issue on ${dayLabel()} right now — the issues panel is clear.`);
} else {
ai('I can shift times, swap stops, move things between days, or re-route a leg — e.g. “start the day later” or “make lunch longer”. You can also drag stops by the ⠿ grip, drop one on a day tab, or click a leg to edit its route.');
}
}
$('#chat-send').onclick = chatSend;
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
// ---------------- landing → parse → plan ----------------
const SAMPLE = [
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'],
['dates', '12 – 15 Sep 2026', '3 nights', 'hi'],
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'],
['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid']
];
function showParse() {
const t = $('#parse-table'); t.innerHTML = '';
SAMPLE.forEach(([k, v, note, c]) => {
const tr = document.createElement('tr');
tr.innerHTML = `${k} | ${v}${c === 'hi' ? 'confirmed' : 'verify'} ${note} | `;
t.append(tr);
});
$('#parse-modal').classList.remove('hidden');
}
$('#parse-cancel').onclick = () => $('#parse-modal').classList.add('hidden');
$('#parse-confirm').onclick = startApp;
// ?autostart=plan|map|chat — skips the landing screen (dev/testing)
const auto = new URLSearchParams(location.search).get('autostart');
if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120);
function startApp() {
$('#parse-modal').classList.add('hidden');
$('#landing').classList.add('hidden');
$('#topbar').classList.remove('hidden');
$('#app').classList.remove('hidden');
renderBaseChip();
buildDayTabs();
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
renderRail(); renderMap();
script1();
}
// ---------------- detail sheet ----------------
// the hotel chip: reflects the (possibly multiple) open base options
function renderBaseChip() {
const tb = $('#transit-block'); tb.innerHTML = '';
const b = dayBase();
const n = staysForDay().length;
const h = el('div', 'transit-chip', `🏨${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'}base`);
h.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true)));
h.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false)));
tb.append(h);
}
function openDetail(s, actions) {
const d = s.detail; if (!d) return;
const oldM = document.getElementById('detail-modal'); if (oldM) oldM.remove();
const modal = el('div', 'modal'); modal.id = 'detail-modal';
const card = el('div', 'modal-card wide');
const img = el('div', 'd-img');
img.style.background = s.img[2];
if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; img.append(im); }
else img.append(el('div', 'd-emoji', s.img[0]));
img.append(el('div', 'd-attr', s.img[1] + ' · Wikimedia Commons'));
const body = el('div', 'd-body');
const t = el('div', 'd-title');
const a = el('a', null, s.name); a.href = s.url; a.target = 'blank'; a.rel = 'noopener';
t.append(a, el('span', 'd-kind', s.kind));
body.append(t);
const facts = el('div', 'd-facts');
facts.append(
el('span', 'tc ' + CONF_CLS[s.durConf], 'planned ' + fmt(s.dur)),
el('span', 'tc t-search', 'typically ' + fmt(s.suggested.value)),
s.price ? el('span', 'pchip', '~' + s.price.amount + s.price.cur + (s.mealKind ? ' / person' : '')) : null
);
body.append(facts);
body.append(el('div', 'd-summary', d.summary));
const links = el('div', 'd-links');
d.links.forEach(l => {
const b = el('a', 'd-link', l.label); b.href = l.href; b.target = 'blank'; b.rel = 'noopener';
links.append(b);
});
body.append(links);
if (actions) {
const foot = el('div', 'd-actions');
const a2 = el('button', 'btn primary small', 'Add to day'); a2.onclick = actions.add;
const m2 = el('button', 'btn ghost small', 'Maybe'); m2.onclick = actions.maybe;
const p2 = el('button', 'btn ghost small', 'Not for me'); p2.onclick = actions.pass;
foot.append(a2, m2, p2);
actions.register([a2, m2, p2]);
body.append(foot);
}
const close = el('button', 'd-close', '✕');
close.onclick = () => modal.remove();
card.append(img, body, close);
modal.append(card);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
document.body.append(modal);
}
// ---------------- adding stops (planned / maybe / alt) ----------------
function addStopToDay(id, requested) {
const src = allStops(id);
const copy = deep(src);
const hasPrimary = day.stops.some(x => x.slot === copy.slot && x.state === 'planned');
let state = requested || (hasPrimary ? 'alt' : 'planned');
if (state === 'planned') {
const cur = day.stops.find(x => x.slot === copy.slot && x.state === 'planned');
if (cur) cur.state = 'alt';
}
copy.state = state;
day.stops.push(copy);
rebuildLegs();
renderRail([id]); renderMap();
return state;
}
function script1() {
setTimeout(() => {
scriptBusy = true;
ai('Anchored your stay — Hotel Palagio, 12–15 Sep; the 15th’s train (09:12) is pinned as well. What kind of trip are you after?', 900);
setTimeout(() => {
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
const chips = el('div', 'chips');
M.vibes.forEach(v => {
const c = el('button', 'chip', v.label);
c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(); };
chips.append(c);
});
m.append(chips);
}, 1800);
}, 300);
}
function script2() {
scriptBusy = true;
ai('Noted — relaxed, food-first. A few ideas for your first day:', 900);
setTimeout(() => {
const m = msg('ai', '');
const cards = el('div', 'cards');
m.append(cards);
let sequenced = false;
M.suggestions.forEach((sg, i) => {
const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop);
const card = el('div', 'scard');
const imgBox = el('div', 'scard-img');
imgBox.style.background = s.img[2];
const skel = el('div', 'skel');
const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); };
imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons'));
if (s.img[3]) {
const im = document.createElement('img');
im.src = s.img[3]; im.alt = s.img[1];
im.onload = ready; im.onerror = ready;
imgBox.append(im);
} else {
imgBox.append(el('div', 'ph', s.img[0]));
setTimeout(ready, 1400 + i * 500);
}
const body = el('div', 'scard-body');
const title = el('div', 'scard-name');
const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener';
title.append(tl);
body.append(title);
body.append(el('div', 'scard-pitch', sg.pitch));
const foot = el('div', 'scard-foot');
const add = el('button', 'btn primary small', 'Add to day');
const maybeB = el('button', 'btn ghost small', 'Maybe');
const pass = el('button', 'btn ghost small', 'Not for me');
const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : '');
const st = { done: false };
const sheetBtns = [];
const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; });
const doAdd = () => { if (st.done) return; st.done = true;
card.classList.add('added');
finish(['Added ✓', '', '', 'Added ✓', '', '']);
const state = addStopToDay(s.id, null);
if (state === 'alt') ai(`${s.name} is in as the alternative for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
else ai(`Added ${s.name} — I’ll sequence the day once the set is done.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doMaybe = () => { if (st.done) return; st.done = true;
card.classList.add('maybe-card');
finish(['', 'Holding', '', '', 'Holding', '']);
addStopToDay(s.id, 'maybe');
ai(`Holding ${s.name} as an option — dashed on the day, not counted against the time budget.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doPass = () => { if (st.done) return; st.done = true;
card.classList.add('added', 'dropped');
finish(['', '', 'Ruled out', '', '', 'Ruled out']);
cards.appendChild(card);
const dm = document.getElementById('detail-modal'); if (dm) dm.remove();
excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches
ai(`Ruled out ${s.name} — noted as a preference.`, 500);
};
add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass;
card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } });
card.addEventListener('mouseenter', () => tempShow(s));
card.addEventListener('mouseleave', tempClear);
foot.append(add, maybeB, pass, pc);
body.append(foot);
const en = el('div', 'scard-enrich', sg.enrich);
body.append(en);
card.append(imgBox, body);
cards.append(card);
setTimeout(() => en.classList.add('show'), 2600 + i * 500);
});
m.scrollIntoView({ behavior: 'smooth' });
}, 1900);
}
function script3() {
setTimeout(() => {
const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 };
day.stops.sort((a, b) => (rank[a.id] ?? 99) - (rank[b.id] ?? 99));
if (!stopById('S6')) {
const gel = deep(allStops('S6'));
day.stops.push(gel);
}
rebuildLegs();
renderRail(day.stops.map(s => s.id)); renderMap();
ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h of 11 h waking. Two flags below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 1100);
}, 1400);
}
// ---------------- landing wiring ----------------
const dz = $('#dropzone');
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); }));
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); }));
dz.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); });
$('#dz-sample').onclick = showParse;
$('#dest-go').onclick = () => {
const v = $('#dest').value.trim();
if (!v) { showParse(); return; }
SAMPLE[1] = ['name', v, 'geocoded', 'hi'];
showParse();
};
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
initMap();