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:
parent
1ba39f7333
commit
6457501520
263
mock/app.js
263
mock/app.js
|
|
@ -214,7 +214,10 @@ function llmContext() {
|
|||
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.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');
|
||||
}
|
||||
|
|
@ -320,9 +323,10 @@ function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
|
|||
// before the traveller had even reached the hotel).
|
||||
function syncArrivalStart(d) {
|
||||
if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return;
|
||||
d.startMin = d.arrLandingMin + d.transferIn.dur;
|
||||
const arrEnd = Math.min(ARR_DAY_END, d.startMin + (d.wakingHours || 12) * 60);
|
||||
d.wakingHours = Math.max(2, Math.round((arrEnd - d.startMin) / 60));
|
||||
d.startMin = d.arrLandingMin + d.transferIn.dur; // time at the HOTEL (bag drop)
|
||||
const eff = d.startMin + firstLegDur(d); // + hotel → first stop = time at the first stop
|
||||
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) {
|
||||
const [y, mo, da] = String(dateStr).split('-').map(Number);
|
||||
|
|
@ -466,14 +470,22 @@ function healTransfers() {
|
|||
// geometry ends anywhere else than the current hotel point, drop it so it re-routes
|
||||
ids.forEach(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 leg = dd.retLeg;
|
||||
if (leg && leg.geometry && leg.geometry.length) {
|
||||
if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
|
||||
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 h0 = (first.base && first.base.at) ? first.base : (stays[0] || null);
|
||||
|
|
@ -708,7 +720,12 @@ const TOOLS = {
|
|||
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 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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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++) {
|
||||
const a = d.stops[i], b = d.stops[i + 1];
|
||||
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
|
||||
|
|
@ -1063,7 +1099,7 @@ 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 = [], arrLine = null, arrLines = [];
|
||||
let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [], arrLine = null, arrLines = [], firstLine = null, firstLines = [];
|
||||
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)
|
||||
|
|
@ -1168,6 +1204,39 @@ function getReturnLeg(d = day) {
|
|||
}
|
||||
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) {
|
||||
if (d.isDeparture) return 0; // departure day: the train is the end of the day
|
||||
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)));
|
||||
}
|
||||
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; });
|
||||
return t + returnDur(d);
|
||||
}
|
||||
|
|
@ -1192,6 +1265,9 @@ function rebuildLegs(d = day, enrich = true) {
|
|||
delete d.retRouted;
|
||||
delete d.retLeg;
|
||||
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++) {
|
||||
const a = d.stops[i], b = d.stops[i + 1];
|
||||
// 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(legEls).forEach(l => map.removeLayer(l)); legEls = {};
|
||||
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
|
||||
M.days.forEach(d => {
|
||||
const obj = days[d.id];
|
||||
|
|
@ -1286,6 +1362,16 @@ function renderMap() {
|
|||
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
|
||||
const t = obj.transferIn;
|
||||
const pts = t.geometry || [t.at, t.hotelAt];
|
||||
|
|
@ -1437,7 +1523,22 @@ function renderStrip() {
|
|||
track.append(b);
|
||||
};
|
||||
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) => {
|
||||
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';
|
||||
|
|
@ -1703,8 +1804,31 @@ 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);
|
||||
// every day lives between two hotel nodes: you LEAVE the hotel (or land and drop
|
||||
// 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));
|
||||
// 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
|
||||
|
|
@ -1715,8 +1839,9 @@ function renderRail(flashIds = []) {
|
|||
renderStrip(); renderBudget(); renderNudges(); renderSugg();
|
||||
}
|
||||
|
||||
// arrival-day header: the flight you landed on, then the car to the hotel
|
||||
function renderArrivalHeader(body) {
|
||||
// arrival-day flight card. The airport → hotel drop-off is NOT embedded here: it is a
|
||||
// stand-alone first-class leg row (renderGroundLegRow), exactly like every other leg.
|
||||
function renderArrivalFlightCard(body) {
|
||||
const f = day.arrivalFlight; if (!f) return;
|
||||
const c = el('div', 'stop-card struct-card arr-card');
|
||||
const top = el('div', 'sc-top');
|
||||
|
|
@ -1731,42 +1856,83 @@ function renderArrivalHeader(body) {
|
|||
c.append(meta);
|
||||
if (f.at) c.addEventListener('click', () => focusPoint(f.at));
|
||||
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.
|
||||
const t = day.transferIn || {};
|
||||
const tr = el('div', 'leg-row');
|
||||
tr.append(el('span', 'leg-ico', t.mode === 'foot' ? '🚶' : '🚗'));
|
||||
tr.append(document.createTextNode('to ' + (t.hotelName || (dayBase() ? dayBase().name : 'hotel')) + ' — drop bags'));
|
||||
tr.append(document.createTextNode(' '));
|
||||
tr.append(tchip(t.dur || 30, t.conf || 2));
|
||||
const ac = t.mode === 'foot' ? '#5a7d5a' : '#274b8f';
|
||||
const on = { weight: 7, opacity: 1, color: ac, dashArray: null };
|
||||
const off = { color: ac, weight: 5, opacity: .85, dashArray: '6 4' };
|
||||
tr.addEventListener('mouseenter', () => arrLines.forEach(l => l.setStyle(on)));
|
||||
tr.addEventListener('mouseleave', () => arrLines.forEach(l => l.setStyle(off)));
|
||||
tr.addEventListener('click', () => openArrivalEditor());
|
||||
body.append(tr);
|
||||
}
|
||||
// every ground leg in the timeline renders through this one function — the arrival
|
||||
// drop-off, hotel → first stop, and the return ride. Same row, same chip, same click
|
||||
// → the same route card (drag the route, walk/taxi toggle).
|
||||
function renderGroundLegRow(body, o) {
|
||||
const leg = o.leg;
|
||||
const lr = el('div', 'leg-row' + (o.dim ? ' dim' : ''));
|
||||
lr.append(el('span', 'leg-ico', o.ico));
|
||||
lr.append(document.createTextNode(o.label + ' '));
|
||||
lr.append(tchip(leg.dur || 0, leg.conf || 2));
|
||||
if (o.title) lr.title = o.title;
|
||||
const on = { weight: 7, opacity: 1, color: o.color, dashArray: null };
|
||||
const off = { color: o.color, weight: 5, opacity: .85, dashArray: '5 5' };
|
||||
if (o.hover) {
|
||||
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) {
|
||||
const multi = staysForDay().length > 1;
|
||||
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> `);
|
||||
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', () => {
|
||||
if (leg) {
|
||||
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);
|
||||
}
|
||||
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
|
||||
function renderHotelCard(body) {
|
||||
|
|
@ -2019,7 +2185,7 @@ 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);
|
||||
+ (prim.length ? firstLegDur() + 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;
|
||||
|
|
@ -2273,14 +2439,14 @@ function setLegMode(leg, m) {
|
|||
leg.conf = 1;
|
||||
if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away
|
||||
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);
|
||||
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 (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
|
||||
renderRail();
|
||||
if (l3.kind === 'arrival') renderMap();
|
||||
if (l3.kind === 'arrival' || l3.kind === 'first') renderMap();
|
||||
l3.refreshCard();
|
||||
});
|
||||
}
|
||||
|
|
@ -2504,7 +2670,7 @@ $('#scope-trip').onclick = () => setScope('trip');
|
|||
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);
|
||||
+ (planned.length ? firstLegDur(d) + (d.isDeparture ? 0 : 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 };
|
||||
|
|
@ -2967,6 +3133,7 @@ function setTravelMode(m) {
|
|||
M.days.forEach(md => {
|
||||
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; });
|
||||
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.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode)
|
||||
rebuildLegs(d, false);
|
||||
|
|
@ -2974,7 +3141,7 @@ function setTravelMode(m) {
|
|||
renderAll();
|
||||
if (day) enrichLegs(day);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -686,7 +686,7 @@ const COLOMBIA = {
|
|||
] } },
|
||||
{ id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
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' },
|
||||
price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' },
|
||||
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.',
|
||||
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
|
||||
{ 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 },
|
||||
price: null,
|
||||
img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'],
|
||||
|
|
@ -723,7 +723,7 @@ const COLOMBIA = {
|
|||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
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' },
|
||||
price: null,
|
||||
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' } ] } },
|
||||
{ id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch',
|
||||
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' },
|
||||
price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user