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:
@@ -284,6 +284,8 @@ textarea.input { resize: vertical; }
|
||||
.map-pin-wrap { background: none; border: none; }
|
||||
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
|
||||
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem 0.1rem 0.55rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-left: 4px solid var(--leg-accent, transparent); }
|
||||
.wp-dot-wrap { background: none; border: none; }
|
||||
.wp-dot { display: block; width: 10px; height: 10px; border-radius: 50%; background: var(--wp-color, var(--brand)); border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.35); }
|
||||
.map-popup strong { font-size: 0.95rem; }
|
||||
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
||||
@@ -326,6 +328,8 @@ textarea.input { resize: vertical; }
|
||||
.icon-btn { border: none; background: var(--surface-2); border-radius: 8px; width: 2rem; height: 2rem; cursor: pointer; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); }
|
||||
.icon-btn:hover { background: var(--border); color: var(--text); }
|
||||
.icon-btn.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.icon-btn:disabled:hover { background: var(--surface-2); color: var(--text-muted); }
|
||||
|
||||
.entry-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.entry-empty { padding: 0.6rem 0; }
|
||||
@@ -434,6 +438,17 @@ textarea.input { resize: vertical; }
|
||||
.entry-seg { display: flex; gap: 0.5rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
||||
.seg-flight { font-weight: 700; color: var(--brand-dark); }
|
||||
|
||||
/* ---------- Scenic waypoints (day editor) ---------- */
|
||||
.wp-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.wp-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.wp-row { display: flex; align-items: center; gap: 0.5rem; border: 1px solid var(--border); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); padding: 0.45rem 0.6rem; background: var(--surface-2); }
|
||||
.wp-num { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||
.wp-name { flex: 1; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wp-row-actions { display: flex; gap: 0.2rem; flex-shrink: 0; }
|
||||
.wp-move { font-size: 0.8rem; }
|
||||
.wp-search { position: relative; }
|
||||
.wp-max-hint { margin: 0; }
|
||||
|
||||
.stat-sub { font-size: 0.66rem; color: var(--brand-dark); font-weight: 700; }
|
||||
|
||||
/* ---------- Rental details (day editor) ---------- */
|
||||
|
||||
+4
-1
@@ -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),
|
||||
|
||||
@@ -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