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:
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
rental TEXT,
|
||||
transport_mode TEXT,
|
||||
auto_ref TEXT,
|
||||
waypoints TEXT,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
@@ -75,6 +76,7 @@ const MIGRATIONS = [
|
||||
{ table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' },
|
||||
{ table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
|
||||
{ table: 'entries', column: 'auto_ref', ddl: 'ALTER TABLE entries ADD COLUMN auto_ref TEXT' },
|
||||
{ table: 'entries', column: 'waypoints', ddl: 'ALTER TABLE entries ADD COLUMN waypoints TEXT' },
|
||||
{ table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' },
|
||||
];
|
||||
|
||||
|
||||
@@ -20,27 +20,50 @@ function round1(n) {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
const MAX_VIA = 8;
|
||||
|
||||
// Parses the optional `via` query param: up to 8 "lat,lng" pairs separated by
|
||||
// '|'. Returns { points: [...] } (points is [] when via is absent) or
|
||||
// { error: true } when any pair is malformed/out of range or there are too many.
|
||||
function parseVia(value) {
|
||||
if (value === undefined) return { points: [] };
|
||||
const parts = String(value).split('|');
|
||||
if (parts.length > MAX_VIA) return { error: true };
|
||||
const points = [];
|
||||
for (const part of parts) {
|
||||
const point = parseLatLng(part);
|
||||
if (!point) return { error: true };
|
||||
points.push(point);
|
||||
}
|
||||
return { points };
|
||||
}
|
||||
|
||||
export default function directionsRoutes() {
|
||||
const router = express.Router();
|
||||
const cache = new Map(); // "lat,lng;lat,lng" (rounded) -> { at, data }
|
||||
const cache = new Map(); // "lat,lng;lat,lng;..." (rounded) -> { at, data }
|
||||
|
||||
// GET /api/directions?from=lat,lng&to=lat,lng
|
||||
// GET /api/directions?from=lat,lng&to=lat,lng[&via=lat,lng|lat,lng...]
|
||||
router.get('/', async (req, res) => {
|
||||
const from = parseLatLng(req.query.from);
|
||||
const to = parseLatLng(req.query.to);
|
||||
if (!from || !to) {
|
||||
return res.status(400).json({ error: 'from and to must be lat,lng' });
|
||||
}
|
||||
const via = parseVia(req.query.via);
|
||||
if (via.error) {
|
||||
return res.status(400).json({ error: 'via must be up to 8 lat,lng pairs' });
|
||||
}
|
||||
|
||||
const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org';
|
||||
const key = `${from.lat.toFixed(5)},${from.lng.toFixed(5)};${to.lat.toFixed(5)},${to.lng.toFixed(5)}`;
|
||||
const coords = [from, ...via.points, to];
|
||||
const key = coords.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join(';');
|
||||
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||
return res.status(200).json(cached.data);
|
||||
}
|
||||
|
||||
const url = `${osrmUrl}/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
|
||||
const url = `${osrmUrl}/route/v1/driving/${coords.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson`;
|
||||
let data;
|
||||
try {
|
||||
const upstream = await fetch(url, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { membership } from '../util/access.js';
|
||||
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||
import { validateSegments } from '../util/segments.js';
|
||||
import { validateRental } from '../util/rental.js';
|
||||
import { validateWaypoints } from '../util/waypoints.js';
|
||||
import { autoTransportTitle } from '../util/autoTransport.js';
|
||||
|
||||
const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
|
||||
@@ -208,6 +209,22 @@ function validateEntry(body, { partial, existing, memberIds }) {
|
||||
}
|
||||
}
|
||||
|
||||
// waypoints: transport-only scenic via-points (null or [] clears them).
|
||||
if (has('waypoints')) {
|
||||
const v = body.waypoints;
|
||||
if (v === null || (Array.isArray(v) && v.length === 0)) {
|
||||
fields.waypoints = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'transport') {
|
||||
return { error: 'waypoints are only allowed on transport entries' };
|
||||
}
|
||||
const checked = validateWaypoints(v);
|
||||
if (checked.error) return { error: checked.error };
|
||||
fields.waypoints = JSON.stringify(checked.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, participants, hasParticipants: has('participants') };
|
||||
}
|
||||
|
||||
@@ -306,8 +323,8 @@ export default function entriesRoutes(db) {
|
||||
`INSERT INTO entries
|
||||
(trip_id, date, end_date, type, title, details, start_time, end_time,
|
||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental,
|
||||
transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
transport_mode, waypoints)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tripId,
|
||||
@@ -327,7 +344,8 @@ export default function entriesRoutes(db) {
|
||||
f.split_mode ?? 'equal',
|
||||
f.segments ?? null,
|
||||
f.rental ?? null,
|
||||
f.transport_mode ?? null
|
||||
f.transport_mode ?? null,
|
||||
f.waypoints ?? null
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
// participants provided as an array -> store rows; null/absent -> all members.
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
attachParticipantsAll,
|
||||
parseSegments,
|
||||
parseRental,
|
||||
parseWaypoints,
|
||||
} from '../util/entrySerialize.js';
|
||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||
import { regenerateAutoTransports } from '../util/autoTransport.js';
|
||||
import { buildLegWaypointsResolver } from '../util/legWaypoints.js';
|
||||
|
||||
const MAX_RANGE_DAYS = 365;
|
||||
const MIN_LEG_KM = 0.05;
|
||||
@@ -316,9 +318,11 @@ export default function tripsRoutes(db) {
|
||||
...r,
|
||||
segments: parseSegments(r.segments),
|
||||
rental: parseRental(r.rental),
|
||||
waypoints: parseWaypoints(r.waypoints),
|
||||
}));
|
||||
|
||||
const stops = buildStops(allEntries);
|
||||
const waypointsForLeg = buildLegWaypointsResolver(allEntries);
|
||||
|
||||
const legs = [];
|
||||
let totalKm = 0;
|
||||
@@ -338,6 +342,7 @@ export default function tripsRoutes(db) {
|
||||
toEntryId: b.entryId,
|
||||
km: Math.round(km * 10) / 10,
|
||||
mode,
|
||||
waypoints: isAir ? [] : waypointsForLeg(a.date, b.date),
|
||||
});
|
||||
totalKm += km;
|
||||
if (isAir) kmAir += km;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export const ENTRY_COLUMNS =
|
||||
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref';
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref, waypoints';
|
||||
|
||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||
export function parseSegments(value) {
|
||||
@@ -41,6 +41,17 @@ export function parseAutoRef(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the stored waypoints JSON text into an array, or null if absent/invalid.
|
||||
export function parseWaypoints(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function attachParticipants(db, row) {
|
||||
if (!row) return row;
|
||||
const rows = db
|
||||
@@ -50,6 +61,7 @@ export function attachParticipants(db, row) {
|
||||
row.segments = parseSegments(row.segments);
|
||||
row.rental = parseRental(row.rental);
|
||||
row.auto_ref = parseAutoRef(row.auto_ref);
|
||||
row.waypoints = parseWaypoints(row.waypoints);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Attach scenic waypoints (see "Scenic waypoints" in docs/API.md) to route
|
||||
// legs. A ground leg's waypoints come from the transport entry that bridges
|
||||
// it: the earliest (by date, id) transport entry with a non-empty waypoints
|
||||
// array whose date falls within the leg's stop date range, inclusive. A given
|
||||
// transport bridges at most one leg — once claimed by a leg it's excluded
|
||||
// from later legs, so the first leg in stop order wins.
|
||||
export function buildLegWaypointsResolver(entries) {
|
||||
const candidates = entries
|
||||
.filter((e) => e.type === 'transport' && Array.isArray(e.waypoints) && e.waypoints.length > 0)
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.id - b.id));
|
||||
const used = new Set();
|
||||
|
||||
return function waypointsForLeg(fromDate, toDate) {
|
||||
for (const e of candidates) {
|
||||
if (used.has(e.id)) continue;
|
||||
if (e.date >= fromDate && e.date <= toDate) {
|
||||
used.add(e.id);
|
||||
return e.waypoints;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Validation + normalization for transport-entry scenic waypoints (see
|
||||
// "Scenic waypoints" in docs/API.md).
|
||||
|
||||
const MAX_WAYPOINTS = 8;
|
||||
const MAX_NAME_LEN = 120;
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Validate a waypoints array (already known non-empty/array by the caller's
|
||||
// clearing check). Returns { error } or { value: normalizedArray }.
|
||||
export function validateWaypoints(waypoints) {
|
||||
if (!Array.isArray(waypoints)) {
|
||||
return { error: 'waypoints must be an array' };
|
||||
}
|
||||
if (waypoints.length > MAX_WAYPOINTS) {
|
||||
return { error: 'at most 8 waypoints' };
|
||||
}
|
||||
const out = [];
|
||||
for (const w of waypoints) {
|
||||
if (!w || typeof w !== 'object' || Array.isArray(w)) {
|
||||
return { error: 'waypoint lat/lng out of range' };
|
||||
}
|
||||
if (!validCoord(w.lat, -90, 90) || !validCoord(w.lng, -180, 180)) {
|
||||
return { error: 'waypoint lat/lng out of range' };
|
||||
}
|
||||
const point = { lat: w.lat, lng: w.lng };
|
||||
if (w.name !== undefined && w.name !== null) {
|
||||
if (typeof w.name !== 'string' || w.name.length > MAX_NAME_LEN) {
|
||||
return { error: 'waypoint name must be a string of at most 120 characters' };
|
||||
}
|
||||
const trimmed = w.name.trim();
|
||||
if (trimmed.length > MAX_NAME_LEN) {
|
||||
return { error: 'waypoint name must be a string of at most 120 characters' };
|
||||
}
|
||||
if (trimmed !== '') point.name = trimmed;
|
||||
}
|
||||
out.push(point);
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
Reference in New Issue
Block a user