Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// 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: `<span class="map-pin" style="background:${color}">${n}</span>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -24],
|
||||
});
|
||||
}
|
||||
|
||||
function kmLabel(L, km) {
|
||||
return L.divIcon({
|
||||
className: 'km-label-wrap',
|
||||
html: `<span class="km-label">${fmtKm(km)} km</span>`,
|
||||
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 (
|
||||
`<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 || '?';
|
||||
}
|
||||
|
||||
function legList(route, stops) {
|
||||
const legs = route.legs || [];
|
||||
if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.');
|
||||
|
||||
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
||||
let li = 0;
|
||||
let shown = 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;
|
||||
const leg = legs[li++];
|
||||
shown += 1;
|
||||
const air = leg.mode === 'air';
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'leg-row' },
|
||||
el('span', { class: 'leg-index' }, String(shown)),
|
||||
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
||||
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
||||
el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
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, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user