Unified leg system: every movement is a first-class leg + fix 4 in-the-sea coordinates

Legs:
- day.firstLeg (hotel -> first stop) is now a first-class leg object exactly
  like retLeg/transferIn: own mode/dur/geometry, same leg-row rendering, same
  click-to-edit L3 card (drag waypoint + walk/taxi toggle), same router +
  sanity guards, same strip/budget/context accounting
- hotel rendered as a timeline NODE on every day: 'arrive HH:MM - drop bags'
  on the arrival day (after the airport->hotel leg row), 'depart HH:MM'
  (startMin minus the out-leg) on ordinary days; the arrival drop-off is a
  stand-alone leg row instead of being embedded in the flight card
- arrival day: the first stop starts at hotel-arrival + out-leg; ordinary
  days keep startMin = time AT the first stop (the out-leg happens before)
- out-leg suppressed when the day doesn't start at the base hotel: travel
  days (city change, first stop > 25km away) and breakfast-at-hotel days
  (< 120m); stale firstLeg objects self-heal
- set_start tool shifts arrLandingMin on arrival days so the whole chain
  (hotel arrival, first stop, ...) follows the new landing time

Coordinates (verified on land via Nominatim + tile coastline scan):
- S14 Castillo San Diego -> Bodeguita promontory [10.4205,-75.5517]
- S16 Malecon de Bocagrande -> seawall [10.43545,-75.539]
- S18 Punta Canoa -> sandbar beach [10.4403,-75.530]
- S19 beachside lunch -> same sandbar [10.4402,-75.5294]
healCoordinates syncs saved plans from data.js on load
This commit is contained in:
Greg Pomerantz 2026-09-11 00:46:08 -04:00
parent 1ba39f7333
commit 6457501520
2 changed files with 224 additions and 57 deletions

View File

