server.js: /llm-status probe + /llm-chat proxy to the local llama.cpp OpenAI-compatible server (192.168.3.7:1234, model ctx 131072, 2 parallel sessions). Model name, base URL and token budget pinned server-side (LLM_BASE/LLM_MODEL/LLM_MAX_TOKENS env to override); non-streaming, since the hidden thinking text shares max_tokens with the visible answer. app.js: checkLLM() alongside checkRouter(); free-form chat now goes to the model with a compact live plan snapshot (bookings, stays, per-day stops with times) as system context. Deterministic UI intents (discovery drawer, time nudges, swaps) stay local — the LLM proposes, the UI commits. Canned-reply fallback when the server is down or the model is not loaded; status badge now reads e.g. 'online · router · llm'.
1743 lines
84 KiB
JavaScript
1743 lines
84 KiB
JavaScript
/* 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.
|
||
*/
|
||
let M = window.MOCK; // active trip dataset (reassigned on trip switch)
|
||
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)));
|
||
|
||
// ---------------- route client (local OSRM via the /route proxy) ----------
|
||
// Legs start life as straight-line estimates (conf 1) and are upgraded in the
|
||
// background to real road-network times + geometry (conf 3) when the point
|
||
// pair is inside the router's extract. Out of coverage → stays an estimate.
|
||
let routerOn = false;
|
||
// each trip declares which local OSRM extract covers it (server.js maps
|
||
// the key to an upstream; default is the NE-US car extract)
|
||
const routerKey = () => (M && M.router) || 'northeast';
|
||
async function checkRouter() {
|
||
try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; }
|
||
catch { routerOn = false; }
|
||
}
|
||
|
||
// ---------------- LLM client (local llama.cpp via the /llm-chat proxy) -----
|
||
// Free-form chat answers come from the model; deterministic UI intents
|
||
// (drawer discovery, time nudges, swaps) stay local — the LLM proposes,
|
||
// the user's UI commits. Falls back to canned replies when the server is
|
||
// down or the model isn't loaded.
|
||
let llmOn = false;
|
||
async function checkLLM() {
|
||
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
|
||
catch { llmOn = false; }
|
||
}
|
||
const badgeText = () => '📶 online' + (routerOn ? ' · router' : '') + (llmOn ? ' · llm' : '');
|
||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
// Compact itinerary snapshot (the design doc's LLM context form): stops with
|
||
// times + stays + bookings. No geometry, no tool output. layout() fills
|
||
// s.start in place, so it is safe to call at any time.
|
||
function llmContext() {
|
||
const L = [`Trip: ${M.trip.title}`];
|
||
for (const b of M.trip.bookings)
|
||
L.push(`Booking (${b.type}): ${b.name}${b.ref ? ' — ref ' + b.ref : ''}${b.dates ? ', ' + b.dates : ''}${b.from ? `, ${b.from} → ${b.to}, ${b.date} dep ${b.depart}` : ''}`);
|
||
for (const st of stays)
|
||
L.push(`Stay: ${st.name} (${st.place || ''}) — ${st.checkIn} → ${st.checkOut}${st.price ? ', ' + st.price + ' €/night' : ''}`);
|
||
for (const d of M.days) {
|
||
const dd = days[d.id];
|
||
layout(dd);
|
||
const stops = dd.stops.filter(s => s.state === 'planned').map(
|
||
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
|
||
L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
|
||
}
|
||
return L.join('\n');
|
||
}
|
||
function askLLM(v) {
|
||
const t = typing();
|
||
scriptBusy = true;
|
||
const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' +
|
||
'Answer the user\'s message concisely (2–5 sentences), using the plan as ground truth — never invent times, prices, or stops. ' +
|
||
'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' +
|
||
'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext();
|
||
fetch('/llm-chat', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
const c = (j.choices?.[0]?.message?.content || '').trim();
|
||
t.remove();
|
||
msg('ai', c ? esc(c).replace(/\n+/g, '<br>')
|
||
: 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
||
scriptBusy = false;
|
||
})
|
||
.catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; });
|
||
}
|
||
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
|
||
// OSRM encodes with precision 5 (unlike Google’s default 6)
|
||
function decodePolyline(str, precision = 5) {
|
||
const out = [];
|
||
let p = 0, lat = 0, lng = 0;
|
||
const next = () => { // one zigzag varint component
|
||
let result = 0, shift = 0, b;
|
||
do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32);
|
||
return (result & 1) ? ~(result >> 1) : (result >> 1);
|
||
};
|
||
while (p < str.length) {
|
||
lat += next();
|
||
lng += next();
|
||
out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat]
|
||
}
|
||
return out;
|
||
}
|
||
// route an ordered list of [lat,lng] points; null when unroutable (out of coverage)
|
||
async function routePts(points, mode = 'foot') {
|
||
if (!routerOn) return null;
|
||
const q = points.map(p => `${p[1]},${p[0]}`).join(';');
|
||
try {
|
||
const r = await fetch(`/route?mode=${mode}&router=${routerKey()}&points=${encodeURIComponent(q)}`);
|
||
const j = await r.json();
|
||
const route = j?.routes?.[0];
|
||
if (!route) return null; // NoTable / out of coverage
|
||
if (!route.distance && !route.duration) return null;
|
||
// distance 0 = both points snapped to the same node → adjacent stops
|
||
return {
|
||
durMin: Math.max(1, Math.round(route.duration / 60)),
|
||
distance: route.distance,
|
||
geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng]
|
||
};
|
||
} catch { return null; }
|
||
}
|
||
let legToken = 0;
|
||
// background enrichment: upgrade each straight-line leg to a real route
|
||
async function enrichLegs(d = day) {
|
||
if (!routerOn) return;
|
||
const token = ++legToken;
|
||
for (let i = 0; i < d.stops.length - 1; i++) {
|
||
const a = d.stops[i], b = d.stops[i + 1];
|
||
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
|
||
// legs touching a transit stop are fixed (flight/taxi/bus), never re-routed
|
||
if (a.kind === 'transit' || b.kind === 'transit') continue;
|
||
const res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode);
|
||
if (token !== legToken) return; // the day changed mid-flight
|
||
const leg = d.legs[i];
|
||
if (res) {
|
||
const changed = leg.dur !== res.durMin || !leg.geometry;
|
||
leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry;
|
||
leg.src = 'local router';
|
||
if (changed) renderAll();
|
||
} else {
|
||
leg.conf = 1; leg.src = 'estimate — outside router coverage';
|
||
renderAll();
|
||
}
|
||
}
|
||
// return-to-hotel leg when a single stay anchors the day
|
||
if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) {
|
||
const last = d.stops[d.stops.length - 1];
|
||
if (last.kind !== 'region' && last.kind !== 'transit') {
|
||
const res = await routePts([last.at, dayBase(d).at], 'foot');
|
||
if (token !== legToken) return;
|
||
if (res) { d.retRouted = res; renderAll(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------- state ----------------
|
||
// (filled by enterTrip() — the app can switch between window.TRIPS at runtime)
|
||
let days = {};
|
||
let curDay, day;
|
||
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 () {
|
||
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');
|
||
})();
|
||
|
||
// ---------------- 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.
|
||
let stays, staySeq; // filled by enterTrip()
|
||
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);
|
||
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' : 'open option (worst-case anchor)'}`));
|
||
});
|
||
}
|
||
let hotelMks = [], trainMks = [];
|
||
|
||
function initMap() {
|
||
const c = M.trip.places?.[0]?.center || [43.769, 11.252];
|
||
map = L.map('map', { zoomControl: false }).setView(c, 14);
|
||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||
L.tileLayer('/tiles/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19
|
||
}).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: '🚌', fly: '✈️' };
|
||
|
||
// ---------------- 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;
|
||
// a single stay anchored the day and the router has a real walk → use it
|
||
if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin;
|
||
const list = staysForDay(d);
|
||
// worst case across every candidate stay for these nights (equals the real walk when one)
|
||
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60)));
|
||
}
|
||
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 = [];
|
||
delete d.retRouted;
|
||
for (let i = 0; i < d.stops.length - 1; i++) {
|
||
const a = d.stops[i], b = d.stops[i + 1];
|
||
// a vague (region) stop at either end → worst-case leg until refined
|
||
if (a.kind === 'region' || b.kind === 'region') {
|
||
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
|
||
} else {
|
||
// legs touching a transit stop: fixed duration (flight + taxi/bus),
|
||
// optionally broken down via M.multimodal entries
|
||
if (a.kind === 'transit' || b.kind === 'transit') {
|
||
const mm = (M.multimodal || []).find(x => x.into === b.id)
|
||
|| (M.multimodal || []).find(x => x.into === a.id);
|
||
d.legs.push(mm
|
||
? { mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }
|
||
: { mode: 'fly', dur: 30, conf: 3, sub: [{ mode: 'fly', dur: 30 }] });
|
||
continue;
|
||
}
|
||
const mm = (M.multimodal || []).find(x => x.into === b.id);
|
||
if (mm) {
|
||
d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub });
|
||
} else {
|
||
let h = 0; const key = a.name + '→' + b.name;
|
||
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997;
|
||
// straight-line estimate until the router upgrades it (or proves out of coverage)
|
||
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 });
|
||
}
|
||
}
|
||
}
|
||
enrichLegs(d);
|
||
}
|
||
|
||
// ---------------- vague / region placeholder stops ----------------
|
||
const REGION_MAP = {
|
||
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(); renderAll([stop.id]);
|
||
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. 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 = {};
|
||
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++) {
|
||
// real road geometry once the router has answered, straight line until then
|
||
const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]];
|
||
legEls[i] = L.polyline(g, {
|
||
color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : obj.legs[i]?.mode === 'fly' ? '#8a93a6' : '#2e9e5b',
|
||
weight: 5, opacity: .85,
|
||
dashArray: obj.legs[i]?.mode === 'fly' ? '6 6' : undefined
|
||
}).addTo(map);
|
||
}
|
||
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
|
||
staysForDay(obj).forEach(o => {
|
||
const g = (obj.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at];
|
||
retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map);
|
||
otherLayers.push(retLine);
|
||
retLines.push(retLine);
|
||
});
|
||
}
|
||
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' ? ' · ' + 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];
|
||
const pts = scope === 'trip'
|
||
? M.days.flatMap(md => days[md.id].stops.map(s => s.at))
|
||
: cur.stops.map(s => s.at);
|
||
if (pts.length) {
|
||
const discOpen = !$('#discover').classList.contains('hidden');
|
||
const pad = matchMedia('(max-width: 860px)').matches
|
||
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
|
||
: { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] };
|
||
// day scope: only the hotels anchoring THIS day (a multi-city trip has several)
|
||
const stayPts = scope === 'trip' ? stays.map(o => o.at) : staysForDay().map(o => o.at);
|
||
map.fitBounds(L.latLngBounds([dayBase().at, ...stayPts, ...pts]), 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 = []; }
|
||
|
||
// ---------------- 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 <b>${cat}</b> 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 = `<b>${catLabel}</b>${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: `<div class="candpin">${i + 1}</div>`, 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();
|
||
commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`);
|
||
renderAll(flash ? [stop.id] : []);
|
||
return { stop, incumbent: stop.slot === slot && stop.state === 'planned' ? day.stops.find(x => x.slot === slot && x.state === 'alt' && x !== stop) : null };
|
||
}
|
||
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(); commit(`hotel → ${c.name}`); renderAll();
|
||
ai(`Now staying at <b>${c.name}</b> for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700);
|
||
return;
|
||
}
|
||
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: <b>${c.name}</b> 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 <b>${c.name}</b> for ${dayLabel()}${incumbent ? ` — <b>${incumbent.name}</b> 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(); commit(`hotel option: ${c.name}`); renderAll();
|
||
ai(`Keeping <b>${c.name}</b> open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800);
|
||
return;
|
||
}
|
||
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 <b>${c.name}</b> 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 <b>${c.name}</b> 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}: <b>${closest.name}</b> (${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) => renderStopCard(body, s, i, flashIds));
|
||
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);
|
||
}
|
||
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: <b>${cand.name}</b> is your ${s.slot} now${incumbent ? `; <b>${incumbent.name}</b> 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 <b>${cand.name}</b> over its slot — it’s your ${slot} now; <b>${incumbent.name}</b> becomes the backup.`, 500);
|
||
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
|
||
} else {
|
||
day.stops.push(makeStop(cand, slot, 'maybe'));
|
||
rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]);
|
||
ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
|
||
}
|
||
if (disc) closeDiscover();
|
||
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();
|
||
commit(`moved ${moved.name}`);
|
||
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' : g.mode === 'fly' ? '#8a93a6' : '#2e9e5b', weight: 5, opacity: .85, dashArray: g.mode === 'fly' ? '6 6' : undefined };
|
||
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 (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('→ Idea', () => setState(s, 'maybe'));
|
||
mk('→ Backup', () => setState(s, 'alt'));
|
||
} else if (s.state === 'maybe') {
|
||
mk('→ In plan', () => setState(s, 'planned'));
|
||
mk('→ Backup', () => setState(s, 'alt'));
|
||
} else {
|
||
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));
|
||
});
|
||
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 backup in the slot becomes the plan
|
||
}
|
||
rebuildLegs(); renderAll();
|
||
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> becomes the backup.`, 600); }
|
||
}
|
||
s.state = state;
|
||
rebuildLegs(); commit(`${s.name} → ${stateLabel(state)}`); renderAll([s.id]);
|
||
if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt')))
|
||
ai(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400);
|
||
}
|
||
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);
|
||
commit(`${s.name} → ${t.label}`); renderAll();
|
||
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
|
||
}
|
||
const dayLabel = () => M.days.find(d => d.id === day.id).label;
|
||
|
||
// ---------------- time budget (gentle phrasing) ----------------
|
||
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);
|
||
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}`;
|
||
}
|
||
|
||
// ---------------- nudges (issues, but gentle) ----------------
|
||
// Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags.
|
||
const nudgesByDay = deep(M.nudges || {});
|
||
Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false));
|
||
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
|
||
function renderNudges() {
|
||
const p = $('#nudges'); if (!p) return; p.innerHTML = '';
|
||
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;
|
||
if (n.fix.stop && n.fix.dur) { const st = stopById(n.fix.stop); if (st) st.dur = n.fix.dur; }
|
||
commit(`nudge: ${n.fix.label}`);
|
||
renderAll();
|
||
if (n.fix.msg) ai(n.fix.msg, 300);
|
||
};
|
||
d.append(b);
|
||
p.append(d);
|
||
});
|
||
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} <span class="sugg-note">${note}</span>`);
|
||
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, `<b>Comparing ${compareItems.length}</b> — 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;
|
||
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');
|
||
let rt = 0; // debounce token for the router round-trip
|
||
const upd = () => {
|
||
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
|
||
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]);
|
||
const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]);
|
||
leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate
|
||
leg.conf = 1;
|
||
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
|
||
renderBudget();
|
||
const t = ++rt;
|
||
routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], 'foot').then(res => {
|
||
if (t !== rt || !l3) return;
|
||
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); }
|
||
box.innerHTML = 'leg time: ' + (res ? '' : 'est. ');
|
||
box.append(tchip(leg.dur, leg.conf));
|
||
renderBudget();
|
||
});
|
||
};
|
||
[m1, m2, m3].forEach(m => m.on('drag', upd));
|
||
$('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`;
|
||
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);
|
||
commit('edited a leg route');
|
||
renderAll();
|
||
}, 250);
|
||
}
|
||
function closeL3() {
|
||
if (l3) { map.removeLayer(l3.layer); l3 = null; }
|
||
$('#l3-editor').classList.add('hidden');
|
||
}
|
||
$('#l3-close').onclick = closeL3;
|
||
|
||
// ---------------- version history (every edit = a version) ----------------
|
||
// Both editors (user + chat) funnel through commit(): the snapshot captures
|
||
// days + stays, so undo/redo/revert is a single code path.
|
||
let version = 0;
|
||
let history = []; // [{v, label, at, days, stays}]
|
||
let hIdx = -1; // pointer into history
|
||
const commit = (label) => {
|
||
history = history.slice(0, hIdx + 1); // drop any redo branch
|
||
history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) });
|
||
hIdx = history.length - 1;
|
||
if (history.length > 60) { history.shift(); hIdx--; }
|
||
renderVersionPill();
|
||
if (typeof persistTrip === 'function') persistTrip();
|
||
};
|
||
function restore(idx, silent) {
|
||
const s = history[idx]; if (!s) return;
|
||
hIdx = idx;
|
||
Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); });
|
||
stays = deep(s.stays);
|
||
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
|
||
closeDiscover(); closeL3();
|
||
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
|
||
renderHotelMks(); renderBaseChip();
|
||
renderAll();
|
||
renderVersionPill();
|
||
if (!silent) ai(`↩ Reverted to <b>v${s.v}</b> — ${s.label}. Later edits are gone from the branch; redo can’t bring them back.`, 400);
|
||
}
|
||
function undo() { if (hIdx > 0) restore(hIdx - 1); }
|
||
function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); }
|
||
function renderVersionPill() {
|
||
const elV = $('#verlabel'); if (!elV) return;
|
||
elV.textContent = 'v' + version;
|
||
$('#ver-undo').classList.toggle('off', hIdx <= 0);
|
||
$('#ver-redo').classList.toggle('off', hIdx >= history.length - 1);
|
||
}
|
||
// what changed between that version and the current plan (stop-level, per day)
|
||
function diffVsCurrent(snap) {
|
||
const parts = [];
|
||
M.days.forEach(md => {
|
||
const A = (snap.days[md.id] || {}).stops || [];
|
||
const B = (days[md.id] || {}).stops || [];
|
||
const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id));
|
||
const added = B.filter(x => !aSet.has(x.id)).map(x => x.name);
|
||
const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name);
|
||
if (added.length || removed.length)
|
||
parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' −' + removed.join(', −') : ''}`);
|
||
});
|
||
return parts.slice(0, 3);
|
||
}
|
||
function toggleVerList() {
|
||
const p = $('#verlist');
|
||
if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; }
|
||
p.innerHTML = '';
|
||
const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse();
|
||
rows.forEach(({ s, i }) => {
|
||
const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : ''));
|
||
const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
const d = diffVsCurrent(s);
|
||
r.append(el('div', 'vr-main', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
|
||
if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · ')));
|
||
r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); };
|
||
p.append(r);
|
||
});
|
||
p.classList.remove('hidden');
|
||
}
|
||
$('#ver-undo').onclick = undo;
|
||
$('#ver-redo').onclick = redo;
|
||
$('#verlabel').onclick = toggleVerList;
|
||
document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); });
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
|
||
});
|
||
|
||
// ---------------- day tabs ----------------
|
||
function buildDayTabs() {
|
||
const tabs = $('#daytabs'); tabs.innerHTML = '';
|
||
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();
|
||
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
|
||
renderAll();
|
||
}
|
||
let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview)
|
||
function renderAll(flashIds = []) {
|
||
if (scope === 'trip') renderTripRail();
|
||
else renderRail(flashIds);
|
||
renderMap();
|
||
}
|
||
function setScope(sc) {
|
||
scope = sc;
|
||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc));
|
||
$('#daystrip').classList.toggle('hidden', sc === 'trip');
|
||
$('#rail-foot').classList.toggle('hidden', sc === 'trip');
|
||
renderAll();
|
||
}
|
||
$('#scope-day').onclick = () => setScope('day');
|
||
$('#scope-trip').onclick = () => setScope('trip');
|
||
|
||
// ---------------- L0 trip overview (macro scale) ----------------
|
||
function dayStats(d) {
|
||
const planned = d.stops.filter(s => s.state === 'planned');
|
||
const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0)
|
||
+ (planned.length && !d.isDeparture ? returnDur(d) : 0);
|
||
return { count: planned.length, dur, end: layout(d),
|
||
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
|
||
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
|
||
}
|
||
function renderTripRail() {
|
||
const body = $('#rail-body'); body.innerHTML = '';
|
||
const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0);
|
||
const head = el('div', 'trip-head');
|
||
head.append(el('div', 'trip-title', M.trip.title));
|
||
head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`));
|
||
body.append(head);
|
||
M.days.forEach(md => {
|
||
const d = days[md.id];
|
||
const st = dayStats(d);
|
||
const pct = Math.min(100, st.dur / st.cap * 100);
|
||
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
|
||
const top = el('div', 'td-top');
|
||
top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : '')));
|
||
top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
|
||
card.append(top);
|
||
const bar = el('div', 'td-bar');
|
||
const fill = el('div', 'td-fill');
|
||
fill.style.width = pct + '%';
|
||
fill.classList.toggle('warn', pct > 92);
|
||
bar.append(fill);
|
||
card.append(bar);
|
||
const meta = el('div', 'td-meta');
|
||
meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`));
|
||
meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`));
|
||
const cur = (d.stops.find(x => x.price)?.price)?.cur || '';
|
||
if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`));
|
||
card.append(meta);
|
||
const names = d.stops.filter(s => s.state === 'planned').map(s => s.name);
|
||
card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : '')));
|
||
card.onclick = () => { setDay(md.id); setScope('day'); };
|
||
body.append(card);
|
||
});
|
||
body.append(offlineCard());
|
||
}
|
||
|
||
// ---------------- on-trip offline bundle (simulated) ----------------
|
||
let offlineState = 'idle'; // idle | working | ready
|
||
function offlineCard() {
|
||
const c = el('div', 'offline-card');
|
||
if (offlineState === 'ready') {
|
||
c.append(el('div', 'oc-title', '📦 Offline bundle ready'));
|
||
c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`));
|
||
} else if (offlineState === 'working') {
|
||
c.append(el('div', 'oc-title', '📦 Preparing offline bundle…'));
|
||
c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…'));
|
||
} else {
|
||
c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle'));
|
||
c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.'));
|
||
const b = el('button', 'btn ghost small', 'Prepare for trip');
|
||
b.onclick = () => prepareOffline();
|
||
c.append(b);
|
||
}
|
||
return c;
|
||
}
|
||
function prepareOffline() {
|
||
offlineState = 'working';
|
||
renderTripRail();
|
||
const steps = [
|
||
['itinerary ✓ → POI details…', 500],
|
||
['POI details (hours, tags) ✓ → vector tiles…', 1200],
|
||
[`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100],
|
||
];
|
||
steps.forEach(([txt, t]) => setTimeout(() => {
|
||
if (offlineState !== 'working') return;
|
||
const p = document.querySelector('.oc-progress');
|
||
if (p) p.textContent = txt;
|
||
}, t));
|
||
setTimeout(() => {
|
||
offlineState = 'ready';
|
||
const badge = $('#offline-badge');
|
||
if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; }
|
||
if (scope === 'trip') renderTripRail();
|
||
ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900);
|
||
}, 2500);
|
||
}
|
||
|
||
// ---------------- chat ----------------
|
||
function msg(role, html) {
|
||
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 (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)) { 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” → 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|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 <b>⚖</b> 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;
|
||
commit('start day 30 min later');
|
||
renderAll(day.stops.map(x => x.id));
|
||
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`);
|
||
} else if (s.includes('lunch') || s.includes('longer')) {
|
||
const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
|
||
const st = n ? stopById(n.fix.stop) : null;
|
||
if (n && !n.fixed && st) {
|
||
n.fixed = true; st.dur = n.fix.dur;
|
||
commit(`nudge: ${n.fix.label}`);
|
||
rebuildLegs(day);
|
||
renderAll([st.id]);
|
||
ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`);
|
||
} else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`);
|
||
} else if (llmOn) {
|
||
askLLM(v); // free-form → LLM with the live plan as context
|
||
} else {
|
||
ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop card’s <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
|
||
}
|
||
}
|
||
$('#chat-send').onclick = chatSend;
|
||
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
|
||
|
||
// ---------------- landing → parse → plan ----------------
|
||
// parsed-booking preview, derived from the active dataset (city-aware)
|
||
function sampleRows() {
|
||
const hb = M.trip.bookings.find(b => b.type === 'hotel');
|
||
const tb = M.trip.bookings.find(b => b.type === 'train');
|
||
const rows = [
|
||
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
|
||
['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'],
|
||
['dates', hb.dates + ' ' + (M.trip.places[0].dates[0] || '').slice(0, 4), 'booked stay', 'hi'],
|
||
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'],
|
||
['ref', `Confirmation ${hb.ref}`, 'read from footer image — worth a double-check', 'mid'],
|
||
];
|
||
if (tb) rows.push(['train', tb.name, `${tb.from} → ${tb.to} · dep ${tb.depart}`, 'hi']);
|
||
return rows;
|
||
}
|
||
function showParse() {
|
||
const t = $('#parse-table'); t.innerHTML = '';
|
||
sampleRows().forEach(([k, v, note, c]) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `<td>${k}</td><td><b>${v}</b><span class="conf ${c}">${c === 'hi' ? 'confirmed' : 'verify'}</span><div style="font-size:11px;color:var(--ink2)">${note}</div></td>`;
|
||
t.append(tr);
|
||
});
|
||
$('#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);
|
||
|
||
// ---------------- multi-trip: registry, per-trip persistence, switching --------
|
||
// All trips live in window.TRIPS. Each trip's working state (days/stays +
|
||
// version history) is persisted to localStorage under its own key, so several
|
||
// trips can be worked on at once and each resumes exactly where it stopped.
|
||
let tripId = window.MOCK_CITY;
|
||
const tripStore = {
|
||
key: id => 'tripstate.v1.' + id,
|
||
save(id, data) { try { localStorage.setItem(this.key(id), JSON.stringify(data)); } catch {} },
|
||
load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } },
|
||
};
|
||
let persistT = 0;
|
||
function persistTrip() {
|
||
clearTimeout(persistT);
|
||
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
|
||
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist }), 250);
|
||
}
|
||
function renderTripMenu() {
|
||
$('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
|
||
const menu = $('#trip-menu');
|
||
menu.innerHTML = '';
|
||
menu.append(el('div', 'ti-head', 'Trips — each keeps its own plan'));
|
||
Object.values(window.TRIPS).forEach(t => {
|
||
const saved = tripStore.load(t.id);
|
||
const b = el('button', 'trip-item' + (t.id === tripId ? ' cur' : ''));
|
||
b.innerHTML = `<span class="ti-name">${t.trip.title}</span><span class="ti-sub">${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}</span>`;
|
||
b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); };
|
||
menu.append(b);
|
||
});
|
||
const more = el('button', 'trip-item');
|
||
more.innerHTML = `<span class="ti-name">+ New trip…</span><span class="ti-sub">start from a booking on the landing screen</span>`;
|
||
more.onclick = () => location.href = location.pathname;
|
||
menu.append(more);
|
||
}
|
||
function enterTrip(id) {
|
||
tripId = id;
|
||
M = window.TRIPS[id];
|
||
days = {}; M.days.forEach(d => days[d.id] = deep(d));
|
||
curDay = 'D1'; day = days[curDay];
|
||
stays = deep(M.trip.stays);
|
||
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
|
||
// resume this trip's saved state (edits + version history), if any
|
||
const saved = tripStore.load(id);
|
||
if (saved) { days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx; }
|
||
// reset per-trip UI state
|
||
compareItems = [];
|
||
closeDiscover(); closeL3();
|
||
scope = 'day';
|
||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));
|
||
$('#daystrip').classList.remove('hidden');
|
||
$('#rail-foot').classList.remove('hidden');
|
||
offlineState = 'idle';
|
||
const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); }
|
||
$('#chat-body').innerHTML = '';
|
||
scriptBusy = false;
|
||
legToken++; // drop in-flight route enrichment from the previous trip
|
||
map.setView(M.trip.places[0].center, 14);
|
||
renderTripMenu();
|
||
buildDayTabs();
|
||
renderHotelMks(); renderBaseChip();
|
||
// this trip may live on a different OSRM extract — re-check before routing
|
||
Promise.all([checkRouter(), checkLLM()]).then(() => {
|
||
const b = $('#offline-badge');
|
||
if (b && offlineState !== 'ready') b.textContent = badgeText();
|
||
if (routerOn) enrichLegs(day);
|
||
});
|
||
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
|
||
renderAll();
|
||
renderVersionPill();
|
||
if (!saved) {
|
||
commit('initial plan');
|
||
script1();
|
||
} else {
|
||
ai(`Welcome back to <b>${M.trip.title}</b> — you’re at <b>v${version}</b> (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600);
|
||
}
|
||
}
|
||
function switchTrip(id) {
|
||
if (id === tripId) return;
|
||
persistTrip();
|
||
$('#trip-menu').classList.add('hidden');
|
||
enterTrip(id);
|
||
setTimeout(() => map.invalidateSize(), 80);
|
||
}
|
||
$('#crumb').onclick = e => { e.stopPropagation(); $('#trip-menu').classList.toggle('hidden'); };
|
||
document.addEventListener('click', e => { if (!e.target.closest('#trip-picker')) $('#trip-menu').classList.add('hidden'); });
|
||
|
||
function startApp() {
|
||
$('#parse-modal').classList.add('hidden');
|
||
$('#landing').classList.add('hidden');
|
||
$('#topbar').classList.remove('hidden');
|
||
$('#app').classList.remove('hidden');
|
||
enterTrip(tripId);
|
||
}
|
||
|
||
// ---------------- 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'} · ⚖ to compare hotels</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)));
|
||
h.onclick = () => openDiscover('hotel');
|
||
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', '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]);
|
||
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();
|
||
commit(`${copy.name} → ${stateLabel(state)}`);
|
||
renderAll([id]);
|
||
return state;
|
||
}
|
||
|
||
function script1() {
|
||
setTimeout(() => {
|
||
scriptBusy = true;
|
||
{ const b0 = dayBase(); ai(`Anchored your stay — <b>${b0.name}, ${stayRange(b0)}</b>; bookings are pinned as hard anchors. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?`, 900); }
|
||
setTimeout(() => {
|
||
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
|
||
const chips = el('div', 'chips');
|
||
M.vibes.forEach(v => {
|
||
const c = el('button', 'chip', v.label);
|
||
c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(v.label); };
|
||
chips.append(c);
|
||
});
|
||
m.append(chips);
|
||
}, 1800);
|
||
}, 300);
|
||
}
|
||
function script2(vibe) {
|
||
scriptBusy = true;
|
||
ai(`Noted — ${vibe || M.vibes[0].label}. A few ideas for your first day:`, 900);
|
||
setTimeout(() => {
|
||
const m = msg('ai', '');
|
||
const cards = el('div', 'cards');
|
||
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', '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 };
|
||
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>backup</b> for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
|
||
else ai(`Added <b>${s.name}</b> — I’ll sequence the day once the set is done.`, 500);
|
||
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
|
||
};
|
||
const doMaybe = () => { if (st.done) return; st.done = true;
|
||
card.classList.add('maybe-card');
|
||
finish(['', 'Holding', '', '', 'Holding', '']);
|
||
addStopToDay(s.id, 'maybe');
|
||
ai(`Holding <b>${s.name}</b> 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;
|
||
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(() => {
|
||
rebuildLegs(day);
|
||
renderAll(day.stops.map(s => s.id));
|
||
const st = dayStats(day);
|
||
ai(`Day sequenced — <b>${(st.dur / 60).toFixed(1)} h</b> of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100);
|
||
}, 1400);
|
||
}
|
||
|
||
// ---------------- 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 = () => { showParse(); };
|
||
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
|
||
// ?city= preselects the demo city on the landing screen; the demo chips switch dataset
|
||
(function () {
|
||
const c = new URLSearchParams(location.search).get('city');
|
||
if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name;
|
||
document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => {
|
||
location.href = location.pathname + '?city=' + b.dataset.city;
|
||
});
|
||
})();
|
||
|
||
// drop a candidate onto the plan panel itself → hold it as an idea
|
||
(function () {
|
||
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(); commit(`idea: ${c.name}`); renderAll([c.id]);
|
||
if (disc) closeDiscover();
|
||
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
|
||
});
|
||
})();
|
||
|
||
initMap();
|
||
// router status + leg enrichment happen in enterTrip() (per-trip extract)
|