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:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user