diff --git a/mock/app.js b/mock/app.js
index 1e92cf2..2b17c55 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -1,9 +1,25 @@
-/* Interaction demo. All data is fake (data.js). */
+/* Interaction demo (redesigned). All data is fake (data.js).
+ *
+ * Design pillars (see recommendations):
+ * 1. ONE unified canvas — no modal "modes". Discovery is a drawer, not a
+ * mode-switch: the plan rail and map stay visible while browsing.
+ * 2. Smoother, proactive discovery — drawer chips + a "you might like"
+ * suggestion block in the rail, not just chat prompts.
+ * 3. Frictionless replacement — every stop card expands to inline
+ * alternatives with one-click swap, and any candidate can be dragged
+ * straight onto the plan (drop on a same-slot stop = swap).
+ * 4. Multi-scale comparison — a persistent bottom "compare dock" lines up
+ * any 2–4 candidates/stops side by side (hotel scale and stop scale).
+ * 5. Lower cognitive load — states read as "in plan / idea / backup",
+ * strict validators are rephrased as gentle nudges with one-tap fixes.
+ */
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));
+const WALK_KMH = 4.6;
+const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60)));
// ---------------- state ----------------
const days = {};
@@ -14,6 +30,11 @@ 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;
+let dragCand = null; // candidate object currently being dragged (drawer / dock)
+let compareItems = []; // items in the comparison dock (max 4)
+
+// user-facing state vocabulary (engine keeps planned/maybe/alt underneath)
+const stateLabel = st => st === 'planned' ? 'in plan' : st === 'maybe' ? 'idea' : 'backup';
// ---------------- mobile: single pane + bottom tabs ----------------
(function () {
@@ -28,14 +49,12 @@ let map, l3 = null, scriptBusy = false;
if (window.matchMedia('(max-width: 860px)').matches) setTab('plan');
})();
-// ---------------- map ----------------
-let hotelMks = [], trainMks = [];
+// ---------------- stays / base ----------------
// 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 covers = (s, date) => s.checkIn <= date && date <= s.checkOut;
const staysForDay = (d = day) => stays.filter(s => covers(s, d.date));
const dayBase = (d = day) => {
const list = staysForDay(d);
@@ -53,9 +72,10 @@ function renderHotelMks() {
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)'}`));
+ }).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'open option (worst-case anchor)'}`));
});
}
+let hotelMks = [], trainMks = [];
function initMap() {
map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 14);
@@ -136,9 +156,11 @@ function addRegionStop(slot, region) {
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);
+ rebuildLegs(); renderAll([stop.id]);
+ 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. Open the ${slot} drawer or tap the card to see options.`, 900);
}
+
+// ---------------- map ----------------
function renderMap() {
Object.values(markers).forEach(m => map.removeLayer(m)); markers = {};
Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {};
@@ -179,16 +201,17 @@ function renderMap() {
? `${i + 1}
`
: `${i + 1}
`,
iconSize: [26, 26], iconAnchor: [13, 13] })
- }).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + s.state : ''}`);
+ }).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + stateLabel(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 discOpen = !$('#discover').classList.contains('hidden');
const pad = matchMedia('(max-width: 860px)').matches
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
- : { paddingTopLeft: [370, 70], paddingBottomRight: [410, 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);
}
}
@@ -214,85 +237,289 @@ function tempShow(s) {
}
function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; }
+// ---------------- day strip (multi-scale L1 overview) ----------------
+function renderStrip() {
+ const p = $('#daystrip'); if (!p) return;
+ p.innerHTML = '';
+ layout();
+ const t0 = Math.floor(day.startMin / 60) * 60;
+ const t1 = Math.max(layout() + 15, day.startMin + 360);
+ const span = t1 - t0;
+ const pos = t => (t - t0) / span * 100;
+ const track = el('div', 'strip-track');
+ const block = (t, dur, cls, label, tip, onClick) => {
+ const b = el('div', 'sb ' + cls, label);
+ b.style.left = pos(t) + '%';
+ b.style.width = Math.max(1.4, dur / span * 100) + '%';
+ if (tip) b.title = tip;
+ if (onClick) b.onclick = onClick;
+ track.append(b);
+ };
+ let cur = t0;
+ if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free');
+ day.stops.forEach((s, i) => {
+ if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time');
+ const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit';
+ block(s.start, s.dur, kindCls + (s.state !== 'planned' ? ' dimmed' : ''), String(i + 1),
+ `${s.name} · ${fmt(s.start)}–${fmt(s.start + s.dur)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`,
+ () => flyTo(s));
+ cur = s.start + s.dur;
+ if (day.legs[i]) { block(cur, day.legs[i].dur, 'leg', '', `leg · ${day.legs[i].dur} min`); cur += day.legs[i].dur; }
+ });
+ const ret = returnDur();
+ if (ret) block(cur, ret, 'hotel', '🏨', 'back to the hotel');
+ p.append(track);
+ const axis = el('div', 'strip-axis');
+ for (let t = t0 + 60; t < t1; t += 60) {
+ const h = el('span', 'tick', String(Math.floor(t / 60)).padStart(2, '0'));
+ h.style.left = pos(t) + '%';
+ axis.append(h);
+ }
+ p.append(axis);
+}
+
+// ---------------- discovery (a drawer, not a mode) ----------------
+const CATS = [
+ { id: 'lunch', label: 'Lunch' },
+ { id: 'dinner', label: 'Dinner' },
+ { id: 'visit', label: 'Afternoon' },
+ { id: 'hotel', label: 'Hotel' },
+];
+const excluded = new Set(); // the exclusion list — survives across searches
+let disc = null; // {cat, region, results}
+let candMks = {};
+
+const isExcluded = c => [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase()));
+
+// where the user is when they make a choice: the stop right before the slot,
+// else the last planned stop, else the hotel
+function anchorStop(slot) {
+ const idx = slot ? day.stops.findIndex(x => x.slot === slot) : -1;
+ if (idx > 0) return day.stops[idx - 1];
+ if (idx === 0) return { name: dayBase().name, at: dayBase().at };
+ 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 poolFor(cat, region) {
+ let pool = (M.candidates[cat] || []).filter(c => !isExcluded(c));
+ if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r);
+ return pool;
+}
+function openDiscover(cat, region = null) {
+ if (!M.candidates[cat]?.length) { ai(`Nothing to browse for ${cat} here yet — try “lunch”, “afternoon” or “hotel”.`, 500); return; }
+ disc = { cat, region: region || null, results: poolFor(cat, region) };
+ $('#discover').classList.remove('hidden');
+ renderDiscover();
+ renderMap();
+}
+function closeDiscover(note) {
+ if (!disc) return;
+ Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
+ disc = null;
+ $('#discover').classList.add('hidden');
+ renderMap();
+ if (note) ai(note, 400);
+}
+function renderDiscover() {
+ const chipRow = $('#disc-chips'); chipRow.innerHTML = '';
+ CATS.forEach(c => {
+ const b = el('button', 'dchip' + (disc.cat === c.id ? ' on' : ''), c.label);
+ b.onclick = () => { disc = { cat: c.id, region: disc.region, results: poolFor(c.id, disc.region) }; renderDiscover(); renderMap(); };
+ chipRow.append(b);
+ });
+ const catLabel = CATS.find(c => c.id === disc.cat).label;
+ $('#disc-title').innerHTML = `${catLabel} ${disc.region ? ' · ' + disc.region.label : ' · ' + dayLabel()} — ${disc.results.length} option${disc.results.length !== 1 ? 's' : ''}`;
+ // numbered pins on the map
+ Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
+ const anchor = anchorStop(disc.cat);
+ const isHotel = disc.cat === 'hotel';
+ const list = $('#disc-list'); list.innerHTML = '';
+ disc.results.forEach((c, i) => {
+ const card = candCard(c, anchor, isHotel);
+ list.append(card);
+ const mk = L.marker(c.at, {
+ icon: L.divIcon({ className: '', html: `${i + 1}
`, iconSize: [24, 32], iconAnchor: [12, 30] })
+ }).addTo(map).bindTooltip(c.name);
+ mk.on('click', () => {
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ card.classList.add('flash'); setTimeout(() => card.classList.remove('flash'), 1200);
+ focusPoint(c.at);
+ });
+ candMks[c.id] = mk;
+ });
+ if (!disc.results.length)
+ list.append(el('div', 'disc-empty', M.candidates[disc.cat]?.length
+ ? 'Nothing left — loosen a filter in chat, or “reset”.'
+ : 'No options in this pool yet — try another category.'));
+}
+function findCand(id) {
+ for (const [slot, pool] of Object.entries(M.candidates)) {
+ const c = pool.find(x => x.id === id);
+ if (c) return { c, slot };
+ }
+ return null;
+}
+function candCard(c, anchor, isHotel) {
+ const card = el('div', 'candcard');
+ card.draggable = true;
+ card.addEventListener('dragstart', e => { dragCand = c; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'cand:' + c.id); });
+ card.addEventListener('dragend', () => { dragCand = null; document.querySelectorAll('.candcard.dragging, .drop-target').forEach(x => x.classList.remove('dragging', 'drop-target')); });
+ const head = el('div', 'cc-head');
+ if (c.img) {
+ const im = el('div', 'cc-img'); im.style.background = c.img[2];
+ if (c.img[3]) { const i = document.createElement('img'); i.src = c.img[3]; i.alt = c.img[1]; im.append(i); }
+ else im.append(el('span', 'cc-emoji', c.img[0]));
+ head.append(im);
+ }
+ head.append(el('div', 'cc-name', c.name));
+ card.append(head);
+ if (c.pitch) card.append(el('div', 'cc-pitch', c.pitch));
+ const chips = el('div', 'cc-chips');
+ chips.append(el('span', 'pchip', `↦ ${walkMin(anchor.at, c.at)} min from ${anchor.name}`));
+ if (anchor.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}`));
+ if (c.dur) chips.append(el('span', 'tc t-search', '~' + fmt(c.dur)));
+ chips.append(el('span', 'pchip', c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'));
+ card.append(chips);
+ if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' ')));
+ const acts = el('div', 'cc-acts');
+ const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
+ pick.onclick = () => chooseCand(c);
+ const idea = el('button', 'btn ghost small', 'Idea');
+ idea.onclick = () => holdIdea(c);
+ const cmp = el('button', 'btn ghost small', '⚖ Compare');
+ cmp.onclick = () => toggleCompare(itemFromCand(c, isHotel ? 'hotel' : disc.cat));
+ const no = el('button', 'cc-no', 'Not for me');
+ no.title = 'remember as a preference';
+ no.onclick = () => {
+ excluded.add(c.name.toLowerCase().includes('—') ? c.name.split('—').pop().trim() : c.name);
+ disc.results.splice(disc.results.indexOf(c), 1);
+ renderDiscover(); renderMap();
+ };
+ acts.append(pick, idea, cmp, no);
+ card.append(acts);
+ return card;
+}
+function makeStop(c, slot, state) {
+ return { id: c.id, name: c.name, kind: slot === 'visit' ? 'visit' : 'meal',
+ mealKind: slot === 'visit' ? undefined : slot, 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 };
+}
+// one slot, one plan: promote the candidate, demote the incumbent to backup
+function promoteCand(c, slot, state = 'planned', flash = true) {
+ const existing = day.stops.find(x => x.id === c.id);
+ const stop = existing || makeStop(c, slot, state);
+ let incumbent = null;
+ if (state === 'planned') {
+ incumbent = day.stops.find(x => x.slot === slot && x.state === 'planned' && x !== stop) || null;
+ if (incumbent) incumbent.state = 'alt';
+ }
+ stop.state = state;
+ if (!existing) {
+ // take the incumbent’s sequence position so the day’s order stays sensible
+ const at = incumbent ? day.stops.indexOf(incumbent) : -1;
+ if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
+ }
+ rebuildLegs();
+ 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 };
+}
+function chooseCand(c, slot) {
+ slot = slot || disc?.cat;
+ if (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();
+ closeDiscover(); renderAll();
+ 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 loose = day.stops.find(x => x.slot === slot && x.kind === 'region');
+ if (loose) {
+ day.stops[day.stops.indexOf(loose)] = makeStop(c, slot, 'planned');
+ rebuildLegs(); closeDiscover(); renderAll([c.id]);
+ ai(`Refined: ${c.name} is your ${slot} now — the placeholder is gone and the leg times are real.`, 600);
+ return;
+ }
+ const { incumbent } = promoteCand(c, slot);
+ closeDiscover();
+ ai(`Locked in ${c.name} for ${dayLabel()}${incumbent ? ` — ${incumbent.name} becomes the backup: one ${slot}, one plan, swap any time.` : '. See it in the day.'}`, 600);
+}
+function holdIdea(c, slot) {
+ slot = slot || disc?.cat;
+ if (slot === 'hotel') {
+ if (stays.some(o => o.name === c.name)) { closeDiscover(); 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();
+ closeDiscover(); renderAll();
+ 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 === slot && x.kind === 'region');
+ if (loose) {
+ const stop = makeStop(c, slot, 'maybe');
+ day.stops[day.stops.indexOf(loose)] = stop;
+ rebuildLegs(); closeDiscover(); renderAll([stop.id]);
+ ai(`Holding ${c.name} as an idea — the placeholder became a specific option, off the time budget.`, 600);
+ return;
+ }
+ if (day.stops.some(x => x.id === c.id)) { closeDiscover(); return; }
+ day.stops.push(makeStop(c, slot, 'maybe'));
+ rebuildLegs();
+ closeDiscover();
+ renderAll([c.id]);
+ ai(`Holding ${c.name} as an idea — dashed on the day, not counted against the time budget.`, 500);
+}
+
+// chat still filters an open drawer: "cheaper", "near Boboli", "no Mario"
+function drawerFilter(text) {
+ const t = text.toLowerCase();
+ let res = [...disc.results];
+ const notes = [];
+ if (/cheaper|cheap|less|budget|under/.test(t)) {
+ const m = t.match(/under\s*€?(\d+)/);
+ const cap = m ? +m[1] : (disc.cat === '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 = disc.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}`); }
+ }
+ disc.results = res;
+ renderDiscover();
+ if (res.length) {
+ const a = anchorStop(disc.cat);
+ 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 tap a card.`, 600);
+ } else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600);
+}
+$('#disc-x').onclick = () => closeDiscover('Back to the plan.');
+document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc) closeDiscover(); else closeL3(); } });
+
// ---------------- 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);
- }
- });
+ day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
if (day.stops.length && !day.isDeparture) {
const multi = staysForDay().length > 1;
const ret = el('div', 'leg-row', `🏨 back to hotel `);
@@ -304,23 +531,188 @@ function renderRail(flashIds = []) {
ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
body.append(ret);
}
- renderBudget(); renderIssues();
+ renderStrip(); renderBudget(); renderNudges(); renderSugg();
+}
+const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' };
+// inline alternatives for a planned stop: same-slot candidates, not already in the day
+function slotAlternatives(s, n = 3) {
+ const key = POOL_KEY[s.slot]; if (!key) return [];
+ const a = anchorStop(s.slot);
+ return [...(M.candidates[key] || [])]
+ .filter(c => c.id !== s.id && !isExcluded(c) && !day.stops.some(x => x.id === c.id))
+ .sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, n);
+}
+function renderStopCard(body, s, i, flashIds) {
+ if (s.kind === 'transit') {
+ // booked external transport: fixed, shown only on its own day
+ const c = el('div', 'stop-card transit-card');
+ if (flashIds.includes(s.id)) c.classList.add('flash');
+ const top = el('div', 'sc-top');
+ top.append(el('span', 'num', String(i + 1)));
+ 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;
+ }
+
+ 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', 'grip', '⠿'));
+ top.append(el('span', 'num', String(i + 1)));
+ 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');
+ if (s.state !== 'planned') {
+ const st = el('span', 'sc-state ' + (s.state === 'maybe' ? 't-idea' : 't-backup'), stateLabel(s.state));
+ meta.append(st);
+ }
+ meta.append(tchip(s.dur, s.durConf));
+ if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned')
+ meta.append(el('span', 'sug-note', `usually ~${fmt(s.suggested.value)}`));
+ if (s.price) meta.append(el('span', 'pchip', `~${s.price.amount}${s.price.cur}`));
+ c.append(meta);
+
+ // vague placeholder: one tap to browse the area
+ if (s.region) {
+ c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined'));
+ const rf = el('button', 'refine-btn', 'See options →');
+ rf.onclick = e => { e.stopPropagation(); openDiscover(POOL_KEY[s.slot] || s.slot, s.region); };
+ c.append(rf);
+ }
+
+ // quick actions: compare + alternatives (planned only) + more menu
+ const acts = el('div', 'sc-acts');
+ const cmp = el('button', 'btn ghost tiny', '⚖');
+ cmp.title = 'Add to comparison';
+ cmp.onclick = e => { e.stopPropagation(); toggleCompare(itemFromStop(s)); };
+ acts.append(cmp);
+ if (s.state === 'planned') {
+ const alts = slotAlternatives(s);
+ if (alts.length) {
+ const aBtn = el('button', 'btn ghost tiny', `⇄ alternatives (${alts.length})`);
+ const box = el('div', 'alts');
+ alts.forEach(cand => {
+ const row = el('div', 'altrow');
+ row.append(el('span', 'ar-name', cand.name));
+ row.append(el('span', 'pchip', `↦ ${walkMin(anchorStop(s.slot).at, cand.at)} min`));
+ row.append(el('span', 'pchip', cand.price ? '~' + cand.price + '€' : 'free'));
+ const sw = el('button', 'btn ghost tiny', 'Swap');
+ sw.onclick = e => {
+ e.stopPropagation();
+ const { incumbent } = promoteCand(cand, s.slot);
+ ai(`Swapped: ${cand.name} is your ${s.slot} now${incumbent ? `; ${incumbent.name} becomes the backup.` : ''} Times re-sequenced.`, 500);
+ };
+ const ac = el('button', 'btn ghost tiny', '⚖');
+ ac.title = 'Compare';
+ ac.onclick = e => { e.stopPropagation(); toggleCompare(itemFromCand(cand, s.slot)); };
+ row.append(sw, ac);
+ box.append(row);
+ });
+ aBtn.onclick = e => { e.stopPropagation(); box.classList.toggle('open'); };
+ acts.append(aBtn);
+ c.append(box);
+ }
+ } else {
+ // idea / backup: one-tap promote
+ const up = el('button', 'btn ghost tiny', s.state === 'alt' ? '⇄ Swap in' : '✓ Make planned');
+ up.onclick = e => { e.stopPropagation(); setState(s, 'planned'); };
+ acts.append(up);
+ }
+ const more = el('button', 'btn ghost tiny', '⋯');
+ more.title = 'More (move, remove, state)';
+ more.onclick = e => { e.stopPropagation(); openStateMenu(s, more); };
+ acts.append(more);
+ c.append(acts);
+
+ // drag & drop: reorder within day, or drop a candidate card onto this stop
+ c.draggable = true;
+ c.addEventListener('dragstart', e => {
+ if (dragCand) { e.preventDefault(); return; }
+ 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();
+ if (dragCand) c.classList.add('drop-target');
+ });
+ c.addEventListener('dragleave', () => c.classList.remove('drop-target'));
+ c.addEventListener('drop', e => {
+ e.preventDefault(); c.classList.remove('drop-target');
+ const data = e.dataTransfer.getData('text/plain');
+ if (data.startsWith('cand:')) {
+ const found = findCand(data.slice(5));
+ if (!found) return;
+ const { c: cand, slot } = found;
+ dragCand = null;
+ e.stopPropagation();
+ if (slot === s.slot) {
+ const { incumbent } = promoteCand(cand, slot);
+ if (incumbent) ai(`Dropped ${cand.name} over its slot — it’s your ${slot} now; ${incumbent.name} becomes the backup.`, 500);
+ else ai(`Dropped ${cand.name} into ${dayLabel()}.`, 500);
+ } else {
+ day.stops.push(makeStop(cand, slot, 'maybe'));
+ rebuildLegs(); renderAll([cand.id]);
+ ai(`Dropped ${cand.name} into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
+ }
+ if (disc) closeDiscover();
+ return;
+ }
+ const from = parseInt(data, 10);
+ if (isNaN(from) || from === i) return;
+ const [moved] = day.stops.splice(from, 1);
+ day.stops.splice(i, 0, moved);
+ rebuildLegs();
+ renderAll([moved.id]);
+ });
+ c.addEventListener('mouseenter', () => highlightStop(s.id, true));
+ c.addEventListener('mouseleave', () => highlightStop(s.id, false));
+ c.addEventListener('click', e => {
+ if (e.target.closest('button, a')) return;
+ if (s.detail) openDetail(s, null);
+ else 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);
+ }
}
-// state menu (planned ⇄ maybe ⇄ alt, move day, remove)
+// state menu (promote / demote / 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'));
+ mk('→ Idea', () => setState(s, 'maybe'));
+ mk('→ Backup', () => setState(s, 'alt'));
} else if (s.state === 'maybe') {
- mk('→ Planned', () => setState(s, 'planned'));
- mk('→ Alternative', () => setState(s, 'alt'));
+ mk('→ In plan', () => setState(s, 'planned'));
+ mk('→ Backup', () => setState(s, 'alt'));
} else {
- mk('→ Planned (swap in)', () => setState(s, 'planned'));
- mk('→ Maybe', () => setState(s, 'maybe'));
+ mk('⇄ Swap in', () => setState(s, 'planned'));
+ mk('→ Idea', () => setState(s, 'maybe'));
}
M.days.filter(d => d.id !== day.id).forEach(d => {
mk(`Move to ${d.label}`, () => moveStopToDay(s, d.id));
@@ -331,9 +723,9 @@ function openStateMenu(s, anchor) {
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
+ if (promoted) promoted.state = 'planned'; // last backup in the slot becomes the plan
}
- rebuildLegs(); renderRail(); renderMap();
+ rebuildLegs(); renderAll();
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
@@ -344,23 +736,24 @@ document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMe
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); }
+ if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} becomes the backup.`, 600); }
}
s.state = state;
- rebuildLegs(); renderRail([s.id]); renderMap();
+ rebuildLegs(); renderAll([s.id]);
if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt')))
- ai(`${s.name} is now “${state}”.`, 400);
+ ai(`${s.name} is now “${stateLabel(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();
+ renderAll();
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; }
+const dayLabel = () => M.days.find(d => d.id === day.id).label;
+// ---------------- time budget (gentle phrasing) ----------------
function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length;
@@ -373,59 +766,167 @@ function renderBudget() {
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)})`;
+ const mood = pct > 92 ? ' — running a bit full, there’s room to trim' : pct > 80 ? '' : ' — comfortably paced';
+ $('#budget-text').textContent = `${(planned / 60).toFixed(1)} h of ${day.wakingHours} h · ends ~${fmt(end)}${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h of ideas' : ''}${mood}`;
}
-// ---------------- issues ----------------
-const issuesByDay = {
+// ---------------- nudges (issues, but gentle) ----------------
+const nudgesByDay = {
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' }
+ { id: 'N1', fixed: false,
+ title: 'Lunch may be a little tight',
+ body: '45 min at Trattoria Mario — a meal there usually runs ~90 min (web) , 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: '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' }
+ { 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() {} } }
]
};
-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'));
+function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
+function renderNudges() {
+ const p = $('#nudges'); if (!p) return; p.innerHTML = '';
+ const list = activeNudges();
+ if (!list.length) { p.append(el('div', 'nudge-ok', '✓ this day looks good')); }
+ list.forEach(n => {
+ const d = el('div', 'nudge');
+ d.append(el('div', 'nt', n.title));
+ d.append(el('div', 'nb', n.body));
+ const b = el('button', 'btn ghost small', n.fix.label);
+ b.onclick = () => {
+ n.fixed = true; n.fix.apply();
+ renderAll();
+ if (n.fix.msg) ai(n.fix.msg, 300);
+ };
+ d.append(b);
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);
+ const badge = $('#nudge-badge');
+ if (badge) {
+ badge.innerHTML = list.length ? `💡 ${list.length} nudge${list.length > 1 ? 's' : ''}` : '✓ looking good';
+ badge.classList.toggle('ok', !list.length);
}
}
+// ---------------- "you might like" (context-aware suggestions in the rail) ----------------
+function renderSugg() {
+ const p = $('#sugg-block'); if (!p) return; p.innerHTML = '';
+ const planned = day.stops.filter(s => s.state === 'planned');
+ const missing = ['dinner', 'lunch', 'visit'].find(k => !planned.some(s => s.slot === k) && M.candidates[k]?.length);
+ let title, note, pool, slot;
+ if (missing) {
+ slot = missing;
+ title = `No ${missing} planned yet`;
+ note = `· ${dayLabel()}`;
+ pool = M.candidates[slot].filter(c => !isExcluded(c));
+ } else {
+ slot = 'visit';
+ title = 'More like this day';
+ note = '';
+ pool = (M.candidates.visit || []).filter(c => !isExcluded(c) && !day.stops.some(x => x.id === c.id));
+ }
+ if (!pool.length) return;
+ const a = anchorStop(slot);
+ const best = [...pool].sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, 3);
+ const head = el('div', 'sugg-title', `💡 ${title} ${note} `);
+ const open = el('button', 'sugg-open', 'open drawer');
+ open.onclick = () => openDiscover(slot);
+ head.append(open);
+ p.append(head);
+ best.forEach(c => {
+ const row = el('div', 'sugg-row');
+ row.append(el('span', 'sr-name', c.name));
+ row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`));
+ row.append(el('span', 'pchip', c.price ? '~' + c.price + '€' : 'free'));
+ const cmp = el('button', 'btn ghost tiny', '⚖');
+ cmp.title = 'Compare';
+ cmp.onclick = () => toggleCompare(itemFromCand(c, slot));
+ row.append(cmp);
+ p.append(row);
+ });
+}
+
+// ---------------- comparison dock (multi-scale alternatives) ----------------
+function itemFromCand(c, slot) {
+ return { key: 'cand:' + c.id, kind: 'cand', cand: c, slot,
+ name: c.name, at: c.at, price: c.price, dur: c.dur, tags: c.tags, img: c.img };
+}
+function itemFromStop(s) {
+ return { key: 'stop:' + s.id, kind: 'stop', stop: s, slot: s.slot,
+ name: s.name, at: s.at, price: s.price ? s.price.amount : null, dur: s.dur,
+ tags: [s.state === 'planned' ? 'in plan' : stateLabel(s.state), s.kind], img: s.img };
+}
+function toggleCompare(item) {
+ const i = compareItems.findIndex(x => x.key === item.key);
+ if (i >= 0) { compareItems.splice(i, 1); }
+ else {
+ if (compareItems.length >= 4) { ai('The comparison holds up to four — remove one first (✕).', 400); return; }
+ compareItems.push(item);
+ }
+ renderDock();
+}
+function renderDock() {
+ const d = $('#dock');
+ if (!compareItems.length) { d.classList.add('hidden'); d.innerHTML = ''; return; }
+ d.classList.remove('hidden');
+ d.innerHTML = '';
+ const head = el('div', 'dock-head');
+ head.append(el('span', null, `Comparing ${compareItems.length} — side by side, from the anchor point; choose or hold any of them`));
+ const clr = el('button', 'dock-clear', 'clear all');
+ clr.onclick = () => { compareItems = []; renderDock(); };
+ head.append(clr);
+ d.append(head);
+ const grid = el('div', 'dock-grid');
+ compareItems.forEach(item => {
+ const isHotel = item.slot === 'hotel';
+ const a = anchorStop(isHotel ? null : item.slot);
+ const col = el('div', 'dock-col');
+ const t = el('div', 'dc-name', item.name);
+ if (item.img) { const th = el('div', 'dc-thumb'); th.style.background = item.img[2]; th.textContent = item.img[0]; t.prepend(th); }
+ col.append(t);
+ const chips = el('div', 'dc-chips');
+ if (item.at) {
+ chips.append(el('span', 'pchip', `↦ ${walkMin(a.at, item.at)} min from ${a.name}`));
+ if (a.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(item.at)} min to hotel`));
+ }
+ if (item.dur) chips.append(el('span', 'tc t-search', '~' + fmt(item.dur)));
+ const price = typeof item.price === 'number' ? item.price : (item.price?.amount ?? null);
+ chips.append(el('span', 'pchip', price ? '~' + price + '€ / ' + (isHotel ? 'night' : 'person') : item.price === 0 ? 'free' : '—'));
+ col.append(chips);
+ if (item.tags?.length) col.append(el('div', 'dc-tags', item.tags.map(t => (t === 'in plan' ? '✓ ' : '') + t).join(' · ')));
+ const acts = el('div', 'dc-acts');
+ if (item.kind === 'cand') {
+ const pick = el('button', 'btn primary tiny', isHotel ? 'Make this the hotel' : 'Choose this');
+ pick.onclick = () => { chooseCand(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
+ const idea = el('button', 'btn ghost tiny', 'Idea');
+ idea.onclick = () => { holdIdea(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
+ acts.append(pick, idea);
+ } else {
+ const st = el('span', 'dc-state', item.stop.state === 'planned' ? 'in the plan' : stateLabel(item.stop.state));
+ if (item.stop.state !== 'planned') {
+ const up = el('button', 'btn primary tiny', '⇄ Swap in');
+ up.onclick = () => { setState(item.stop, 'planned'); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
+ acts.append(up);
+ } else acts.append(st);
+ }
+ const rm = el('button', 'dc-rm', '✕');
+ rm.title = 'remove from comparison';
+ rm.onclick = () => { compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
+ acts.append(rm);
+ col.append(acts);
+ grid.append(col);
+ });
+ d.append(grid);
+}
+
// ---------------- L3 route editor ----------------
function haversine(a, b) {
const R = 6371e3, toR = d => d * Math.PI / 180;
@@ -460,7 +961,7 @@ function openL3(legIdx) {
const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs);
- renderRail(); renderMap();
+ renderAll();
}, 250);
}
function closeL3() {
@@ -469,173 +970,6 @@ function closeL3() {
}
$('#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'}
-
- ${isHotel ? 'Make this the hotel' : 'Choose this'}
- ${isHotel ? 'Keep as option' : 'Maybe'}
-
-
`;
- 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 = '';
@@ -664,8 +998,10 @@ function setDay(id) {
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();
+ if (disc) closeDiscover(); // the drawer’s anchor is day-specific
+ renderAll();
}
+function renderAll(flashIds = []) { renderRail(flashIds); renderMap(); }
// ---------------- chat ----------------
function msg(role, html) {
@@ -688,36 +1024,40 @@ function chatSend() {
}
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);
+ if (disc) {
+ if (/reset/.test(s)) { disc.results = poolFor(disc.cat, disc.region); renderDiscover(); renderMap(); ai('Back to the full list.', 400); return; }
+ if (/done|exit|never ?mind|back to/.test(s)) { closeDiscover('Back to the plan.'); return; }
+ drawerFilter(v);
return;
}
- if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { enterFocus('hotel'); return; }
+ if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { openDiscover('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]]);
+ if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → browse
+ if (/find|looking|search|options/.test(s)) openDiscover(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 (/find|looking|want|search|options|another|different|discover/.test(s) && slotWord) { openDiscover(slotWord[1]); return; }
+ if (/find|looking|want|search|options|another|different|discover/.test(s) && /activit|afternoon|things to do/.test(s)) { openDiscover('visit'); return; }
+ if (/compare/.test(s)) {
+ ai('Tap the ⚖ on any candidate, stop card, or suggestion row to line up 2–4 options side by side in the comparison tray at the bottom — travel from the anchor, to the hotel, price, and time, all in one glance.', 900);
+ return;
+ }
if (s.includes('later') || s.includes('morning')) {
day.startMin += 30;
- renderRail(day.stops.map(x => x.id)); renderMap();
+ renderAll(day.stops.map(x => x.id));
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 n = activeNudges()[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();
+ if (n && !n.fixed && lunch) {
+ n.fixed = true; lunch.dur = 90;
+ renderAll([lunch.id]);
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`);
- } else ai(`There’s no time issue on ${dayLabel()} right now — the issues panel is clear.`);
+ } else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`);
} 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.');
+ 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 alternatives or drag a candidate straight onto the plan.', 900);
}
}
$('#chat-send').onclick = chatSend;
@@ -755,7 +1095,7 @@ function startApp() {
renderBaseChip();
buildDayTabs();
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
- renderRail(); renderMap();
+ renderAll();
script1();
}
@@ -765,9 +1105,10 @@ 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 `);
+ const h = el('div', 'transit-chip', `🏨 ${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'} · ⚖ to compare hotels base `);
h.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true)));
h.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false)));
+ h.onclick = () => openDiscover('hotel');
tb.append(h);
}
@@ -803,7 +1144,7 @@ function openDetail(s, actions) {
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 m2 = el('button', 'btn ghost small', 'Idea'); 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]);
@@ -830,14 +1171,14 @@ function addStopToDay(id, requested) {
copy.state = state;
day.stops.push(copy);
rebuildLegs();
- renderRail([id]); renderMap();
+ renderAll([id]);
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);
+ ai('Anchored your stay — Hotel Palagio, 12–15 Sep ; 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);
setTimeout(() => {
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
const chips = el('div', 'chips');
@@ -883,7 +1224,7 @@ function script2() {
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 maybeB = el('button', 'btn ghost small', 'Idea');
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 };
@@ -893,7 +1234,7 @@ function script2() {
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);
+ if (state === 'alt') ai(`${s.name} is in as the backup 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); }
};
@@ -901,7 +1242,7 @@ function script2() {
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);
+ ai(`Holding ${s.name} as an idea — 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;
@@ -936,8 +1277,8 @@ function script3() {
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);
+ renderAll(day.stops.map(s => s.id));
+ ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h 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);
}, 1400);
}
@@ -955,4 +1296,22 @@ $('#dest-go').onclick = () => {
};
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
+// drop a candidate onto the plan panel itself → hold it as an idea
+(function () {
+ const body = $('#rail-body'); if (!body) return;
+ body.addEventListener('dragover', e => { if (dragCand) e.preventDefault(); });
+ body.addEventListener('drop', e => {
+ const data = e.dataTransfer.getData('text/plain');
+ if (!data.startsWith('cand:')) return;
+ e.preventDefault();
+ const found = findCand(data.slice(5));
+ if (!found) return;
+ const { c, slot } = found; dragCand = null;
+ day.stops.push(makeStop(c, slot, 'maybe'));
+ rebuildLegs(); renderAll([c.id]);
+ if (disc) closeDiscover();
+ ai(`Dropped ${c.name} into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
+ });
+})();
+
initMap();
diff --git a/mock/index.html b/mock/index.html
index 01702cd..643e64d 100644
--- a/mock/index.html
+++ b/mock/index.html
@@ -46,20 +46,22 @@
- ⚠ 0 issues
+ ✓ looking good