Files
trip-plan/public/js/views/waypoints.js
T
grabowski e2c3089c25 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)
2026-07-20 14:57:26 +07:00

165 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}