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
+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 };
}