Rail: real day structure (airport→hotel→day→hotel) + walk/taxi toggle
The left pane used to show one global hotel chip pinned to the top for the whole trip. Now the itinerary reads like the day actually happens: - Arrival day starts with an airport card (✈️ Landed — CTG, arr 10:20, the flight you booked) then a ground-transfer leg to the hotel ("drop bags"), then the activities with travel legs between them, then a ride back to the hotel and a per-night hotel card (🏨 … tonight, the right hotel + dates). - Departure day ends with a ride to the airport and the flight. Fixed a bug where apply_flight_anchors kept the dataset's placeholder flight (e.g. AV 1352→BOG) instead of replacing it with the user's real ticket (CM 875→LGA). - "Getting around" toggle (🚶 Walk / 🚗 Taxi) where the base-chip used to sit; every travel leg re-estimates instantly (26 km/h car vs 4.6 walk), per-trip and persisted. - healTravelAnchors() rebuilds the arrival/departure airport cards + transfers on load for plans saved before day objects carried them. Verified: CDP suites — 12/12 structural checks (incl. reload persistence), 7/7 date re-dating, 4/4 no-JS-error smoke on the other two trips.
This commit is contained in:
parent
54f049eecc
commit
733843d096
203
mock/app.js
203
mock/app.js
|
|
@ -20,6 +20,13 @@ const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math
|
|||
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)));
|
||||
// how the traveler gets between stops: on foot, or by taxi. Affects every
|
||||
// on-ground leg's estimate + routing profile (the router is asked for the
|
||||
// matching OSRM profile; out of coverage → straight-line estimate at the
|
||||
// mode's average speed). Airport↔hotel transfers are always by car.
|
||||
let travelMode = 'foot'; // 'foot' | 'car' — loaded per-app in enterTrip
|
||||
const legSpeedKmh = m => (m === 'car' ? 26 : WALK_KMH); // urban taxi incl. traffic/parking
|
||||
const estLegMin = (m, a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (legSpeedKmh(m) / 60)));
|
||||
|
||||
// ---------------- route client (local OSRM via the /route proxy) ----------
|
||||
// Legs start life as straight-line estimates (conf 1) and are upgraded in the
|
||||
|
|
@ -191,6 +198,32 @@ function redateStaysAndPlaces() {
|
|||
if (st) b.dates = stayRange(st);
|
||||
});
|
||||
}
|
||||
// re-derive the arrival/departure anchors for plans saved before the day
|
||||
// objects carried them: the flights are in the bookings, so the airport card +
|
||||
// ground transfer can be rebuilt on load (idempotent — fresh plans are a no-op)
|
||||
function healTravelAnchors() {
|
||||
const flights = (M.trip.bookings || []).filter(b => b.type === 'flight');
|
||||
const ids = Object.keys(days).sort();
|
||||
if (!ids.length || !flights.length) return;
|
||||
const first = days[ids[0]], last = days[ids[ids.length - 1]];
|
||||
const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at);
|
||||
const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at);
|
||||
const codeOf = s => (String(s || '').match(/\b([A-Z]{3})\b/) || [])[1] || '';
|
||||
const arr = flights.find(b => b.ref === 'arr-booking') || flights.find(b => b.stations && !b.stations.from && b.stations.to);
|
||||
const dep = flights.find(b => b.ref === 'dep-booking') || flights.find(b => b.stations && b.stations.from && !b.stations.to);
|
||||
if (arr && !first.arrivalFlight) {
|
||||
const ap = arr.stations && arr.stations.to;
|
||||
first.isArrival = true;
|
||||
first.arrivalFlight = { code: codeOf(arr.to) || codeOf(arr.name) || arr.to || 'airport', at: ap || null, time: arr.arrive || '', flight: String(arr.name || '').split(' · ')[0], from: arr.from || '', to: arr.to || '' };
|
||||
first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60 };
|
||||
}
|
||||
if (dep && !last.departureFlight) {
|
||||
const ap = dep.stations && dep.stations.from;
|
||||
last.isDeparture = true;
|
||||
last.departureFlight = { code: codeOf(dep.from) || codeOf(dep.name) || dep.from || 'airport', at: ap || null, time: dep.depart || '', flight: String(dep.name || '').split(' · ')[0], from: dep.from || '', to: dep.to || '' };
|
||||
last.transferOut = { at: ap || null, hotelAt: hotelLast, dur: (ap && hotelLast) ? (driveMin({ at: ap }, hotelLast) || 60) : 60 };
|
||||
}
|
||||
}
|
||||
function rangeStr(a, b) {
|
||||
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; };
|
||||
return a && b ? (a === b ? f(a) : f(a) + '–' + f(b)) : '';
|
||||
|
|
@ -342,6 +375,15 @@ const TOOLS = {
|
|||
const arrTransfer = driveMin(arrAp, hotelFirst) != null ? driveMin(arrAp, hotelFirst) : 60; // land → hotel
|
||||
const depTransfer = driveMin(depAp, hotelLast) != null ? driveMin(depAp, hotelLast) : 60; // hotel → airport
|
||||
const removed = [], notes = [];
|
||||
// remember the anchors ON the day objects so the rail can render the
|
||||
// airport card + the ground transfer in the right place. The flights are
|
||||
// bookings (hard anchors), not stops — the day starts/ends with them.
|
||||
first.isArrival = true;
|
||||
first.arrivalFlight = { code: arrAp ? arrAp.code : (arr.airport || arr.city || 'airport'), at: arrAp ? arrAp.at : null, time: arr.time, flight: arr.flight || '', from: arr.from || '', to: arr.city || '' };
|
||||
first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer };
|
||||
last.isDeparture = true;
|
||||
last.departureFlight = { code: depAp ? depAp.code : (dep.airport || dep.city || 'airport'), at: depAp ? depAp.at : null, time: dep.time, flight: dep.flight || '', from: dep.city || '', to: dep.to || '' };
|
||||
last.transferOut = { at: depAp ? depAp.at : null, hotelAt: hotelLast, dur: depTransfer };
|
||||
|
||||
// 1) re-date across the real [arrival, departure] window — spread the plan's
|
||||
// days evenly between the booked first and last date (contiguous when the
|
||||
|
|
@ -394,16 +436,21 @@ const TOOLS = {
|
|||
first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60));
|
||||
trimDayTo(first, first.startMin + first.wakingHours * 60, removed);
|
||||
|
||||
// 4) departure day: keep only check-out + the flight; size the flight block
|
||||
// so the day lands exactly on the booked departure time
|
||||
// 4) departure day: keep only check-out, and REPLACE the (placeholder)
|
||||
// transit stop with the user's actual departure flight; size the flight
|
||||
// block so the day lands exactly on the booked departure time
|
||||
last.isDeparture = true;
|
||||
last.startMin = Math.min(last.startMin || 540, 8 * 60);
|
||||
last.stops.forEach(s => { if (s.slot !== 'transit' && s.slot !== 'breakfast') removed.push(s.name); });
|
||||
last.stops = last.stops.filter(s => s.slot === 'transit' || s.slot === 'breakfast');
|
||||
if (!last.stops.some(s => s.slot === 'breakfast'))
|
||||
last.stops.unshift({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 });
|
||||
if (!last.stops.some(s => s.slot === 'transit'))
|
||||
last.stops.push({ id: 'FLYOUT', name: 'Flight out — ' + (dep.airport || 'airport'), kind: 'transit', slot: 'transit', state: 'planned', at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3 });
|
||||
last.stops.forEach(s => { if (s.slot !== 'breakfast') removed.push(s.name); });
|
||||
last.stops = last.stops.filter(s => s.slot === 'breakfast');
|
||||
if (!last.stops.length)
|
||||
last.stops.push({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 });
|
||||
last.stops.push({
|
||||
id: 'FLYOUT', name: 'Flight out — ' + (depAp ? depAp.code : (dep.airport || 'airport')), kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3,
|
||||
transit: { name: dep.flight || 'flight', to: dep.to || (depAp ? depAp.code : (dep.city || 'airport')), depart: dep.time, arriveBy: fmt(Math.max(0, depMin - 60)), buffer: '1 h before departure' },
|
||||
price: null,
|
||||
});
|
||||
rebuildLegs(last);
|
||||
let pre = last.startMin;
|
||||
for (let i = 0; i < last.stops.length; i++) {
|
||||
|
|
@ -422,7 +469,7 @@ const TOOLS = {
|
|||
persistTrip(true);
|
||||
renderAll();
|
||||
buildDayTabs();
|
||||
renderBaseChip();
|
||||
renderModeToggle();
|
||||
renderTripMenu();
|
||||
renderVersionPill();
|
||||
const fs2 = dayStats(first), ls2 = dayStats(last);
|
||||
|
|
@ -578,11 +625,11 @@ async function enrichLegs(d = day) {
|
|||
renderAll();
|
||||
}
|
||||
}
|
||||
// return-to-hotel leg when a single stay anchors the day
|
||||
// return-to-hotel leg when a single stay anchors the day (uses the travel mode)
|
||||
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');
|
||||
const res = await routePts([last.at, dayBase(d).at], travelMode);
|
||||
if (token !== legToken) return;
|
||||
if (res) { d.retRouted = res; renderAll(); }
|
||||
}
|
||||
|
|
@ -687,26 +734,31 @@ function returnDur(d = day) {
|
|||
// 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)));
|
||||
// worst case across every candidate stay for these nights (equals the real leg when one)
|
||||
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 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) {
|
||||
function rebuildLegs(d = day, enrich = true) {
|
||||
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 });
|
||||
d.legs.push({ mode: travelMode, dur: travelMode === 'car' ? 9 : 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') {
|
||||
// ground transfer INTO the departure flight (hotel → airport) — always by car
|
||||
if (b.kind === 'transit' && d.isDeparture && d.transferOut) {
|
||||
d.legs.push({ mode: 'car', dur: d.transferOut.dur, conf: 2, toAirport: true });
|
||||
continue;
|
||||
}
|
||||
const mm = (M.multimodal || []).find(x => x.into === b.id)
|
||||
|| (M.multimodal || []).find(x => x.into === a.id);
|
||||
d.legs.push(mm
|
||||
|
|
@ -718,14 +770,13 @@ function rebuildLegs(d = day) {
|
|||
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 });
|
||||
// straight-line estimate at the current travel mode's speed, until the
|
||||
// router upgrades it (or proves out of coverage)
|
||||
d.legs.push({ mode: travelMode, dur: estLegMin(travelMode, a.at, b.at), conf: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
enrichLegs(d);
|
||||
if (enrich) enrichLegs(d);
|
||||
}
|
||||
|
||||
// ---------------- vague / region placeholder stops ----------------
|
||||
|
|
@ -1036,7 +1087,7 @@ function chooseCand(c, slot) {
|
|||
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();
|
||||
renderHotelMks(); renderModeToggle();
|
||||
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;
|
||||
|
|
@ -1059,7 +1110,7 @@ function holdIdea(c, slot) {
|
|||
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();
|
||||
renderHotelMks(); renderModeToggle();
|
||||
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;
|
||||
|
|
@ -1119,10 +1170,48 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc)
|
|||
function renderRail(flashIds = []) {
|
||||
layout(); // assign start times before the cards render them
|
||||
const body = $('#rail-body'); body.innerHTML = '';
|
||||
// a travel day OPENS with the arrival: airport card, then the ride in to drop bags
|
||||
if (day.isArrival && day.arrivalFlight) renderArrivalHeader(body);
|
||||
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
|
||||
// every day but the departure closes back at the hotel — the ride back, then
|
||||
// the hotel card for the night, in the timeline where it belongs
|
||||
if (day.stops.length && !day.isDeparture) {
|
||||
renderReturnRow(body);
|
||||
renderHotelCard(body);
|
||||
}
|
||||
renderStrip(); renderBudget(); renderNudges(); renderSugg();
|
||||
}
|
||||
|
||||
// arrival-day header: the flight you landed on, then the car to the hotel
|
||||
function renderArrivalHeader(body) {
|
||||
const f = day.arrivalFlight; if (!f) return;
|
||||
const c = el('div', 'stop-card struct-card arr-card');
|
||||
const top = el('div', 'sc-top');
|
||||
top.append(el('span', 'leg-ico', '✈️'));
|
||||
top.append(el('span', 'sc-name', 'Landed — ' + (f.code || f.to || 'airport')));
|
||||
top.append(el('span', 'sc-times', 'arr ' + f.time));
|
||||
c.append(top);
|
||||
const meta = el('div', 'sc-meta');
|
||||
meta.append(el('span', 'tc t-sched', 'booked'));
|
||||
if (f.flight) meta.append(el('span', 'pchip', f.flight));
|
||||
if (f.from) meta.append(el('span', 'leg-sub', 'from ' + f.from));
|
||||
c.append(meta);
|
||||
if (f.at) c.addEventListener('click', () => focusPoint(f.at));
|
||||
body.append(c);
|
||||
// the ride to the hotel to drop bags — always by car, before the first stop
|
||||
const t = day.transferIn || {};
|
||||
const tr = el('div', 'leg-row struct-leg');
|
||||
tr.append(el('span', 'leg-ico', '🚗'));
|
||||
tr.append(document.createTextNode('to ' + (dayBase() ? dayBase().name : 'hotel') + ' — drop bags'));
|
||||
tr.append(document.createTextNode(' '));
|
||||
tr.append(tchip(t.dur || 30, 2));
|
||||
body.append(tr);
|
||||
}
|
||||
|
||||
// the ride back to the hotel at the end of a (non-departure) day
|
||||
function renderReturnRow(body) {
|
||||
const multi = staysForDay().length > 1;
|
||||
const ret = el('div', 'leg-row', `<span class="leg-ico">🏨</span><span>back to hotel</span> `);
|
||||
const ret = el('div', 'leg-row', `<span class="leg-ico">${MODE_ICO[travelMode] || '•'}</span><span>back to ${dayBase() ? dayBase().name : '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 };
|
||||
|
|
@ -1130,8 +1219,27 @@ function renderRail(flashIds = []) {
|
|||
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();
|
||||
}
|
||||
|
||||
// the hotel for tonight — in the timeline, after the ride back
|
||||
function renderHotelCard(body) {
|
||||
const b = dayBase(); if (!b) return;
|
||||
const n = staysForDay().length;
|
||||
const c = el('div', 'stop-card struct-card hotel-card');
|
||||
const top = el('div', 'sc-top');
|
||||
top.append(el('span', 'leg-ico', '🏨'));
|
||||
top.append(el('span', 'sc-name', b.name));
|
||||
top.append(el('span', 'sc-times', 'tonight'));
|
||||
c.append(top);
|
||||
const meta = el('div', 'sc-meta');
|
||||
meta.append(el('span', 'tc t-sched', b.state === 'booked' ? 'booked' : 'base'));
|
||||
meta.append(el('span', 'leg-sub', stayRange(b)));
|
||||
if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`));
|
||||
c.append(meta);
|
||||
c.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true)));
|
||||
c.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false)));
|
||||
c.addEventListener('click', () => openDiscover('hotel'));
|
||||
body.append(c);
|
||||
}
|
||||
const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' };
|
||||
// inline alternatives for a planned stop: same-slot candidates, not already in the day
|
||||
|
|
@ -1154,7 +1262,7 @@ function renderStopCard(body, s, i, flashIds) {
|
|||
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}`));
|
||||
if (s.price) 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));
|
||||
|
|
@ -1288,6 +1396,7 @@ function renderStopCard(body, s, i, flashIds) {
|
|||
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.toAirport) lr.append(document.createTextNode('to ' + ((day.departureFlight && (day.departureFlight.code || day.departureFlight.from)) || 'airport') + ' '));
|
||||
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(' '));
|
||||
|
|
@ -1680,7 +1789,7 @@ function restore(idx, silent) {
|
|||
redateStaysAndPlaces(); // old-era snapshots may hold inconsistent pairs
|
||||
closeDiscover(); closeL3();
|
||||
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
|
||||
renderHotelMks(); renderBaseChip();
|
||||
renderHotelMks(); renderModeToggle();
|
||||
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);
|
||||
|
|
@ -2084,6 +2193,8 @@ function enterTrip(id) {
|
|||
M = window.TRIPS[id];
|
||||
days = {}; M.days.forEach(d => days[d.id] = deep(d));
|
||||
curDay = 'D1'; day = days[curDay];
|
||||
// travel mode (on foot vs taxi) is an app-level preference, remembered
|
||||
try { const tm = localStorage.getItem('trips.travemode'); if (tm === 'car' || tm === 'foot') travelMode = tm; } catch {}
|
||||
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
|
||||
|
|
@ -2096,6 +2207,7 @@ function enterTrip(id) {
|
|||
// but not the stays (hotel nights then no longer "covered" any day);
|
||||
// idempotent — a consistent plan is a no-op
|
||||
redateStaysAndPlaces();
|
||||
healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers
|
||||
persistTrip();
|
||||
}
|
||||
tripDocs = {}; // rehydrated from the server store below
|
||||
|
|
@ -2115,7 +2227,7 @@ function enterTrip(id) {
|
|||
map.setView(M.trip.places[0].center, 14);
|
||||
renderTripMenu();
|
||||
buildDayTabs();
|
||||
renderHotelMks(); renderBaseChip();
|
||||
renderHotelMks(); renderModeToggle();
|
||||
renderDocChips();
|
||||
// this trip may live on a different OSRM extract — re-check before routing
|
||||
Promise.all([checkRouter(), checkLLM()]).then(() => {
|
||||
|
|
@ -2152,16 +2264,31 @@ function startApp() {
|
|||
}
|
||||
|
||||
// ---------------- 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);
|
||||
// the travel-mode toggle: on foot vs taxi between stops (the hotel itself now
|
||||
// lives in the day timeline as the "tonight" card, not in this block)
|
||||
function renderModeToggle() {
|
||||
const tb = $('#transit-block'); if (!tb) return; tb.innerHTML = '';
|
||||
const wrap = el('div', 'mode-toggle');
|
||||
wrap.append(el('span', 'mode-label', 'getting around'));
|
||||
[['foot', '🚶 Walk'], ['car', '🚗 Taxi']].forEach(([m, lab]) => {
|
||||
const b = el('button', 'mode-btn' + (travelMode === m ? ' on' : ''), lab);
|
||||
b.title = m === 'foot' ? 'On foot between stops' : 'By taxi between stops';
|
||||
b.onclick = () => setTravelMode(m);
|
||||
wrap.append(b);
|
||||
});
|
||||
tb.append(wrap);
|
||||
}
|
||||
function setTravelMode(m) {
|
||||
if (m === travelMode) return;
|
||||
travelMode = m;
|
||||
try { localStorage.setItem('trips.travemode', m); } catch {}
|
||||
// re-estimate every day's on-ground legs at the new speed. Enrichment is not
|
||||
// queued here — one fetch per day would cancel the token out — only the day
|
||||
// you're looking at is re-routed in the background
|
||||
M.days.forEach(md => { const d = days[md.id]; if (d) rebuildLegs(d, false); });
|
||||
renderModeToggle();
|
||||
renderAll();
|
||||
if (day) enrichLegs(day);
|
||||
}
|
||||
|
||||
function openDetail(s, actions) {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,26 @@ button { font: inherit; }
|
|||
.leg-ico { font-size: 13px; }
|
||||
.leg-sub { font-size: 11px; color: var(--ink2); opacity: .8; }
|
||||
.leg-row.dim { opacity: .55; }
|
||||
.leg-row.struct-leg { cursor: default; }
|
||||
.leg-row.struct-leg:hover { background: transparent; }
|
||||
|
||||
/* travel-mode toggle (on foot vs taxi) — sits where the old base-chip did */
|
||||
.mode-toggle { display: flex; align-items: center; gap: 6px; }
|
||||
.mode-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--ink2);
|
||||
margin-right: 2px; white-space: nowrap; }
|
||||
.mode-btn { flex: 1; border: 1px solid var(--line); background: var(--card); color: var(--ink2);
|
||||
border-radius: 9px; padding: 6px 8px; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s; }
|
||||
.mode-btn:hover { border-color: #c9cfda; }
|
||||
.mode-btn.on { background: var(--ink); border-color: var(--ink); color: #fff; }
|
||||
|
||||
/* structural timeline cards: the airport you landed on + the hotel for the
|
||||
night — rendered in the day timeline, not as editable stops */
|
||||
.struct-card { border-left: 3px solid #c3cad6; }
|
||||
.struct-card .sc-name { font-size: 13px; }
|
||||
.arr-card { border-left-color: #274b8f; background: #f6f9ff; }
|
||||
.hotel-card { border-left-color: #7a8291; background: #f7f8fa; }
|
||||
.hotel-card .sc-times { font-weight: 600; }
|
||||
|
||||
/* vague / region placeholder stops */
|
||||
.region-note { font-size: 11px; color: var(--ink2); margin-top: 5px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user