// Leaflet map: numbered markers for located stops, a polyline through them, // per-leg km labels, and a leg-by-leg list. Graceful empty state when the // trip has no located entries yet. Uses the /route response from tctx.route. import { el } from '../dom.js'; import { typeInfo, entryIcon } from '../format.js'; import { api } from '../api.js'; import { STAY_PALETTE } from './stayBands.js'; // Each route leg gets its own colour from the same palette used for stay // bands/area dots, so several ground legs (e.g. multiple car routes) read // apart instead of blending into one generic "ground" colour. function legColor(i) { return STAY_PALETTE[((i % STAY_PALETTE.length) + STAY_PALETTE.length) % STAY_PALETTE.length]; } export function renderMap(tctx) { const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; const stops = route.stops || []; const section = el('section', { class: 'card map-section' }); section.appendChild( el( 'div', { class: 'section-head' }, el('h2', {}, 'Map'), el('p', { class: 'muted' }, stops.length ? `${stops.length} located ${stops.length === 1 ? 'stop' : 'stops'} Β· ${fmtKm(route.totalKm)} km total` : 'Add a location to an entry to see it here.'), ), ); if (!stops.length) { section.appendChild( el( 'div', { class: 'map-empty' }, el('div', { class: 'empty-icon' }, 'πΊοΈ'), el('p', {}, 'No located entries yet.'), el('p', { class: 'muted' }, 'Open a day, add an entry, and give it a location to plot it on the map.'), ), ); return section; } const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` }); section.appendChild(mapDiv); // Legs and the "Legs" list below share the same filtered (leg, fromStop, // toStop) triples so the map's polylines/labels and the list rows stay in // lockstep by index. const legs = visibleLegs(route, stops); const { wrap: legListEl, kmEls } = legList(legs); section.appendChild(legListEl); // Leaflet needs the container attached with a real size, so init on the // next tick after this section is mounted into the page. setTimeout(() => initMap(mapDiv, stops, legs, kmEls), 0); return section; } // Consecutive non-coincident stop pairs (skip <0.05km, the server's rule), // paired with their matching /route leg. function visibleLegs(route, stops) { const legs = route.legs || []; const result = []; let li = 0; for (let i = 0; i < stops.length - 1 && li < legs.length; i++) { const a = stops[i]; const b = stops[i + 1]; if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue; result.push({ leg: legs[li], a, b }); li += 1; } return result; } function initMap(mapDiv, stops, legs, kmEls) { const L = window.L; if (!L) { mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.')); return; } const map = L.map(mapDiv, { scrollWheelZoom: true }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: 'Β© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); // Geometry is driven by stop ORDER, not entryId: a multi-leg flight expands // into several airport stops that all share the same entryId, so keying by // entryId would collapse them. const points = stops.map((s) => [s.lat, s.lng]); stops.forEach((stop, i) => { const info = typeInfo(stop.type); L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) }) .addTo(map) .bindPopup(popupHtml(stop, info, entryIcon(stop))); }); // One polyline per measured leg, each in its own colour from the shared // palette (air legs keep the dashed pattern), each with a km label at its // midpoint (straight great-circle line to start β the map is never empty // while road geometry loads). const legLayers = legs.map(({ leg, a, b }, i) => { const air = leg.mode === 'air'; const color = legColor(i); const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], { color, weight: 3, opacity: 0.75, dashArray: air ? '6 6' : null, }).addTo(map); const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2]; const label = L.marker(mid, { icon: kmLabel(L, leg.km, false, color), interactive: false }).addTo(map); // Scenic via-points (ground legs only) β small dots in the leg's colour, // with the waypoint name as a tooltip. (leg.waypoints || []).forEach((wp) => { // esc() the name: Leaflet renders a string tooltip as HTML, and wp.name // is user-typed, so a raw value is a stored-XSS sink for co-travellers. L.marker([wp.lat, wp.lng], { icon: waypointIcon(L, color), title: wp.name || '' }) .bindTooltip(esc(wp.name || 'Waypoint')) .addTo(map); }); return { leg, a, b, line, label, color }; }); if (points.length === 1) { map.setView(points[0], 10); } else { map.fitBounds(L.latLngBounds(points).pad(0.2)); } map.invalidateSize(); fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls); } // Async road-following geometry for ground legs, per the /api/directions // "Consumer contract (map)" in docs/API.md. Straight great-circle lines are // already drawn, so a slow/failed fetch just leaves those in place β no // error UI. Each leg fetches (and fails) independently. function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) { legLayers.forEach(({ leg, a, b, line, label, color }, i) => { if (leg.mode !== 'ground') return; api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng }, leg.waypoints || []) .then((res) => { // The trip view may have been re-rendered (refreshTrip) while this // was in flight β the old map/section is detached from the DOM and // its layers must not be touched. Only latlngs/label are swapped β // the polyline keeps its per-leg colour/style from creation. if (!mapDiv.isConnected || !map.hasLayer(line) || !map.hasLayer(label)) return; line.setLatLngs(res.geometry); label.setLatLng(pathMidpoint(res.geometry) || label.getLatLng()); label.setIcon(kmLabel(L, res.km, true, color)); const kmEl = kmEls[i]; if (kmEl) kmEl.textContent = `${fmtKm(res.km)} km Β· road`; }) .catch(() => {}); }); } function pathMidpoint(coords) { if (!coords || !coords.length) return null; return coords[Math.floor(coords.length / 2)]; } // Great-circle km β mirrors the server rule for skipping zero-distance legs. function haversineKm(lat1, lng1, lat2, lng2) { const R = 6371; const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); } function numberedIcon(L, n, color) { return L.divIcon({ className: 'map-pin-wrap', html: `${n}`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -24], }); } function waypointIcon(L, color) { return L.divIcon({ className: 'wp-dot-wrap', html: ``, iconSize: [10, 10], iconAnchor: [5, 5], }); } function kmLabel(L, km, road, color) { const accent = color ? ` style="--leg-accent:${color}"` : ''; return L.divIcon({ className: 'km-label-wrap', html: `${fmtKm(km)} km${road ? ' Β· road' : ''}`, iconSize: [0, 0], }); } // Built from server-provided fields; escape to keep the popup injection-safe. function popupHtml(stop, info, icon) { const isAirport = stop.kind === 'airport' && stop.code; const heading = isAirport ? `${icon} ${esc(stop.code)}` : `${icon} ${esc(stop.title)}`; const sub = isAirport ? `${esc(info.label)} Β· ${esc(stop.date)}${stop.title ? ` Β· ${esc(stop.title)}` : ''}` : `${esc(info.label)} Β· ${esc(stop.date)}`; const locIcon = isAirport ? 'βοΈ' : 'π'; return ( `