Add scenic waypoints for drive legs (OSRM via-routing)

- Transport entries carry an optional ordered waypoints array of
  lat/lng/name points; /route attaches them to the ground leg the
  transport bridges, and /api/directions accepts a via param so the
  drawn road route detours through them
- Day editor gains a geocoded "Scenic waypoints" list on transport
  entries; the map draws leg-coloured waypoint dots
- Escape waypoint names in the Leaflet tooltip (stored-XSS fix flagged
  by security review: names are user-typed and Leaflet renders string
  tooltips as HTML)
This commit is contained in:
2026-07-20 14:57:26 +07:00
parent 9369c82e56
commit e2c3089c25
15 changed files with 594 additions and 16 deletions
+4 -1
View File
@@ -73,7 +73,10 @@ export const api = {
costs: (id) => get(`/api/trips/${id}/costs`),
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
},
directions: (from, to) => get(`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}`),
directions: (from, to, via = []) => get(
`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}` +
(via.length ? `&via=${via.map((w) => `${w.lat},${w.lng}`).join('|')}` : ''),
),
entries: {
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
+13 -1
View File
@@ -22,6 +22,7 @@ import {
} from '../format.js';
import { createFlightRoute, renderSegmentLines } from './segments.js';
import { createRentalDetails, renderRentalLine } from './rental.js';
import { createWaypoints } from './waypoints.js';
import { createCostForm } from './costForm.js';
import { enableRowReorder } from './dragdrop.js';
@@ -190,6 +191,7 @@ export function openDayEditor(tctx, date) {
// Flight segments (load() triggers the flight-route onChange -> UI sync).
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
fields.rentalDetails.load(entry.rental || null);
fields.waypoints.load(Array.isArray(entry.waypoints) ? entry.waypoints : []);
renderLoc();
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
fields.title.focus();
@@ -243,6 +245,10 @@ export function openDayEditor(tctx, date) {
// state), before the section elements below exist — gate until wired.
let typeUIReady = false;
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
// ----- Scenic waypoints subsection (shown only for transport entries) -----
const waypoints = createWaypoints({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
const waypointsSection = waypoints.node;
const flightSection = el(
'div',
{ class: 'flight-section' },
@@ -269,6 +275,7 @@ export function openDayEditor(tctx, date) {
flightSection.style.display = type === 'flight' ? '' : 'none';
rentalSection.style.display = type === 'rental' ? '' : 'none';
modeField.style.display = type === 'transport' ? '' : 'none';
waypointsSection.style.display = type === 'transport' ? '' : 'none';
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
// Stays emphasise an end date ("until"); other types call it "End date".
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
@@ -285,7 +292,7 @@ export function openDayEditor(tctx, date) {
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
endDate: endDateInput, mode: modeSelect,
locInput, locResults, locSelected,
cost: costForm, flightRoute, rentalDetails,
cost: costForm, flightRoute, rentalDetails, waypoints,
};
wireGeocode(locInput, locResults);
@@ -354,6 +361,10 @@ export function openDayEditor(tctx, date) {
// changing an entry's type away from transport drops any prior mode.
payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null;
// Scenic waypoints (transport entries only). Clear them otherwise so
// changing an entry's type away from transport drops any prior waypoints.
payload.waypoints = typeSelect.value === 'transport' ? waypoints.read().waypoints : null;
// end_date must be on/after the effective start date (rental may have
// moved payload.date to the pickup date above).
if (payload.end_date && payload.end_date < payload.date) {
@@ -416,6 +427,7 @@ export function openDayEditor(tctx, date) {
),
flightSection,
rentalSection,
waypointsSection,
locationField,
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
costForm.node,
+19 -1
View File
@@ -114,6 +114,15 @@ function initMap(mapDiv, stops, legs, kmEls) {
}).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 };
});
@@ -134,7 +143,7 @@ function initMap(mapDiv, stops, legs, kmEls) {
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 })
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
@@ -177,6 +186,15 @@ function numberedIcon(L, n, color) {
});
}
function waypointIcon(L, color) {
return L.divIcon({
className: 'wp-dot-wrap',
html: `<span class="wp-dot" style="--wp-color:${color}"></span>`,
iconSize: [10, 10],
iconAnchor: [5, 5],
});
}
function kmLabel(L, km, road, color) {
const accent = color ? ` style="--leg-accent:${color}"` : '';
return L.divIcon({
+164
View File
@@ -0,0 +1,164 @@
// Scenic waypoints editor used inside the day editor for transport entries.
// Geocode-backed search (mirrors the day editor's location field) to append
// ordered via-points, each removable and reorderable with up/down buttons.
// Kept as its own module so dayEditor.js stays small.
import { el, clear, toast } from '../dom.js';
import { api } from '../api.js';
const MAX_WAYPOINTS = 8;
// createWaypoints({ initialWaypoints, onChange }) -> { node, read(), load(), hasWaypoints() }
// read() returns { waypoints: [...] } — an array, possibly empty.
export function createWaypoints({ initialWaypoints = null, onChange = () => {} } = {}) {
const points = []; // { lat, lng, name }
const listEl = el('div', { class: 'wp-list' });
const searchInput = el('input', {
class: 'input',
type: 'text',
placeholder: 'Search a scenic detour (OpenStreetMap)…',
autocomplete: 'off',
});
const resultsBox = el('div', { class: 'loc-results' });
const searchField = el('div', { class: 'wp-search' }, searchInput, resultsBox);
const maxHint = el('p', { class: 'hint muted wp-max-hint' }, 'Maximum of 8 waypoints reached.');
const node = el(
'div',
{ class: 'wp-section' },
el('div', { class: 'cost-heading' }, 'Scenic waypoints (optional)'),
el('p', { class: 'hint muted' }, 'Add via-points to route a drive through a scenic detour.'),
listEl,
searchField,
maxHint,
);
function renderRows() {
clear(listEl);
points.forEach((p, i) => {
const upBtn = el('button', {
class: 'icon-btn wp-move',
type: 'button',
title: 'Move up',
disabled: i === 0,
onClick: () => {
[points[i - 1], points[i]] = [points[i], points[i - 1]];
renderRows();
onChange();
},
}, '↑');
const downBtn = el('button', {
class: 'icon-btn wp-move',
type: 'button',
title: 'Move down',
disabled: i === points.length - 1,
onClick: () => {
[points[i + 1], points[i]] = [points[i], points[i + 1]];
renderRows();
onChange();
},
}, '↓');
const removeBtn = el('button', {
class: 'icon-btn danger',
type: 'button',
title: 'Remove waypoint',
onClick: () => {
points.splice(i, 1);
renderRows();
onChange();
},
}, '×');
listEl.appendChild(
el(
'div',
{ class: 'wp-row' },
el('span', { class: 'wp-num' }, String(i + 1)),
el('span', { class: 'wp-name' }, p.name || `${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`),
el('div', { class: 'wp-row-actions' }, upBtn, downBtn, removeBtn),
),
);
});
syncSearchVisibility();
}
function syncSearchVisibility() {
const full = points.length >= MAX_WAYPOINTS;
searchField.style.display = full ? 'none' : '';
maxHint.style.display = full ? '' : 'none';
}
let timer = null;
let seq = 0;
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
clearTimeout(timer);
if (q.length < 2) {
clear(resultsBox);
return;
}
timer = setTimeout(async () => {
const mySeq = ++seq;
resultsBox.classList.add('loading');
try {
const data = await api.geocode(q);
if (mySeq !== seq) return; // a newer query superseded this one
showResults(data.results || []);
} catch (err) {
if (mySeq !== seq) return;
clear(resultsBox);
toast(err.message || 'Location search failed');
} finally {
resultsBox.classList.remove('loading');
}
}, 400);
});
function showResults(results) {
clear(resultsBox);
if (!results.length) {
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
return;
}
for (const r of results) {
resultsBox.appendChild(
el(
'button',
{
class: 'loc-result',
type: 'button',
onClick: () => {
if (points.length >= MAX_WAYPOINTS) return;
points.push({ lat: r.lat, lng: r.lng, name: r.name });
searchInput.value = '';
clear(resultsBox);
renderRows();
onChange();
},
},
el('span', { class: 'loc-result-name' }, r.name),
),
);
}
}
function read() {
return { waypoints: points.map((p) => ({ lat: p.lat, lng: p.lng, name: p.name || undefined })) };
}
// Replace all waypoints (used when editing an existing transport entry).
function load(waypoints) {
points.length = 0;
if (Array.isArray(waypoints)) {
for (const w of waypoints) points.push({ lat: w.lat, lng: w.lng, name: w.name || '' });
}
renderRows();
onChange();
}
if (Array.isArray(initialWaypoints)) {
for (const w of initialWaypoints) points.push({ lat: w.lat, lng: w.lng, name: w.name || '' });
}
renderRows();
return { node, read, load, hasWaypoints: () => points.length > 0 };
}