Multiple improvements
This commit is contained in:
+53
-6
@@ -3,6 +3,8 @@ import { HttpError } from '../middleware/errors.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const WAYPOINT_DERIVED = Symbol('waypointDerived');
|
||||
|
||||
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
|
||||
const parser = new XMLParser(xmlOptions);
|
||||
const builder = new XMLBuilder({ ...xmlOptions, format: true });
|
||||
@@ -20,6 +22,7 @@ function parseXml(xml) {
|
||||
function toRoutePoint(point, sequence) {
|
||||
return {
|
||||
sequence,
|
||||
...(point.extensions?.['wegwichtel:waypointRoutePoint'] === true ? { [WAYPOINT_DERIVED]: true } : {}),
|
||||
lat: Number.parseFloat(point['@_lat']),
|
||||
lon: Number.parseFloat(point['@_lon']),
|
||||
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
|
||||
@@ -42,6 +45,49 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
|
||||
return result.map((point, sequence) => ({ ...point, sequence }));
|
||||
}
|
||||
|
||||
function sameLocation(left, right) {
|
||||
return left.lat === right.lat && left.lon === right.lon;
|
||||
}
|
||||
|
||||
function insertionDistance(path, slot, waypoint) {
|
||||
const before = path[slot - 1];
|
||||
const after = path[slot];
|
||||
return (before ? distanceMeters(before, waypoint) : 0)
|
||||
+ (after ? distanceMeters(waypoint, after) : 0)
|
||||
- (before && after ? distanceMeters(before, after) : 0);
|
||||
}
|
||||
|
||||
function waypointSlot(path, waypoint, firstSlot) {
|
||||
let slot = firstSlot;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = firstSlot; index <= path.length; index += 1) {
|
||||
const addedDistance = insertionDistance(path, index, waypoint);
|
||||
if (addedDistance < bestDistance) {
|
||||
bestDistance = addedDistance;
|
||||
slot = index;
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
// Keep the recorded path and station order. Among remaining gaps choose the
|
||||
// insertion with the smallest extra walking distance; no road routing is used.
|
||||
function mergeWaypoints(path, waypoints) {
|
||||
const result = [...path];
|
||||
let firstSlot = 0;
|
||||
for (const waypoint of waypoints) {
|
||||
const existing = result.findIndex((point, index) => index >= firstSlot && sameLocation(point, waypoint));
|
||||
if (existing !== -1) {
|
||||
firstSlot = existing;
|
||||
continue;
|
||||
}
|
||||
const slot = waypointSlot(result, waypoint, firstSlot);
|
||||
result.splice(slot, 0, { ...waypoint, [WAYPOINT_DERIVED]: true });
|
||||
firstSlot = slot + 1;
|
||||
}
|
||||
return result.map((point, sequence) => ({ ...point, sequence }));
|
||||
}
|
||||
|
||||
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
|
||||
const gpx = parseXml(xml)?.gpx;
|
||||
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
|
||||
@@ -49,16 +95,16 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
|
||||
const routes = asList(gpx.rte);
|
||||
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
|
||||
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
|
||||
const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
|
||||
const waypoints = validPoints(asList(gpx.wpt));
|
||||
const path = (replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded)))
|
||||
.filter(point => !point[WAYPOINT_DERIVED]);
|
||||
const points = mergeWaypoints(path, waypoints);
|
||||
if (!allowEmpty && !points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
|
||||
const metricPoints = points.length ? points : waypoints;
|
||||
const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
|
||||
const metrics = points.length ? routeMetrics(points) : {
|
||||
startLat: null, startLon: null, centerLat: null, centerLon: null,
|
||||
minLat: null, minLon: null, maxLat: null, maxLon: null,
|
||||
distanceM: 0, elevationGainM: 0, pointCount: 0
|
||||
};
|
||||
if (!points.length) { metrics.distanceM = 0; metrics.elevationGainM = 0; metrics.pointCount = 0; }
|
||||
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
|
||||
delete gpx.trk;
|
||||
delete gpx.rte;
|
||||
@@ -66,11 +112,12 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
|
||||
const { extensions, ...base } = gpx;
|
||||
// GPX 1.1 namespace identifier: this URI is written into XML, never fetched.
|
||||
// Its exact spelling is part of the GPX standard; HTTPS would be another namespace.
|
||||
const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
|
||||
const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi', '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
|
||||
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
|
||||
'@_lat': point.lat, '@_lon': point.lon,
|
||||
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
|
||||
...(point.recordedAt ? { time: point.recordedAt } : {})
|
||||
...(point.recordedAt ? { time: point.recordedAt } : {}),
|
||||
...(point[WAYPOINT_DERIVED] ? { extensions: { 'wegwichtel:waypointRoutePoint': true } } : {})
|
||||
})) };
|
||||
if (extensions) normalized.extensions = extensions;
|
||||
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
|
||||
|
||||
@@ -246,7 +246,10 @@ export function appendRoute(db, id, gpxFile) {
|
||||
|
||||
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||
const current = readRouteGpx(existing).points;
|
||||
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
||||
// An appended route contributes its complete path, including its stations.
|
||||
// Those points are now part of the path independently of this route's POIs.
|
||||
const appendedPoints = parsed.points.map(({ lat, lon, elevation, recordedAt }) => ({ lat, lon, elevation, recordedAt }));
|
||||
const allPoints = current.concat(appendedPoints).map((point, sequence) => ({ ...point, sequence }));
|
||||
const target = resolveStoredFile(existing.gpx_path);
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const normalized = parseGpx(original, allPoints);
|
||||
@@ -290,8 +293,9 @@ function syncRouteWaypoints(db, routeId) {
|
||||
const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
|
||||
const filename = resolveStoredFile(row.gpx_path);
|
||||
const xml = fs.readFileSync(filename, 'utf8');
|
||||
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
|
||||
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
|
||||
const normalized = parseGpx(gpxWithWaypoints(xml, listPois(db, routeId)), null, { allowEmpty: true });
|
||||
fs.writeFileSync(filename, normalized.normalizedXml);
|
||||
updateBounds(db, routeId, normalized.metrics);
|
||||
}
|
||||
|
||||
function poiCoordinates(fields, existing = {}) {
|
||||
|
||||
Reference in New Issue
Block a user