update editor and add tests
This commit is contained in:
+13
-1
@@ -1,11 +1,23 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
function request(path) {
|
||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||
}
|
||||
|
||||
function routeQuery(position) {
|
||||
const params = new URLSearchParams({ activatedOnly: 'true' });
|
||||
if (position) {
|
||||
params.set('lat', String(position.lat));
|
||||
params.set('lon', String(position.lon));
|
||||
params.set('radiusKm', String(ns.Config.routeRadiusKm));
|
||||
}
|
||||
return `/routes?${params.toString()}`;
|
||||
}
|
||||
|
||||
ns.Api = {
|
||||
health: () => request('/health'),
|
||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||
routes: position => request(routeQuery(position)),
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||
};
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
+215
-69
@@ -3,6 +3,8 @@
|
||||
|
||||
const state = {
|
||||
routes: [],
|
||||
localRoutes: [],
|
||||
localRoutesError: null,
|
||||
route: null,
|
||||
position: null,
|
||||
triggered: new Set(),
|
||||
@@ -20,7 +22,6 @@
|
||||
return $('<div>').text(value ?? '').html();
|
||||
}
|
||||
|
||||
|
||||
function persistRouteProgress() {
|
||||
if (!state.route) return;
|
||||
if (state.persistTimer != null) {
|
||||
@@ -54,7 +55,6 @@
|
||||
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
|
||||
? 'paused'
|
||||
: 'idle';
|
||||
|
||||
if (state.routeState === 'paused') {
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
@@ -89,7 +89,6 @@
|
||||
target.removeAttr('hidden');
|
||||
state.activePage = pageSelector;
|
||||
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
||||
|
||||
if (options.updateHistory === false) return;
|
||||
const historyState = { page: pageSelector, previous };
|
||||
if (options.replace) history.replaceState(historyState, '', pageSelector);
|
||||
@@ -112,31 +111,99 @@
|
||||
].filter(Boolean).join(' ')).includes(searchTerm);
|
||||
}
|
||||
|
||||
function renderRouteItems(listSelector, routes, emptyText) {
|
||||
function routeButton(route, source) {
|
||||
const proximity = route.proximityM == null
|
||||
? ''
|
||||
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||
const sourceLabel = source === 'local' ? '<small>Lokaler Entwurf</small>' : '';
|
||||
return `
|
||||
<li>
|
||||
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}">
|
||||
<strong>${escapeHtml(route.name)}</strong>
|
||||
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||
${sourceLabel}
|
||||
${proximity}
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRouteItems(listSelector, routes, emptyText, source = 'server') {
|
||||
const list = $(listSelector).empty();
|
||||
const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
|
||||
if (!items.length) {
|
||||
list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
|
||||
return;
|
||||
}
|
||||
items.forEach(route => list.append(routeButton(route, source)));
|
||||
}
|
||||
|
||||
items.forEach(route => {
|
||||
const proximity = route.proximityM == null
|
||||
? ''
|
||||
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||
function nextPoiMetric() {
|
||||
const pois = state.route?.pois || [];
|
||||
const pending = pois
|
||||
.filter(poi => !state.triggered.has(poi.id))
|
||||
.slice()
|
||||
.sort((left, right) => Number(left.sequence ?? 0) - Number(right.sequence ?? 0));
|
||||
if (!pending.length) return null;
|
||||
if (!state.position) return { poi: pending[0], distance: null };
|
||||
return nearestPendingPoi(poiMetrics(state.position));
|
||||
}
|
||||
|
||||
function updateRouteControlSummary() {
|
||||
const title = $('#route-control-title');
|
||||
const next = $('#route-control-next');
|
||||
if (!state.route) {
|
||||
title.text('Route wählen');
|
||||
next.text('Aktive und lokale Strecken');
|
||||
return;
|
||||
}
|
||||
|
||||
title.text(state.route.name || 'Route');
|
||||
const metric = nextPoiMetric();
|
||||
if (!metric) {
|
||||
next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
|
||||
return;
|
||||
}
|
||||
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
|
||||
next.text(`Nächster POI: ${metric.poi.title}${distance}`);
|
||||
}
|
||||
|
||||
function renderRouteControlList(selector, routes, source, emptyText) {
|
||||
const list = $(selector).empty();
|
||||
if (!routes.length) {
|
||||
list.append(`<li><span>${escapeHtml(emptyText)}</span></li>`);
|
||||
return;
|
||||
}
|
||||
routes.forEach(route => {
|
||||
const current = state.route && String(state.route.id) === String(route.id)
|
||||
&& (state.route.source || 'server') === source;
|
||||
list.append(`
|
||||
<li>
|
||||
<button type="button" data-route-id="${route.id}">
|
||||
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}>
|
||||
<strong>${escapeHtml(route.name)}</strong>
|
||||
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||
${proximity}
|
||||
<small>${source === 'local' ? 'Lokaler Entwurf' : 'Veröffentlicht'}</small>
|
||||
</button>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRouteControl() {
|
||||
updateRouteControlSummary();
|
||||
renderRouteControlList(
|
||||
'#route-control-server-list',
|
||||
state.routes,
|
||||
'server',
|
||||
'Keine veröffentlichte Strecke vorhanden.'
|
||||
);
|
||||
renderRouteControlList(
|
||||
'#route-control-local-list',
|
||||
state.localRoutes,
|
||||
'local',
|
||||
state.localRoutesError || 'Noch keine lokale Strecke vorhanden.'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
|
||||
const radiusM = ns.Config.routeRadiusKm * 1000;
|
||||
@@ -152,6 +219,9 @@
|
||||
.filter(route => routeMatchesSearch(route, searchTerm))
|
||||
.slice()
|
||||
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
const localRoutes = state.localRoutes
|
||||
.slice()
|
||||
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
|
||||
const nearbyEmptyText = state.position
|
||||
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
|
||||
@@ -161,20 +231,26 @@
|
||||
renderRouteItems(
|
||||
'#all-route-list',
|
||||
allRoutes,
|
||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
|
||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine veröffentlichten Routen vorhanden.'
|
||||
);
|
||||
renderRouteItems(
|
||||
'#local-route-list',
|
||||
localRoutes,
|
||||
state.localRoutesError || 'Noch keine lokalen Strecken gespeichert.',
|
||||
'local'
|
||||
);
|
||||
|
||||
$('#all-routes-summary').text(
|
||||
searchTerm
|
||||
? `${allRoutes.length} von ${routes.length} Routen gefunden.`
|
||||
: `${routes.length} Routen verfügbar.`
|
||||
? `${allRoutes.length} von ${routes.length} veröffentlichten Routen gefunden.`
|
||||
: `${routes.length} veröffentlichte Routen verfügbar.`
|
||||
);
|
||||
renderRouteControl();
|
||||
}
|
||||
|
||||
function initializeRouteSearch() {
|
||||
const input = $('#route-search');
|
||||
if (!input.length || typeof input.autocomplete !== 'function') return;
|
||||
|
||||
input.autocomplete({
|
||||
minLength: 0,
|
||||
source(request, response) {
|
||||
@@ -215,7 +291,6 @@
|
||||
const running = state.routeState === 'running';
|
||||
const paused = state.routeState === 'paused';
|
||||
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
|
||||
|
||||
$('#start-route')
|
||||
.prop('disabled', running)
|
||||
.attr({ 'aria-label': startLabel, title: startLabel });
|
||||
@@ -226,7 +301,6 @@
|
||||
function setActivePoi(poiId) {
|
||||
$('#poi-list [data-poi-id]').removeAttr('aria-current');
|
||||
if (poiId == null) return;
|
||||
|
||||
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
|
||||
}
|
||||
|
||||
@@ -243,7 +317,6 @@
|
||||
ns.AudioPlayer.unload();
|
||||
setActivePoi(null);
|
||||
hideNavigation();
|
||||
|
||||
if (announce && wasActive && !returnToRoutes) {
|
||||
$('#tracking-status').prop('hidden', false).text('Route beendet.');
|
||||
} else {
|
||||
@@ -251,16 +324,18 @@
|
||||
}
|
||||
|
||||
updateRouteControls();
|
||||
|
||||
if (returnToRoutes) {
|
||||
state.route = null;
|
||||
updateRouteControlSummary();
|
||||
renderRouteControl();
|
||||
navigate('#routes-page', { replace: true });
|
||||
} else {
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function pauseRoute() {
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
state.routeState = 'paused';
|
||||
persistRouteProgress();
|
||||
stopRouteSensors();
|
||||
@@ -270,32 +345,37 @@
|
||||
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
|
||||
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
|
||||
updateRouteControls();
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
|
||||
function renderRoute(route) {
|
||||
if (state.routeState !== 'idle') endRoute({ announce: false });
|
||||
|
||||
state.route = route;
|
||||
function suspendCurrentRoute() {
|
||||
if (!state.route) return;
|
||||
if (state.routeState === 'running') state.routeState = 'paused';
|
||||
persistRouteProgress();
|
||||
stopRouteSensors();
|
||||
ns.AudioPlayer.unload();
|
||||
state.activePoiId = null;
|
||||
restoreRouteProgress(route);
|
||||
setActivePoi(null);
|
||||
hideNavigation();
|
||||
}
|
||||
|
||||
function renderRouteView(route) {
|
||||
$('#route-title').text(route.name);
|
||||
$('#route-description').text(route.description || '');
|
||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
||||
$('#route-elevation').text(`${Math.round(route.elevationGainM || 0)} m`);
|
||||
$('#route-poi-count').text(route.pois.length);
|
||||
updateRouteControls();
|
||||
|
||||
const list = $('#poi-list').empty();
|
||||
if (!route.pois.length) {
|
||||
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||
updateRouteControlSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
route.pois.forEach(poi => {
|
||||
list.append(`
|
||||
<li>
|
||||
<button type="button" data-poi-id="${poi.id}">
|
||||
<button type="button" data-poi-select data-poi-id="${poi.id}">
|
||||
<strong>${escapeHtml(poi.title)}</strong>
|
||||
<span>${escapeHtml(poi.description || '')}</span>
|
||||
<span class="poi-proximity" aria-label="Entfernung wird ermittelt">
|
||||
@@ -308,8 +388,18 @@
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
|
||||
updatePoiIndicators(state.position);
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
|
||||
function renderRoute(route) {
|
||||
if (state.route) suspendCurrentRoute();
|
||||
state.route = route;
|
||||
state.activePoiId = null;
|
||||
restoreRouteProgress(route);
|
||||
setActivePoi(null);
|
||||
renderRouteView(route);
|
||||
renderRouteControl();
|
||||
}
|
||||
|
||||
function showPoi(poi) {
|
||||
@@ -334,7 +424,6 @@
|
||||
start = Math.max(0, state.routeProgressIndex - 3);
|
||||
end = Math.min(points.length, state.routeProgressIndex + 201);
|
||||
}
|
||||
|
||||
let nearestIndex = start;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = start; index < end; index += 1) {
|
||||
@@ -344,7 +433,6 @@
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
@@ -360,7 +448,6 @@
|
||||
function poiMetrics(position) {
|
||||
if (!position || !state.route?.pois?.length) return [];
|
||||
const heading = effectiveHeading(position);
|
||||
|
||||
return state.route.pois.map(poi => {
|
||||
const distance = ns.Distance.meters(position, poi);
|
||||
const bearing = ns.Distance.bearing(position, poi);
|
||||
@@ -377,7 +464,6 @@
|
||||
|
||||
function updatePoiIndicators(position) {
|
||||
const metrics = poiMetrics(position);
|
||||
|
||||
metrics.forEach(metric => {
|
||||
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
|
||||
const distance = Math.round(metric.distance);
|
||||
@@ -387,7 +473,6 @@
|
||||
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
indicator.find('small').text(`${distance} m`);
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
@@ -403,20 +488,17 @@
|
||||
const previousProgressIndex = state.routeProgressIndex;
|
||||
const points = state.route?.points || [];
|
||||
if (!points.length) return null;
|
||||
|
||||
const nearestIndex = nearestRoutePointIndex(position);
|
||||
if (nearestIndex == null) return null;
|
||||
|
||||
state.routeProgressIndex = state.routeProgressIndex == null
|
||||
? nearestIndex
|
||||
: Math.max(state.routeProgressIndex, nearestIndex);
|
||||
|
||||
const lastIndex = points.length - 1;
|
||||
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
|
||||
let target = points[targetIndex];
|
||||
let distance = ns.Distance.meters(position, target);
|
||||
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
|
||||
|
||||
while (targetIndex < lastIndex && distance <= reachedRadius) {
|
||||
state.routeProgressIndex = targetIndex;
|
||||
targetIndex += 1;
|
||||
@@ -429,7 +511,6 @@
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: true, target, distance: 0 };
|
||||
}
|
||||
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: false, target, distance };
|
||||
}
|
||||
@@ -437,7 +518,6 @@
|
||||
function showPoiNavigation(position, metric) {
|
||||
const heading = effectiveHeading(position);
|
||||
const distance = Math.round(metric.distance);
|
||||
|
||||
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null
|
||||
@@ -464,14 +544,12 @@
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
||||
return;
|
||||
}
|
||||
|
||||
const bearing = ns.Distance.bearing(position, progress.target);
|
||||
const heading = effectiveHeading(position);
|
||||
const rotation = heading == null
|
||||
? bearing
|
||||
: ns.Distance.normalizeDegrees(bearing - heading);
|
||||
const distance = Math.round(progress.distance);
|
||||
|
||||
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
|
||||
@@ -485,11 +563,9 @@
|
||||
|
||||
function updateNavigation(position) {
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
const progress = updateRouteProgress(position);
|
||||
const metrics = updatePoiIndicators(position);
|
||||
const nextPoi = nearestPendingPoi(metrics);
|
||||
|
||||
if (nextPoi) showPoiNavigation(position, nextPoi);
|
||||
else showRoutePointNavigation(position, progress);
|
||||
}
|
||||
@@ -501,37 +577,36 @@
|
||||
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
|
||||
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
|
||||
}
|
||||
|
||||
if (state.position) updateNavigation(state.position);
|
||||
}
|
||||
|
||||
function evaluatePosition(position) {
|
||||
state.position = position;
|
||||
updateRouteProximity(position);
|
||||
renderRouteControl();
|
||||
if (!state.route) return;
|
||||
|
||||
const metrics = updatePoiIndicators(position);
|
||||
updateRouteControlSummary();
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
const nearest = nearestPendingPoi(metrics);
|
||||
updateNavigation(position);
|
||||
|
||||
$('#tracking-status').text(
|
||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
||||
);
|
||||
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
persistRouteProgress();
|
||||
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
||||
showPoi(nearest.poi);
|
||||
updateNavigation(position);
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRoute() {
|
||||
if (!state.route || state.routeState === 'running') return;
|
||||
|
||||
const resumed = state.routeState === 'paused';
|
||||
if (!resumed) {
|
||||
state.routeProgressIndex = null;
|
||||
@@ -542,11 +617,11 @@
|
||||
state.deviceHeading = null;
|
||||
persistRouteProgress();
|
||||
updateRouteControls();
|
||||
updateRouteControlSummary();
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
|
||||
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
|
||||
|
||||
ns.Geo.start(
|
||||
evaluatePosition,
|
||||
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
|
||||
@@ -557,24 +632,35 @@
|
||||
ns.Orientation.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orientationAvailable) {
|
||||
$('#navigation-mode').text('Norden oben · Navigation');
|
||||
}
|
||||
if (!orientationAvailable) $('#navigation-mode').text('Norden oben · Navigation');
|
||||
}
|
||||
|
||||
function routesFromResponse(response) {
|
||||
if (!Array.isArray(response?.routes)) {
|
||||
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
|
||||
}
|
||||
return response.routes.filter(route => route?.id != null && route?.name != null);
|
||||
return response.routes
|
||||
.filter(route => route?.id != null && route?.name != null)
|
||||
.map(route => ({ ...route, source: 'server', local: false }));
|
||||
}
|
||||
|
||||
function loadLocalRoutes() {
|
||||
try {
|
||||
state.localRoutes = ns.LocalRoutes?.listAppRoutes?.() || [];
|
||||
state.localRoutesError = null;
|
||||
} catch (error) {
|
||||
state.localRoutes = [];
|
||||
state.localRoutesError = error.message || 'Lokale Strecken konnten nicht gelesen werden.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateRouteProximity(position) {
|
||||
state.routes = state.routes.map(route => ({
|
||||
const withProximity = route => ({
|
||||
...route,
|
||||
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
|
||||
}));
|
||||
});
|
||||
state.routes = state.routes.map(withProximity);
|
||||
state.localRoutes = state.localRoutes.map(withProximity);
|
||||
}
|
||||
|
||||
async function initializeLocation(context) {
|
||||
@@ -597,7 +683,38 @@
|
||||
}
|
||||
|
||||
function restoreRouteLists() {
|
||||
if (state.routes.length) renderRoutes();
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
renderRoutes();
|
||||
}
|
||||
|
||||
function setRouteControlOpen(open) {
|
||||
$('#route-control-dropdown').prop('hidden', !open);
|
||||
$('#route-control-button').attr('aria-expanded', String(open));
|
||||
}
|
||||
|
||||
async function selectRoute(source, id) {
|
||||
setRouteControlOpen(false);
|
||||
const route = source === 'local'
|
||||
? ns.LocalRoutes.getAppRoute(id)
|
||||
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
}
|
||||
|
||||
function refreshCurrentLocalRoute(id) {
|
||||
if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||
persistRouteProgress();
|
||||
try {
|
||||
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||
renderRouteView(state.route);
|
||||
renderRouteControl();
|
||||
} catch {
|
||||
state.route = null;
|
||||
state.routeState = 'idle';
|
||||
hideNavigation();
|
||||
navigate('#routes-page', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
@@ -607,13 +724,11 @@
|
||||
$('#route-search').val('').trigger('input').trigger('focus');
|
||||
});
|
||||
|
||||
activate('[data-route-id]', async function () {
|
||||
activate('[data-route-select]', async function () {
|
||||
const button = $(this);
|
||||
button.prop('disabled', true);
|
||||
try {
|
||||
const route = await ns.Api.route(button.data('route-id'));
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
|
||||
} catch (error) {
|
||||
alert(error.responseJSON?.message || error.statusText || error.message);
|
||||
} finally {
|
||||
@@ -621,7 +736,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
activate('[data-poi-id]', function () {
|
||||
activate('[data-poi-select]', function () {
|
||||
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
||||
if (poi) showPoi(poi);
|
||||
});
|
||||
@@ -632,14 +747,33 @@
|
||||
else navigate(target, { replace: true });
|
||||
});
|
||||
|
||||
activate('#route-control-button', function () {
|
||||
setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
|
||||
});
|
||||
|
||||
activate('#route-control-new', function () {
|
||||
setRouteControlOpen(false);
|
||||
ns.EditorUi?.openNewLocalRoute?.();
|
||||
});
|
||||
|
||||
activate('#editor-button', function () {
|
||||
setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
$(document).on('pointerup', event => {
|
||||
if ($('#route-control-dropdown').prop('hidden')) return;
|
||||
if ($(event.target).closest('.route-control').length) return;
|
||||
setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
$(document).on('keydown', event => {
|
||||
if (event.key === 'Escape') setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
activate('#start-route', async function () {
|
||||
await startRoute();
|
||||
});
|
||||
|
||||
activate('#pause-route', function () {
|
||||
pauseRoute();
|
||||
});
|
||||
|
||||
activate('#pause-route', pauseRoute);
|
||||
activate('#stop-route', function () {
|
||||
endRoute({ returnToRoutes: true });
|
||||
});
|
||||
@@ -654,12 +788,19 @@
|
||||
if (event.detail?.enabled) persistRouteProgress();
|
||||
});
|
||||
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => {
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
refreshCurrentLocalRoute(event.detail?.id);
|
||||
renderRoutes();
|
||||
ns.EditorUi?.refresh?.();
|
||||
});
|
||||
|
||||
globalThis.addEventListener('pagehide', persistRouteProgress);
|
||||
globalThis.addEventListener('popstate', event => {
|
||||
const page = event.state?.page || '#routes-page';
|
||||
navigate(page, { updateHistory: false, instant: true });
|
||||
});
|
||||
|
||||
globalThis.addEventListener('pageshow', restoreRouteLists);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') restoreRouteLists();
|
||||
@@ -669,6 +810,9 @@
|
||||
|
||||
ns.App = {
|
||||
navigate,
|
||||
currentPosition() {
|
||||
return state.position ? { ...state.position } : null;
|
||||
},
|
||||
|
||||
async initialize(context) {
|
||||
bindEvents();
|
||||
@@ -677,12 +821,14 @@
|
||||
|
||||
const response = await ns.Api.routes(null);
|
||||
state.routes = routesFromResponse(response);
|
||||
loadLocalRoutes();
|
||||
context.markStep('routes', 'done');
|
||||
|
||||
renderRoutes();
|
||||
initializeRouteSearch();
|
||||
updateRouteControls();
|
||||
hideNavigation();
|
||||
setRouteControlOpen(false);
|
||||
|
||||
await initializeLocation(context);
|
||||
}
|
||||
|
||||
+271
-161
@@ -6,10 +6,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const ids = Object.freeze({
|
||||
addPoi: 'editor-add-poi',
|
||||
button: 'editor-button',
|
||||
coordinateGrid: 'editor-coordinate-grid',
|
||||
close: 'editor-close',
|
||||
dialog: 'editor-dialog',
|
||||
entryTitle: 'editor-entry-title',
|
||||
newRoute: 'editor-new-route',
|
||||
poiAudio: 'editor-poi-audio',
|
||||
poiCancel: 'editor-poi-cancel',
|
||||
@@ -23,53 +21,33 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
poiList: 'editor-poi-list',
|
||||
poiLon: 'editor-poi-lon',
|
||||
poiRadius: 'editor-poi-radius',
|
||||
poiSave: 'editor-poi-save',
|
||||
poiTitle: 'editor-poi-title',
|
||||
routeActivated: 'editor-route-activated',
|
||||
routeBack: 'editor-route-back',
|
||||
routeDescription: 'editor-route-description',
|
||||
routeDetailTitle: 'editor-route-detail-title',
|
||||
routeDetailView: 'editor-route-detail-view',
|
||||
routeForm: 'editor-route-form',
|
||||
routeGpx: 'editor-route-gpx',
|
||||
routeId: 'editor-route-id',
|
||||
routeList: 'editor-route-list',
|
||||
routeListTitle: 'editor-route-list-title',
|
||||
routeListView: 'editor-route-list-view',
|
||||
routeName: 'editor-route-name',
|
||||
routeSave: 'editor-route-save',
|
||||
status: 'editor-status',
|
||||
title: 'editor-dialog-title'
|
||||
status: 'editor-status'
|
||||
});
|
||||
|
||||
|
||||
const classes = Object.freeze({
|
||||
button: 'editor-button',
|
||||
dialog: 'editor-dialog',
|
||||
dialogHeader: 'editor-dialog-header',
|
||||
dialogShell: 'editor-dialog-shell',
|
||||
entryPanel: 'editor-entry-panel',
|
||||
form: 'editor-form',
|
||||
iconButton: 'editor-icon-button',
|
||||
iconRow: 'editor-icon-row',
|
||||
muted: 'editor-muted',
|
||||
poiItem: 'editor-poi-item',
|
||||
poiList: 'editor-poi-list',
|
||||
poiToolbar: 'editor-poi-toolbar',
|
||||
primaryButton: 'editor-primary-button',
|
||||
routeItem: 'editor-route-item',
|
||||
routeList: 'editor-route-list',
|
||||
roundButton: 'editor-round-button',
|
||||
secondaryButton: 'editor-secondary-button',
|
||||
status: 'editor-status',
|
||||
toolbar: 'editor-toolbar',
|
||||
toolbarBack: 'editor-toolbar-back',
|
||||
view: 'editor-view'
|
||||
routeGroup: 'editor-route-group',
|
||||
routeItem: 'editor-route-item'
|
||||
});
|
||||
|
||||
const state = {
|
||||
currentPoi: null,
|
||||
currentRoute: null,
|
||||
currentSource: null,
|
||||
pois: [],
|
||||
routes: []
|
||||
};
|
||||
@@ -194,6 +172,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
const raw = await response.text();
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'same-origin',
|
||||
@@ -225,22 +219,6 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
const raw = await response.text();
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function routeArray(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.routes)) return payload.routes;
|
||||
@@ -273,55 +251,71 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return button;
|
||||
}
|
||||
|
||||
function routeSource(route) {
|
||||
return route?._source || route?.source || 'server';
|
||||
}
|
||||
|
||||
function routeItem(route) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
item.dataset.routeId = routeId(route);
|
||||
item.dataset.routeSource = routeSource(route);
|
||||
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', routeName(route));
|
||||
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
||||
appendText(label, 'small', routeSource(route) === 'local' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke');
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function routeGroup(title) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeGroup;
|
||||
item.textContent = title;
|
||||
return item;
|
||||
}
|
||||
|
||||
function poiItem(poi, index) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.poiItem;
|
||||
item.dataset.poiId = poiId(poi);
|
||||
|
||||
const title = poiTitle(poi);
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', title);
|
||||
appendText(label, 'small', `Position ${index + 1}`);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
||||
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
|
||||
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderRouteGroup(list, title, routes, emptyText) {
|
||||
list.appendChild(routeGroup(title));
|
||||
if (!routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
appendText(item, 'span', emptyText);
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
routes.forEach(route => list.appendChild(routeItem(route)));
|
||||
}
|
||||
|
||||
function renderRouteList() {
|
||||
const list = byId(ids.routeList);
|
||||
if (!list) return;
|
||||
list.textContent = '';
|
||||
if (!state.routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
state.routes.forEach(route => list.appendChild(routeItem(route)));
|
||||
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
||||
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
|
||||
renderRouteGroup(list, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.');
|
||||
renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.');
|
||||
}
|
||||
|
||||
function renderPoiList() {
|
||||
@@ -338,12 +332,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
||||
}
|
||||
|
||||
function localRouteSummaries() {
|
||||
if (!ns.LocalRoutes) return [];
|
||||
return ns.LocalRoutes.list().map(routePackage => ({
|
||||
...routePackage.route,
|
||||
id: routePackage.id,
|
||||
_source: 'local'
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
setStatus('Strecken werden geladen …');
|
||||
const payload = await readJson('/routes');
|
||||
state.routes = routeArray(payload);
|
||||
const payload = await readJson('/routes?activatedOnly=true');
|
||||
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
|
||||
let localRoutes = [];
|
||||
try {
|
||||
localRoutes = localRouteSummaries();
|
||||
} catch (error) {
|
||||
setStatus(error.message, true);
|
||||
}
|
||||
state.routes = [...serverRoutes, ...localRoutes];
|
||||
renderRouteList();
|
||||
setStatus('');
|
||||
if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
|
||||
}
|
||||
|
||||
async function openEditor() {
|
||||
@@ -358,6 +368,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await guarded(loadRoutes);
|
||||
}
|
||||
|
||||
async function openNewLocalRoute() {
|
||||
await openEditor();
|
||||
newRoute();
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
@@ -377,11 +392,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return {
|
||||
description: byId(ids.poiDescription)?.value.trim() || '',
|
||||
lat: numberOrNull(byId(ids.poiLat)?.value),
|
||||
latitude: numberOrNull(byId(ids.poiLat)?.value),
|
||||
lon: numberOrNull(byId(ids.poiLon)?.value),
|
||||
longitude: numberOrNull(byId(ids.poiLon)?.value),
|
||||
radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||
sortOrder: state.currentPoi?.sortOrder ?? state.pois.length,
|
||||
triggerRadiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||
sequence: state.currentPoi?.sequence ?? state.pois.length,
|
||||
title: byId(ids.poiTitle)?.value.trim() || ''
|
||||
};
|
||||
}
|
||||
@@ -391,9 +404,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||
byId(ids.routeDescription).value = text(route?.description);
|
||||
byId(ids.routeGpx).value = '';
|
||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||
? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
|
||||
: 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
|
||||
if (state.currentSource === 'local') {
|
||||
byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
|
||||
} else {
|
||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
|
||||
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
|
||||
}
|
||||
}
|
||||
|
||||
function fillPoiForm(poi) {
|
||||
@@ -402,7 +419,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||
byId(ids.poiRadius).value = text(poi?.radiusM ?? poi?.radius ?? 25);
|
||||
byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
|
||||
byId(ids.poiAudio).value = '';
|
||||
byId(ids.poiImages).value = '';
|
||||
}
|
||||
@@ -411,23 +428,47 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const id = routeId(route);
|
||||
if (!id) return;
|
||||
setStatus('Streckendetails werden geladen …');
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = detail;
|
||||
state.pois = poiArray(detail);
|
||||
fillRouteForm(detail);
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const routePackage = ns.LocalRoutes.get(id);
|
||||
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
|
||||
state.currentRoute = {
|
||||
...routePackage.route,
|
||||
id: routePackage.id,
|
||||
gpx: routePackage.gpx,
|
||||
_source: 'local'
|
||||
};
|
||||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = { ...detail, _source: 'server' };
|
||||
state.pois = poiArray(detail);
|
||||
}
|
||||
|
||||
state.currentPoi = null;
|
||||
fillRouteForm(state.currentRoute);
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
state.currentRoute = null;
|
||||
state.currentSource = 'local';
|
||||
state.currentRoute = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'),
|
||||
_source: 'local'
|
||||
};
|
||||
state.currentPoi = null;
|
||||
state.pois = [];
|
||||
fillRouteForm({});
|
||||
fillRouteForm(state.currentRoute);
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.');
|
||||
}
|
||||
|
||||
function formDataFromRoute(values) {
|
||||
@@ -439,52 +480,137 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveLocalRoute(values) {
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
let gpx = state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
|
||||
if (file) {
|
||||
gpx = await ns.LocalRoutes.readFileAsText(file);
|
||||
const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
|
||||
if (!state.pois.length && importedPois.length) state.pois = importedPois;
|
||||
}
|
||||
|
||||
const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
|
||||
const saved = ns.LocalRoutes.save({
|
||||
id,
|
||||
gpx,
|
||||
route: {
|
||||
...(state.currentRoute || {}),
|
||||
id,
|
||||
name: values.name,
|
||||
description: values.description
|
||||
},
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.currentSource = 'local';
|
||||
state.pois = saved.pois;
|
||||
setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
|
||||
await loadRoutes();
|
||||
await loadRoute({ id: saved.id, _source: 'local' });
|
||||
}
|
||||
|
||||
async function saveServerRoute(values) {
|
||||
const id = routeId(state.currentRoute);
|
||||
if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.');
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
const path = `/routes/${encodeURIComponent(id)}`;
|
||||
const payload = file
|
||||
? await sendForm(path, 'PUT', formDataFromRoute(values))
|
||||
: await sendJson(path, 'PUT', values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = { ...saved, _source: 'server' };
|
||||
setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
|
||||
await loadRoutes();
|
||||
await loadRoute({ id, _source: 'server' });
|
||||
}
|
||||
|
||||
async function saveRoute() {
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
||||
const id = byId(ids.routeId).value;
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
|
||||
setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
|
||||
await loadRoutes();
|
||||
if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
|
||||
if (state.currentSource === 'server') await saveServerRoute(values);
|
||||
else await saveLocalRoute(values);
|
||||
}
|
||||
|
||||
function currentPosition() {
|
||||
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
|
||||
}
|
||||
|
||||
function newPoi() {
|
||||
state.currentPoi = null;
|
||||
fillPoiForm({ radiusM: 25 });
|
||||
const position = currentPosition();
|
||||
fillPoiForm({
|
||||
lat: position?.lat ?? '',
|
||||
lon: position?.lon ?? '',
|
||||
triggerRadiusM: 25
|
||||
});
|
||||
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
function editPoi(poi) {
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
byId(ids.poiFormTitle).textContent = 'Station bearbeiten';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const route = state.currentRoute;
|
||||
const currentRouteId = routeId(route);
|
||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||
const audioFile = byId(ids.poiAudio)?.files?.[0];
|
||||
const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
|
||||
const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
|
||||
const images = [...(existingPoi?.images || [])];
|
||||
for (const imageFile of imageFiles) {
|
||||
const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
|
||||
images.push({
|
||||
...image,
|
||||
id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
|
||||
caption: ''
|
||||
});
|
||||
}
|
||||
return { audio, images };
|
||||
}
|
||||
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
const method = currentPoiId ? 'PUT' : 'POST';
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
async function persistLocalCurrentRoute() {
|
||||
const id = routeId(state.currentRoute);
|
||||
if (!id) throw new Error('Speichere zuerst die Strecke.');
|
||||
const saved = ns.LocalRoutes.save({
|
||||
id,
|
||||
gpx: state.currentRoute.gpx,
|
||||
route: state.currentRoute,
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.pois = saved.pois;
|
||||
return saved;
|
||||
}
|
||||
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Station gespeichert.');
|
||||
await loadRoute(route);
|
||||
async function saveLocalPoi(values) {
|
||||
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||
throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
|
||||
}
|
||||
const currentId = Number(byId(ids.poiId).value);
|
||||
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
|
||||
const media = await mediaFromLocalForm(existing, identifier);
|
||||
const savedPoi = {
|
||||
...(existing || {}),
|
||||
...values,
|
||||
...media,
|
||||
id: identifier,
|
||||
sequence: existing?.sequence ?? state.pois.length
|
||||
};
|
||||
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
|
||||
if (index >= 0) state.pois[index] = savedPoi;
|
||||
else state.pois.push(savedPoi);
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('GPX-POI und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||||
}
|
||||
|
||||
async function uploadOne(path, file) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return sendForm(path, 'POST', data);
|
||||
}
|
||||
|
||||
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
||||
@@ -496,30 +622,57 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOne(path, file) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return sendForm(path, 'POST', data);
|
||||
async function saveServerPoi(values) {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
const method = currentPoiId ? 'PUT' : 'POST';
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Station gespeichert.');
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||
else await saveServerPoi(values);
|
||||
}
|
||||
|
||||
function reorderPois(index, offset) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= state.pois.length) return false;
|
||||
const reordered = [...state.pois];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
state.pois = reordered.map((poi, sequence) => ({ ...poi, sequence }));
|
||||
renderPoiList();
|
||||
return true;
|
||||
}
|
||||
|
||||
function swapPois(index, offset) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= state.pois.length) return;
|
||||
const reordered = [...state.pois];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
state.pois = reordered;
|
||||
renderPoiList();
|
||||
if (!reorderPois(index, offset)) return;
|
||||
guarded(savePoiOrder);
|
||||
}
|
||||
|
||||
async function savePoiOrder() {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) return;
|
||||
if (state.currentSource === 'local') {
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Lokale POI-Reihenfolge gespeichert.');
|
||||
return;
|
||||
}
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
const currentPoiId = poiId(poi);
|
||||
if (!currentPoiId) return null;
|
||||
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||
return sendJson(path, 'PUT', { ...poi, position: index, sortOrder: index });
|
||||
return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
|
||||
}).filter(Boolean);
|
||||
await Promise.all(jobs);
|
||||
setStatus('Reihenfolge gespeichert.');
|
||||
@@ -527,7 +680,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function routeForItem(target) {
|
||||
const item = target.closest('[data-route-id]');
|
||||
return state.routes.find(route => routeId(route) === item?.dataset.routeId);
|
||||
const id = item?.dataset.routeId;
|
||||
const source = item?.dataset.routeSource;
|
||||
return state.routes.find(route => routeId(route) === id && routeSource(route) === source);
|
||||
}
|
||||
|
||||
function poiForItem(target) {
|
||||
@@ -580,58 +735,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||
}
|
||||
|
||||
|
||||
function classNames() {
|
||||
return Object.values(classes);
|
||||
}
|
||||
|
||||
function testHooks() {
|
||||
return Object.freeze({
|
||||
apiUrl,
|
||||
classNames,
|
||||
closeEditor,
|
||||
fillPoiForm,
|
||||
fillRouteForm,
|
||||
handleResponse,
|
||||
isActivation,
|
||||
loadRoute,
|
||||
loadRoutes,
|
||||
newPoi,
|
||||
newRoute,
|
||||
poiArray,
|
||||
poiFromForm,
|
||||
poiItem,
|
||||
parseJson,
|
||||
readJson,
|
||||
renderPoiList,
|
||||
renderRouteList,
|
||||
routeArray,
|
||||
routeFromForm,
|
||||
routeItem,
|
||||
savePoi,
|
||||
sendForm,
|
||||
sendJson,
|
||||
setDialogExpanded,
|
||||
setDisabled,
|
||||
setStatus,
|
||||
savePoiOrder,
|
||||
saveRoute,
|
||||
state,
|
||||
swapPois,
|
||||
uploadPoiMedia
|
||||
});
|
||||
}
|
||||
|
||||
function editorApi() {
|
||||
const api = {
|
||||
return Object.freeze({
|
||||
close: closeEditor,
|
||||
classNames,
|
||||
open: openEditor,
|
||||
openNewLocalRoute,
|
||||
refresh: loadRoutes
|
||||
};
|
||||
|
||||
if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks();
|
||||
return Object.freeze(api);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
@@ -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