update editor and add tests
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_PREFIX = 'wegwichtel.local-routes.v1';
|
||||
const INDEX_KEY = `${STORAGE_PREFIX}.index`;
|
||||
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||
let poiCounter = 0;
|
||||
|
||||
function storage() {
|
||||
const target = globalThis.localStorage;
|
||||
if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.');
|
||||
return target;
|
||||
}
|
||||
|
||||
function routeKey(id, suffix) {
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
|
||||
function readIndex() {
|
||||
const raw = storage().getItem(INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(ids) {
|
||||
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
|
||||
}
|
||||
|
||||
function createRouteId() {
|
||||
if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`;
|
||||
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function createPoiId() {
|
||||
poiCounter = (poiCounter + 1) % 1000;
|
||||
return (Date.now() * 1000) + poiCounter;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function emptyGpx(name = 'Neue Strecke') {
|
||||
const safeName = escapeXml(name);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
||||
+ `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n`
|
||||
+ ` <metadata><name>${safeName}</name></metadata>\n`
|
||||
+ ` <trk><name>${safeName}</name><trkseg></trkseg></trk>\n`
|
||||
+ '</gpx>';
|
||||
}
|
||||
|
||||
function parseXml(gpx) {
|
||||
const parser = new DOMParser();
|
||||
const document = parser.parseFromString(String(gpx || ''), 'application/xml');
|
||||
if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
|
||||
throw new Error('Die GPX-Datei ist nicht gültig.');
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function directChildrenByLocalName(parent, localName) {
|
||||
return Array.from(parent?.children || []).filter(node => node.localName === localName);
|
||||
}
|
||||
|
||||
function firstDescendantByLocalName(parent, localName) {
|
||||
return Array.from(parent?.getElementsByTagName('*') || []).find(node => node.localName === localName) || null;
|
||||
}
|
||||
|
||||
function textOfChild(parent, localName) {
|
||||
return directChildrenByLocalName(parent, localName)[0]?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
function numberAttribute(node, name) {
|
||||
const value = Number.parseFloat(node?.getAttribute(name));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function waypointId(node) {
|
||||
const extension = firstDescendantByLocalName(node, 'poiId');
|
||||
const parsed = Number(extension?.textContent);
|
||||
return Number.isSafeInteger(parsed) ? parsed : createPoiId();
|
||||
}
|
||||
|
||||
function parseWaypoints(gpx) {
|
||||
const document = parseXml(gpx);
|
||||
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
|
||||
id: waypointId(node),
|
||||
title: textOfChild(node, 'name') || `Station ${index + 1}`,
|
||||
description: textOfChild(node, 'desc'),
|
||||
lat: numberAttribute(node, 'lat'),
|
||||
lon: numberAttribute(node, 'lon'),
|
||||
triggerRadiusM: 25,
|
||||
sequence: index,
|
||||
audio: null,
|
||||
images: []
|
||||
})).filter(poi => poi.lat != null && poi.lon != null);
|
||||
}
|
||||
|
||||
function createElement(document, localName, text = null) {
|
||||
const element = document.createElementNS(GPX_NAMESPACE, localName);
|
||||
if (text != null) element.textContent = String(text);
|
||||
return element;
|
||||
}
|
||||
|
||||
function updateMetadataName(document, name) {
|
||||
const root = document.documentElement;
|
||||
let metadata = directChildrenByLocalName(root, 'metadata')[0];
|
||||
if (!metadata) {
|
||||
metadata = createElement(document, 'metadata');
|
||||
root.insertBefore(metadata, root.firstChild);
|
||||
}
|
||||
let nameElement = directChildrenByLocalName(metadata, 'name')[0];
|
||||
if (!nameElement) {
|
||||
nameElement = createElement(document, 'name');
|
||||
metadata.appendChild(nameElement);
|
||||
}
|
||||
nameElement.textContent = name;
|
||||
}
|
||||
|
||||
function syncGpx(gpx, route, pois) {
|
||||
const document = parseXml(gpx || emptyGpx(route?.name));
|
||||
const root = document.documentElement;
|
||||
updateMetadataName(document, route?.name || 'Neue Strecke');
|
||||
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
|
||||
|
||||
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
orderedPois.forEach((poi, index) => {
|
||||
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
||||
throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`);
|
||||
}
|
||||
const waypoint = createElement(document, 'wpt');
|
||||
waypoint.setAttribute('lat', String(Number(poi.lat)));
|
||||
waypoint.setAttribute('lon', String(Number(poi.lon)));
|
||||
waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`));
|
||||
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
|
||||
const extensions = createElement(document, 'extensions');
|
||||
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
||||
poiId.textContent = String(poi.id);
|
||||
extensions.appendChild(poiId);
|
||||
waypoint.appendChild(extensions);
|
||||
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
|
||||
|| directChildrenByLocalName(root, 'trk')[0]
|
||||
|| null;
|
||||
root.insertBefore(waypoint, routeOrTrack);
|
||||
});
|
||||
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
|
||||
function readPackage(id) {
|
||||
const target = storage();
|
||||
const gpx = target.getItem(routeKey(id, 'gpx'));
|
||||
const rawJson = target.getItem(routeKey(id, 'json'));
|
||||
if (!gpx || !rawJson) return null;
|
||||
try {
|
||||
const metadata = JSON.parse(rawJson);
|
||||
return {
|
||||
id: String(id),
|
||||
gpx,
|
||||
route: metadata.route || {},
|
||||
pois: Array.isArray(metadata.pois) ? metadata.pois : [],
|
||||
schemaVersion: metadata.schemaVersion || 1
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreValue(target, key, value) {
|
||||
if (value == null) target.removeItem(key);
|
||||
else target.setItem(key, value);
|
||||
}
|
||||
|
||||
function notify(id, action) {
|
||||
if (typeof document === 'undefined' || typeof CustomEvent === 'undefined') return;
|
||||
document.dispatchEvent(new CustomEvent('wegwichtel:local-routes-changed', {
|
||||
detail: { action, id: String(id) }
|
||||
}));
|
||||
}
|
||||
|
||||
function savePackage(routePackage) {
|
||||
const target = storage();
|
||||
const id = String(routePackage.id || routePackage.route?.id || createRouteId());
|
||||
const previousIndex = target.getItem(INDEX_KEY);
|
||||
const gpxKey = routeKey(id, 'gpx');
|
||||
const jsonKey = routeKey(id, 'json');
|
||||
const previousGpx = target.getItem(gpxKey);
|
||||
const previousJson = target.getItem(jsonKey);
|
||||
const now = new Date().toISOString();
|
||||
const inputRoute = routePackage.route || {};
|
||||
const route = {
|
||||
id,
|
||||
name: String(inputRoute.name || ''),
|
||||
description: String(inputRoute.description || ''),
|
||||
createdAt: inputRoute.createdAt || now,
|
||||
updatedAt: now
|
||||
};
|
||||
const pois = (routePackage.pois || []).map((poi, index) => ({
|
||||
...poi,
|
||||
id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
|
||||
sequence: index
|
||||
}));
|
||||
const gpx = syncGpx(routePackage.gpx || emptyGpx(route.name), route, pois);
|
||||
const metadata = JSON.stringify({ schemaVersion: 1, route, pois });
|
||||
|
||||
try {
|
||||
target.setItem(gpxKey, gpx);
|
||||
target.setItem(jsonKey, metadata);
|
||||
writeIndex([...readIndex(), id]);
|
||||
} catch (error) {
|
||||
try {
|
||||
restoreValue(target, gpxKey, previousGpx);
|
||||
restoreValue(target, jsonKey, previousJson);
|
||||
restoreValue(target, INDEX_KEY, previousIndex);
|
||||
} catch {
|
||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||
}
|
||||
const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||
throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
|
||||
}
|
||||
|
||||
notify(id, 'saved');
|
||||
return readPackage(id);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const target = storage();
|
||||
const routeId = String(id);
|
||||
target.removeItem(routeKey(routeId, 'gpx'));
|
||||
target.removeItem(routeKey(routeId, 'json'));
|
||||
writeIndex(readIndex().filter(item => item !== routeId));
|
||||
notify(routeId, 'removed');
|
||||
}
|
||||
|
||||
function listPackages() {
|
||||
return readIndex().map(readPackage).filter(Boolean);
|
||||
}
|
||||
|
||||
function routePoints(gpx) {
|
||||
const document = parseXml(gpx);
|
||||
const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
|
||||
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
|
||||
const selected = trackPoints.length ? trackPoints : routePoints;
|
||||
return selected.map(node => {
|
||||
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
|
||||
return {
|
||||
lat: numberAttribute(node, 'lat'),
|
||||
lon: numberAttribute(node, 'lon'),
|
||||
elevation: Number.isFinite(elevation) ? elevation : null,
|
||||
recordedAt: textOfChild(node, 'time') || null
|
||||
};
|
||||
}).filter(point => point.lat != null && point.lon != null);
|
||||
}
|
||||
|
||||
function radians(value) {
|
||||
return value * (Math.PI / 180);
|
||||
}
|
||||
|
||||
function meters(left, right) {
|
||||
const earthRadius = 6371008.8;
|
||||
const latDelta = radians(right.lat - left.lat);
|
||||
const lonDelta = radians(right.lon - left.lon);
|
||||
const leftLat = radians(left.lat);
|
||||
const rightLat = radians(right.lat);
|
||||
const value = Math.sin(latDelta / 2) ** 2
|
||||
+ Math.cos(leftLat) * Math.cos(rightLat) * (Math.sin(lonDelta / 2) ** 2);
|
||||
return earthRadius * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function metrics(points) {
|
||||
if (!points.length) {
|
||||
return {
|
||||
bounds: null,
|
||||
center: null,
|
||||
distanceM: 0,
|
||||
elevationGainM: 0,
|
||||
start: null
|
||||
};
|
||||
}
|
||||
let distanceM = 0;
|
||||
let elevationGainM = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
distanceM += meters(points[index - 1], points[index]);
|
||||
const previous = points[index - 1].elevation;
|
||||
const current = points[index].elevation;
|
||||
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) elevationGainM += current - previous;
|
||||
}
|
||||
const lats = points.map(point => point.lat);
|
||||
const lons = points.map(point => point.lon);
|
||||
const bounds = {
|
||||
minLat: Math.min(...lats),
|
||||
minLon: Math.min(...lons),
|
||||
maxLat: Math.max(...lats),
|
||||
maxLon: Math.max(...lons)
|
||||
};
|
||||
return {
|
||||
bounds,
|
||||
center: {
|
||||
lat: (bounds.minLat + bounds.maxLat) / 2,
|
||||
lon: (bounds.minLon + bounds.maxLon) / 2
|
||||
},
|
||||
distanceM,
|
||||
elevationGainM,
|
||||
start: { lat: points[0].lat, lon: points[0].lon }
|
||||
};
|
||||
}
|
||||
|
||||
function appPoi(poi) {
|
||||
return {
|
||||
id: Number(poi.id),
|
||||
title: poi.title || 'Unbenannte Station',
|
||||
description: poi.description || '',
|
||||
lat: Number(poi.lat),
|
||||
lon: Number(poi.lon),
|
||||
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
|
||||
sequence: Number(poi.sequence) || 0,
|
||||
audioUrl: poi.audio?.dataUrl || null,
|
||||
images: (poi.images || []).map((image, index) => ({
|
||||
id: image.id || `${poi.id}-${index}`,
|
||||
caption: image.caption || '',
|
||||
sequence: index,
|
||||
url: image.dataUrl
|
||||
})).filter(image => image.url)
|
||||
};
|
||||
}
|
||||
|
||||
function toAppRoute(routePackage) {
|
||||
const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
|
||||
const parsedPoints = routePoints(routePackage.gpx);
|
||||
const points = parsedPoints.length
|
||||
? parsedPoints
|
||||
: pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
|
||||
const routeMetrics = metrics(points);
|
||||
return {
|
||||
id: String(routePackage.id),
|
||||
source: 'local',
|
||||
local: true,
|
||||
name: routePackage.route?.name || 'Unbenannte Strecke',
|
||||
description: routePackage.route?.description || '',
|
||||
schoolName: 'Lokal gespeichert',
|
||||
status: 'local',
|
||||
activatedAt: null,
|
||||
points,
|
||||
pois,
|
||||
pointCount: points.length,
|
||||
gpxUrl: null,
|
||||
createdAt: routePackage.route?.createdAt || null,
|
||||
updatedAt: routePackage.route?.updatedAt || null,
|
||||
...routeMetrics
|
||||
};
|
||||
}
|
||||
|
||||
function listAppRoutes() {
|
||||
return listPackages().map(toAppRoute);
|
||||
}
|
||||
|
||||
function getAppRoute(id) {
|
||||
const routePackage = readPackage(id);
|
||||
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
|
||||
return toAppRoute(routePackage);
|
||||
}
|
||||
|
||||
function readFileAsText(file) {
|
||||
if (!file) return Promise.resolve('');
|
||||
if (typeof file.text === 'function') return file.text();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fileToDataUrl(file) {
|
||||
if (!file) return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve({
|
||||
dataUrl: String(reader.result || ''),
|
||||
name: file.name || 'Datei',
|
||||
size: Number(file.size) || 0,
|
||||
type: file.type || 'application/octet-stream'
|
||||
}), { once: true });
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
ns.LocalRoutes = Object.freeze({
|
||||
STORAGE_PREFIX,
|
||||
createPoiId,
|
||||
createRouteId,
|
||||
emptyGpx,
|
||||
fileToDataUrl,
|
||||
get: readPackage,
|
||||
getAppRoute,
|
||||
list: listPackages,
|
||||
listAppRoutes,
|
||||
parseWaypoints,
|
||||
readFileAsText,
|
||||
remove,
|
||||
save: savePackage,
|
||||
syncGpx,
|
||||
toAppRoute
|
||||
});
|
||||
}(globalThis.Wegwichtel));
|
||||
Reference in New Issue
Block a user