Legs cycle through the shared 8-hue palette by index: polylines (air stays dashed), km-label accents, and the Legs-list number badges all match, so multiple car routes are distinguishable. Badges use dark text after a contrast check ruled out white on these hues.
256 lines
9.2 KiB
JavaScript
256 lines
9.2 KiB
JavaScript
// 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);
|
|
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 })
|
|
.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: `<span class="map-pin" style="background:${color}">${n}</span>`,
|
|
iconSize: [26, 26],
|
|
iconAnchor: [13, 26],
|
|
popupAnchor: [0, -24],
|
|
});
|
|
}
|
|
|
|
function kmLabel(L, km, road, color) {
|
|
const accent = color ? ` style="--leg-accent:${color}"` : '';
|
|
return L.divIcon({
|
|
className: 'km-label-wrap',
|
|
html: `<span class="km-label"${accent}>${fmtKm(km)} km${road ? ' · road' : ''}</span>`,
|
|
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 (
|
|
`<div class="map-popup">` +
|
|
`<strong>${heading}</strong>` +
|
|
`<div class="map-popup-sub">${sub}</div>` +
|
|
(stop.location_name ? `<div class="map-popup-loc">${locIcon} ${esc(stop.location_name)}</div>` : '') +
|
|
`</div>`
|
|
);
|
|
}
|
|
|
|
function stopLabel(stop) {
|
|
if (stop.kind === 'airport' && stop.code) return stop.code;
|
|
return stop.location_name || stop.title || '?';
|
|
}
|
|
|
|
// legs: [{ leg, a, b }, …] from visibleLegs — same order/filtering the map
|
|
// polylines use. Returns the list element plus the per-row km <span>s (by
|
|
// the same index) so fetchRoadGeometry can update them once routed km lands.
|
|
function legList(legs) {
|
|
if (!legs.length) {
|
|
return {
|
|
wrap: el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.'),
|
|
kmEls: [],
|
|
};
|
|
}
|
|
|
|
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
|
const kmEls = [];
|
|
legs.forEach(({ leg, a, b }, i) => {
|
|
const air = leg.mode === 'air';
|
|
const kmEl = el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`);
|
|
kmEls.push(kmEl);
|
|
wrap.appendChild(
|
|
el(
|
|
'div',
|
|
{ class: 'leg-row' },
|
|
// Dark text, not white: the palette's mid-tone hues (amber, lime, …)
|
|
// don't clear AA contrast with white at full strength — the same
|
|
// reason stay bands use dark text on these colours (see stayBands.js).
|
|
el('span', { class: 'leg-index', style: { background: legColor(i), color: '#334155' } }, String(i + 1)),
|
|
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
|
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
|
kmEl,
|
|
),
|
|
);
|
|
});
|
|
return { wrap, kmEls };
|
|
}
|
|
|
|
function fmtKm(n) {
|
|
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
|
|
}
|
|
|
|
function esc(str) {
|
|
return String(str == null ? '' : str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|