trips/mock/app.js
Greg Pomerantz efde2cc71b maps project: design, survey, mock app, and route-aware planning backend
- DESIGN.md: full design incl. driving-trip requirements (R1-R4),
  stays model, focus mode, mobile, provenance rules
- SURVEY.md: open-source landscape
- mock/: interaction mock (Florence itinerary, focus mode, stays,
  region stops, mobile layout)
- router/: Go module (stdlib-only) with Router interface
  (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor,
  routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing),
  integration tests, and setup-osrm.sh for the self-hosted router
- osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts
2026-09-06 00:05:17 -04:00

959 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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