// 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 } from '../format.js'; 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); section.appendChild(legList(route, stops)); // 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, route, stops), 0); return section; } function initMap(mapDiv, route, stops) { 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)); }); // One polyline per measured leg, styled by mode (air = dashed blue, ground = // solid teal), each with a km label at its midpoint. Legs are matched to // consecutive non-coincident stop pairs (skip <0.05km, the server's rule). const legs = route.legs || []; let li = 0; for (let i = 0; i < stops.length - 1 && li < legs.length; i++) { const a = points[i]; const b = points[i + 1]; if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue; const leg = legs[li]; li += 1; const air = leg.mode === 'air'; L.polyline([a, b], { color: air ? '#2563eb' : '#0f766e', weight: 3, opacity: 0.75, dashArray: air ? '6 6' : null, }).addTo(map); const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map); } if (points.length === 1) { map.setView(points[0], 10); } else { map.fitBounds(L.latLngBounds(points).pad(0.2)); } map.invalidateSize(); } // 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 kmLabel(L, km) { return L.divIcon({ className: 'km-label-wrap', html: `${fmtKm(km)} km`, iconSize: [0, 0], }); } // Built from server-provided fields; escape to keep the popup injection-safe. function popupHtml(stop, info) { const isAirport = stop.kind === 'airport' && stop.code; const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.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 ( `