- 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)
165 lines
5.0 KiB
JavaScript
165 lines
5.0 KiB
JavaScript
// 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 };
|
||
}
|