@ -214,7 +214,10 @@ function llmContext() {
layout(dd); layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map( 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 : ''})`); s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`); const arrInfo = (dd.isArrival && dd.transferIn && dd.arrLandingMin != null)
? ` — lands ${fmt(dd.arrLandingMin)}, ${dd.transferIn.mode || 'car'} to hotel ${dd.transferIn.dur} min, first stop at ${fmt((dd.stops[0] || { start: dd.startMin }).start)}`
: '';
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'}${arrInfo} — day ends ~${fmt(layout(dd))}`);
} }
return L.join('\n'); return L.join('\n');
} }
@ -320,9 +323,10 @@ function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
// before the traveller had even reached the hotel). // before the traveller had even reached the hotel).
function syncArrivalStart(d) { function syncArrivalStart(d) {
if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return; if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return;
d.startMin = d.arrLandingMin + d.transferIn.dur; d.startMin = d.arrLandingMin + d.transferIn.dur; // time at the HOTEL (bag drop)
const arrEnd = Math.min(ARR_DAY_END, d.startMin + (d.wakingHours || 12) * 60); const eff = d.startMin + firstLegDur(d); // + hotel → first stop = time at the first stop
d.wakingHours = Math.max(2, Math.round((arrEnd - d.startMin) / 60)); const arrEnd = Math.min(ARR_DAY_END, eff + (d.wakingHours || 12) * 60);
d.wakingHours = Math.max(2, Math.round((arrEnd - eff) / 60));
} }
function relabelDay(d, idx, dateStr) { function relabelDay(d, idx, dateStr) {
const [y, mo, da] = String(dateStr).split('-').map(Number); const [y, mo, da] = String(dateStr).split('-').map(Number);
@ -466,13 +470,21 @@ function healTransfers() {
// geometry ends anywhere else than the current hotel point, drop it so it re-routes // geometry ends anywhere else than the current hotel point, drop it so it re-routes
ids.forEach(id => { ids.forEach(id => {
const dd = days[id]; const dd = days[id];
const leg = dd.retLeg;
if (!leg || !leg.geometry || !leg.geometry.length) return;
const h = dd.base && dd.base.at; const h = dd.base && dd.base.at;
if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) { const leg = dd.retLeg;
const lastSt = dd.stops[dd.stops.length - 1]; if (leg && leg.geometry && leg.geometry.length) {
leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur; if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true; const lastSt = dd.stops[dd.stops.length - 1];
leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur;
leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true;
}
}
const fl = dd.firstLeg; // the hotel → first-stop leg gets the same endpoint-drift check
if (fl && fl.geometry && fl.geometry.length) {
const f0 = dd.stops[0];
if (f0 && h && (haversine(fl.geometry[0], h) > 300 || haversine(fl.geometry[fl.geometry.length - 1], f0.at) > 300)) {
fl.dur = estLegMin(fl.mode || travelMode, h, f0.at); fl.conf = 1; fl.geometry = null; delete fl._routedMode; changed = true;
}
} }
}); });
const first = days[ids[0]], last = days[ids[ids.length - 1]]; const first = days[ids[0]], last = days[ids[ids.length - 1]];
@ -708,7 +720,12 @@ const TOOLS = {
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` }; const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' }; const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' }; const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
d.startMin = h * 60 + mn; if (d.isArrival && d.arrLandingMin != null) {
// the arrival day's start is DERIVED (landing → transfer → hotel → first stop);
// moving "start" moves the landing time so the whole chain stays consistent
d.arrLandingMin = h * 60 + mn;
syncArrivalStart(d);
} else d.startMin = h * 60 + mn;
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll(); rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) }; return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
}, },
@ -1018,6 +1035,25 @@ async function enrichLegs(d = day) {
if (d === day) renderAll(); if (d === day) renderAll();
} }
} }
// hotel → first stop: routed like any other leg (same router, same mode, same sanity
// guards) — the arrival day and every ordinary day get a real, editable time for it
if (d.stops.length && staysForDay(d).length === 1 && !stopAtHotel(d)) {
const f0 = d.stops[0];
if (f0.kind !== 'region' && f0.kind !== 'transit') {
const leg = getFirstLeg(d);
if (leg) {
const want = leg.mode;
if (!(leg.conf === 3 && leg.geometry && leg._routedMode === want)) {
const h = dayBase(d).at;
const res = saneRoute(await routePts([h, f0.at], want), [h, f0.at], want);
if (legTokens[key] !== token) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = want; }
else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; }
if (d === day) renderAll();
}
}
}
}
for (let i = 0; i < d.stops.length - 1; i++) { for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1]; const a = d.stops[i], b = d.stops[i + 1];
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue; if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
@ -1063,7 +1099,7 @@ let days = {};
let curDay, day; let curDay, day;
const stopById = id => day.stops.find(s => s.id === id); const stopById = id => day.stops.find(s => s.id === id);
const allStops = id => M.days.flatMap(d => d.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 = [], arrLine = null, arrLines = []; let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [], arrLine = null, arrLines = [], firstLine = null, firstLines = [];
let map, l3 = null, scriptBusy = false; let map, l3 = null, scriptBusy = false;
let dragCand = null; // candidate object currently being dragged (drawer / dock) let dragCand = null; // candidate object currently being dragged (drawer / dock)
let compareItems = []; // items in the comparison dock (max 4) let compareItems = []; // items in the comparison dock (max 4)
@ -1168,6 +1204,39 @@ function getReturnLeg(d = day) {
} }
return d.retLeg; return d.retLeg;
} }
// Hotel → first stop: the OUT-leg of a day, kept as a first-class leg object exactly
// like getReturnLeg (the in-leg), so it has its own mode, duration and route, renders
// as the same leg-row, and is click-to-edit in the same L3 card. null = there is no
// leg to render (no single base for the day, region/transit first stop).
// The hotel → first-stop leg only makes sense when the day actually STARTS at the
// base hotel. A travel day (city change) starts at the PREVIOUS city’s hotel — e.g.
// “Breakfast & check-out” in Cartagena before the flight to Santa Marta, whose stay
// is the Santa Marta hotel — so a hotel→first-stop leg from the new hotel would be a
// 500 km nonsense line. First stops beyond the city radius ⇒ the day starts elsewhere.
const LEG_CITY_KM = 25;
function firstLegApplies(d = day) {
if (d.isArrival && !d.transferIn) return false; // nothing to leave from before the arrival drop-off
if (!d.stops.length || staysForDay(d).length !== 1) return false;
const first = d.stops[0];
if (first.kind === 'region' || first.kind === 'transit') return false;
const b = dayBase(d);
return !!(b && b.at && first.at && haversine(b.at, first.at) <= LEG_CITY_KM * 1000);
}
function getFirstLeg(d = day) {
if (!firstLegApplies(d)) { delete d.firstLeg; return null; } // self-heal a stale leg from an earlier layout
const first = d.stops[0], b = dayBase(d);
if (!d.firstLeg) {
const mode = d.firstMode || travelMode;
d.firstLeg = { mode, dur: estLegMin(mode, b.at, first.at), conf: 1 };
delete d.firstMode;
}
return d.firstLeg;
}
const stopAtHotel = (d = day) => firstLegApplies(d) && haversine(dayBase(d).at, d.stops[0].at) < 120;
function firstLegDur(d = day) {
const leg = getFirstLeg(d);
return (leg && !stopAtHotel(d)) ? leg.dur : 0;
}
function returnDur(d = day) { function returnDur(d = day) {
if (d.isDeparture) return 0; // departure day: the train is the end of the day if (d.isDeparture) return 0; // departure day: the train is the end of the day
const last = d.stops[d.stops.length - 1]; const last = d.stops[d.stops.length - 1];
@ -1180,7 +1249,11 @@ function returnDur(d = day) {
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60))); return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60)));
} }
function layout(d = day) { function layout(d = day) {
let t = d.startMin; // Arrival day: startMin is the time you reach the HOTEL (landing + transfer); the
// hotel → first-stop leg is added on top. Other days: startMin is the time you are
// AT the first stop — the hotel leg happens before it (shown as the "depart HH:MM"
// hotel node), so it never shifts the stop times.
let t = d.startMin + (d.isArrival ? firstLegDur(d) : 0);
d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; }); d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; });
return t + returnDur(d); return t + returnDur(d);
} }
@ -1192,6 +1265,9 @@ function rebuildLegs(d = day, enrich = true) {
delete d.retRouted; delete d.retRouted;
delete d.retLeg; delete d.retLeg;
if (prevRet) d.retMode = prevRet; if (prevRet) d.retMode = prevRet;
const prevFirst = d.firstLeg && (d.firstLeg.mode === 'foot' || d.firstLeg.mode === 'car') ? d.firstLeg.mode : null; // and the out-leg's choice
delete d.firstLeg;
if (prevFirst) d.firstMode = prevFirst;
for (let i = 0; i < d.stops.length - 1; i++) { for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1]; const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined // a vague (region) stop at either end → worst-case leg until refined
@ -1252,7 +1328,7 @@ function renderMap() {
Object.values(markers).forEach(m => map.removeLayer(m)); markers = {}; Object.values(markers).forEach(m => map.removeLayer(m)); markers = {};
Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {}; Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {};
otherLayers.forEach(l => map.removeLayer(l)); otherLayers = []; otherLayers.forEach(l => map.removeLayer(l)); otherLayers = [];
retLine = null; retLines = []; arrLine = null; arrLines = []; retLine = null; retLines = []; arrLine = null; arrLines = []; firstLine = null; firstLines = [];
// every day, faintly — the whole trip is always visible // every day, faintly — the whole trip is always visible
M.days.forEach(d => { M.days.forEach(d => {
const obj = days[d.id]; const obj = days[d.id];
@ -1286,6 +1362,16 @@ function renderMap() {
retLines.push(retLine); retLines.push(retLine);
}); });
} }
const flg = getFirstLeg(obj); // hotel → first stop: drawn like the other legs, coloured by mode
if (pts.length && !obj.isDeparture && flg && !stopAtHotel(obj)) {
const hb = dayBase(obj);
if (hb && hb.at) {
const g = flg.geometry || [hb.at, pts[0]];
firstLine = L.polyline(g, { color: flg.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 4, opacity: .85, dashArray: '5 5' }).addTo(map);
otherLayers.push(firstLine);
firstLines.push(firstLine);
}
}
if (obj.isArrival && obj.transferIn && obj.transferIn.at && obj.transferIn.hotelAt) { // airport → hotel drop-off: a first-class leg, drawn from its own routed geometry if (obj.isArrival && obj.transferIn && obj.transferIn.at && obj.transferIn.hotelAt) { // airport → hotel drop-off: a first-class leg, drawn from its own routed geometry
const t = obj.transferIn; const t = obj.transferIn;
const pts = t.geometry || [t.at, t.hotelAt]; const pts = t.geometry || [t.at, t.hotelAt];
@ -1437,7 +1523,22 @@ function renderStrip() {
track.append(b); track.append(b);
}; };
let cur = t0; let cur = t0;
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free'); const pre = firstLegDur(); // hotel → first stop
if (day.isArrival) {
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'landing + drop-off');
cur = day.startMin;
if (pre) { block(cur, pre, 'leg', '', `to first stop · ${pre} min`); cur += pre; }
} else {
const leaveAt = day.startMin - pre; // the hotel leg is BEFORE startMin
if (leaveAt <= t0) {
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', pre ? `morning free + ${pre}′ from hotel` : 'morning free');
cur = day.startMin;
} else {
if (cur < leaveAt) block(cur, leaveAt - cur, 'gap', '', 'morning free');
if (pre) block(leaveAt, pre, 'leg', '', `leave hotel · ${pre} min`);
cur = day.startMin;
}
}
day.stops.forEach((s, i) => { day.stops.forEach((s, i) => {
if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time'); 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'; const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit';
@ -1703,8 +1804,31 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc)
function renderRail(flashIds = []) { function renderRail(flashIds = []) {
layout(); // assign start times before the cards render them layout(); // assign start times before the cards render them
const body = $('#rail-body'); body.innerHTML = ''; const body = $('#rail-body'); body.innerHTML = '';
// a travel day OPENS with the arrival: airport card, then the ride in to drop bags // every day lives between two hotel nodes: you LEAVE the hotel (or land and drop
if (day.isArrival && day.arrivalFlight) renderArrivalHeader(body); // bags on the travel day), move to the first stop, and come back at the end. Each
// movement is a leg row — identical in style and behaviour to the stop-to-stop ones.
if (day.isArrival && day.arrivalFlight) {
renderArrivalFlightCard(body); // ✈️ landed
const t = day.transferIn;
if (t && t.at && t.hotelAt)
renderGroundLegRow(body, {
leg: t, ico: MODE_ICO[t.mode] || '🚗',
label: `airport → ${t.hotelName || (dayBase() ? dayBase().name : 'hotel')}`,
hover: arrLines, color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f',
title: 'Click to edit — drag the drop-off, walk or taxi', onClick: openArrivalEditor,
});
renderHotelNode(body, 'arrive'); // 🏨 arrive HH:MM · drop bags
} else if (firstLegApplies()) { // travel days (city change) start elsewhere — no node
renderHotelNode(body, 'depart'); // 🏨 depart HH:MM
}
const fl = getFirstLeg();
if (fl && day.stops.length && !stopAtHotel())
renderGroundLegRow(body, {
leg: fl, ico: MODE_ICO[fl.mode] || '🚶',
label: `${dayBase() ? dayBase().name : 'hotel'} → ${day.stops[0].name}`,
hover: firstLines, color: fl.mode === 'foot' ? '#5a7d5a' : '#274b8f',
title: 'Click to edit — drag the route, walk or taxi', onClick: openFirstEditor,
});
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds)); day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
// every day but the departure closes back at the hotel — the ride back, then // 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 // the hotel card for the night, in the timeline where it belongs
@ -1715,8 +1839,9 @@ function renderRail(flashIds = []) {
renderStrip(); renderBudget(); renderNudges(); renderSugg(); renderStrip(); renderBudget(); renderNudges(); renderSugg();
} }
// arrival-day header: the flight you landed on, then the car to the hotel // arrival-day flight card. The airport → hotel drop-off is NOT embedded here: it is a
function renderArrivalHeader(body) { // stand-alone first-class leg row (renderGroundLegRow), exactly like every other leg.
function renderArrivalFlightCard(body) {
const f = day.arrivalFlight; if (!f) return; const f = day.arrivalFlight; if (!f) return;
const c = el('div', 'stop-card struct-card arr-card'); const c = el('div', 'stop-card struct-card arr-card');
const top = el('div', 'sc-top'); const top = el('div', 'sc-top');
@ -1731,42 +1856,83 @@ function renderArrivalHeader(body) {
c.append(meta); c.append(meta);
if (f.at) c.addEventListener('click', () => focusPoint(f.at)); if (f.at) c.addEventListener('click', () => focusPoint(f.at));
body.append(c); body.append(c);
// the ride to the hotel to drop bags — a first-class leg (walk or taxi), before the first stop. }
// Click it to edit the route / toggle walk vs taxi, exactly like any stop-to-stop leg. // every ground leg in the timeline renders through this one function — the arrival
const t = day.transferIn || {}; // drop-off, hotel → first stop, and the return ride. Same row, same chip, same click
const tr = el('div', 'leg-row'); // → the same route card (drag the route, walk/taxi toggle).
tr.append(el('span', 'leg-ico', t.mode === 'foot' ? '🚶' : '🚗')); function renderGroundLegRow(body, o) {
tr.append(document.createTextNode('to ' + (t.hotelName || (dayBase() ? dayBase().name : 'hotel')) + ' — drop bags')); const leg = o.leg;
tr.append(document.createTextNode(' ')); const lr = el('div', 'leg-row' + (o.dim ? ' dim' : ''));
tr.append(tchip(t.dur || 30, t.conf || 2)); lr.append(el('span', 'leg-ico', o.ico));
const ac = t.mode === 'foot' ? '#5a7d5a' : '#274b8f'; lr.append(document.createTextNode(o.label + ' '));
const on = { weight: 7, opacity: 1, color: ac, dashArray: null }; lr.append(tchip(leg.dur || 0, leg.conf || 2));
const off = { color: ac, weight: 5, opacity: .85, dashArray: '6 4' }; if (o.title) lr.title = o.title;
tr.addEventListener('mouseenter', () => arrLines.forEach(l => l.setStyle(on))); const on = { weight: 7, opacity: 1, color: o.color, dashArray: null };
tr.addEventListener('mouseleave', () => arrLines.forEach(l => l.setStyle(off))); const off = { color: o.color, weight: 5, opacity: .85, dashArray: '5 5' };
tr.addEventListener('click', () => openArrivalEditor()); if (o.hover) {
body.append(tr); lr.addEventListener('mouseenter', () => o.hover.forEach(l => l.setStyle(on)));
lr.addEventListener('mouseleave', () => o.hover.forEach(l => l.setStyle(off)));
}
lr.addEventListener('click', o.onClick);
body.append(lr);
return lr;
}
// the hotel as a timeline NODE — "arrive HH:MM · drop bags" on the arrival day, "depart
// HH:MM" on every other day (startMin minus the hotel → first-stop leg). Same card
// family as the closing "tonight" card.
function renderHotelNode(body, phase) {
const b = dayBase(); if (!b) return;
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',
phase === 'arrive' ? `arrive ${fmt(day.startMin)} · drop bags`
: `depart ${fmt(Math.max(0, day.startMin - firstLegDur()))}`));
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)));
c.append(meta);
const hm = hotelMks.filter((_, i) => staysForDay().some(s => s === stays[i]));
c.addEventListener('mouseenter', () => hm.forEach(m => flashMarker(m, true)));
c.addEventListener('mouseleave', () => hm.forEach(m => flashMarker(m, false)));
c.addEventListener('click', () => openDiscover('hotel'));
body.append(c);
} }
// the ride back to the hotel at the end of a (non-departure) day // the ride back to the hotel at the end of a (non-departure) day — same row style and
// click-to-edit as every other leg
function renderReturnRow(body) { function renderReturnRow(body) {
const multi = staysForDay().length > 1;
const leg = getReturnLeg(day); const leg = getReturnLeg(day);
const ret = el('div', 'leg-row', `<span class="leg-ico">${MODE_ICO[(leg && leg.mode) || travelMode] || '•'}</span><span>back to ${dayBase() ? dayBase().name : 'hotel'}</span> `); if (leg) {
ret.append(tchip(returnDur(), multi ? 1 : (leg ? leg.conf : 3)));
if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
else if (leg) ret.title = 'Click to edit the ride — drag the drop-off, walk or taxi';
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)));
// exactly like any stop-to-stop leg: click → route card with drag + walk/taxi toggle
if (leg) ret.addEventListener('click', () => {
const last = day.stops[day.stops.length - 1]; const last = day.stops[day.stops.length - 1];
openLegEditor(leg, last.at, dayBase().at, last.name + ' → ' + (dayBase() ? dayBase().name : 'hotel'), 'return'); renderGroundLegRow(body, {
}); leg, ico: MODE_ICO[leg.mode] || '🚶',
label: `${last.name} → ${dayBase() ? dayBase().name : 'hotel'}`,
hover: retLines, color: '#7a8291',
title: 'Click to edit the ride — drag the drop-off, walk or taxi',
onClick: openReturnEditor,
});
return;
}
// no single anchor (multi-stay day or region/transit last stop): show the estimate
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(), 1));
if (staysForDay().length > 1) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
body.append(ret); body.append(ret);
} }
function openReturnEditor() {
const leg = getReturnLeg(day); if (!leg) return;
const b = dayBase(); if (!b) return;
const last = day.stops[day.stops.length - 1];
openLegEditor(leg, last.at, b.at, `${last.name} → ${b.name}`, 'return');
}
function openFirstEditor() {
const leg = getFirstLeg(day); if (!leg) return;
const b = dayBase(); if (!b) return;
openLegEditor(leg, b.at, day.stops[0].at, `${b.name} → ${day.stops[0].name}`, 'first');
}
// the hotel for tonight — in the timeline, after the ride back // the hotel for tonight — in the timeline, after the ride back
function renderHotelCard(body) { function renderHotelCard(body) {
@ -2019,7 +2185,7 @@ function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned'); const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length; 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) const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0)
+ (prim.length ? returnDur() : 0); + (prim.length ? firstLegDur() + returnDur() : 0);
const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0); const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0);
const end = layout(); const end = layout();
const cap = day.wakingHours * 60; const cap = day.wakingHours * 60;
@ -2273,14 +2439,14 @@ function setLegMode(leg, m) {
leg.conf = 1; leg.conf = 1;
if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away
renderRail(); renderL3Mode(leg); renderRail(); renderL3Mode(leg);
if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync if (l3.kind === 'arrival' || l3.kind === 'first') renderMap(); // keep the leg line colour (walk=green, taxi=blue) in sync
const t = (leg._rt = (leg._rt || 0) + 1); const t = (leg._rt = (leg._rt || 0) + 1);
routePts(leg._pts || [l3.from, l3.to], m).then(r => { const res = saneRoute(r, leg._pts || [l3.from, l3.to], m); routePts(leg._pts || [l3.from, l3.to], m).then(r => { const res = saneRoute(r, leg._pts || [l3.from, l3.to], m);
if (leg._rt !== t || !l3 || l3.leg !== leg) return; if (leg._rt !== t || !l3 || l3.leg !== leg) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; } if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; }
if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands
renderRail(); renderRail();
if (l3.kind === 'arrival') renderMap(); if (l3.kind === 'arrival' || l3.kind === 'first') renderMap();
l3.refreshCard(); l3.refreshCard();
}); });
} }
@ -2504,7 +2670,7 @@ $('#scope-trip').onclick = () => setScope('trip');
function dayStats(d) { function dayStats(d) {
const planned = d.stops.filter(s => s.state === 'planned'); 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) 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); + (planned.length ? firstLegDur(d) + (d.isDeparture ? 0 : returnDur(d)) : 0);
return { count: planned.length, dur, end: layout(d), return { count: planned.length, dur, end: layout(d),
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0), price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 }; flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
@ -2967,6 +3133,7 @@ function setTravelMode(m) {
M.days.forEach(md => { M.days.forEach(md => {
const d = days[md.id]; if (!d) return; const d = days[md.id]; if (!d) return;
(d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; }); (d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; });
if (d.firstLeg) { d.firstLeg.mode = m; d.firstLeg._routedMode = null; }
if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; } if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
if (d.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode) if (d.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode)
rebuildLegs(d, false); rebuildLegs(d, false);
@ -2974,7 +3141,7 @@ function setTravelMode(m) {
renderAll(); renderAll();
if (day) enrichLegs(day); if (day) enrichLegs(day);
renderModeToggle(); renderModeToggle();
if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : l3.kind === 'return' ? openReturnEditor() : l3.kind === 'first' ? openFirstEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
} }
function openDetail(s, actions) { function openDetail(s, actions) {

View File

@ -686,7 +686,7 @@ const COLOMBIA = {
] } }, ] } },
{ id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned', { id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego', url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego',
at: [10.42720, -75.55370], dur: 75, durConf: 3, at: [10.42050, -75.55170], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' }, suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' }, price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'], img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'],
@ -703,7 +703,7 @@ const COLOMBIA = {
summary: 'Seafood on the Bocagrande malecón — the langosta is the plate, the sunset the side order. Arrive 12:45 for the table with the water view before the 14:00 heat makes the terrace empty.', summary: 'Seafood on the Bocagrande malecón — the langosta is the plate, the sunset the side order. Arrive 12:45 for the table with the water view before the 14:00 heat makes the terrace empty.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S16', name: 'Malecón de Bocagrande', kind: 'visit', slot: 'visit', state: 'planned', { id: 'S16', name: 'Malecón de Bocagrande', kind: 'visit', slot: 'visit', state: 'planned',
at: [10.4365, -75.5390], dur: 60, durConf: 3, at: [10.43545, -75.53900], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: null }, suggested: { value: 60, conf: 2, src: null },
price: null, price: null,
img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'], img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'],
@ -723,7 +723,7 @@ const COLOMBIA = {
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned', { id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Punta_Canoa', url: 'https://en.wikipedia.org/wiki/Punta_Canoa',
at: [10.44600, -75.53100], dur: 90, durConf: 3, at: [10.44030, -75.53000], dur: 90, durConf: 3,
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' }, suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
price: null, price: null,
img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'], img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'],
@ -732,7 +732,7 @@ const COLOMBIA = {
links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } }, links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } },
{ id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch', { id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.44550, -75.53150], dur: 75, durConf: 3, at: [10.44020, -75.52940], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' }, price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'], img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'],