Files
Wegwichtel/public/js/app.js
T
Florian Zumpe b415fc2b63
Sonarqube Scanner / Build and analyze (push) Successful in 57s
fix sonarqube issues
2026-09-26 21:51:37 +02:00

846 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function (ns, $) {
'use strict';
const state = {
routes: [],
localRoutes: [],
localRoutesError: null,
route: null,
position: null,
triggered: new Set(),
routeState: 'idle',
activePage: null,
routeProgressIndex: null,
deviceHeading: null,
viewed: new Set(),
playedAudio: new Set(),
activePoiId: null,
persistTimer: null
};
function escapeHtml(value) {
return $('<div>').text(value ?? '').html();
}
function persistRouteProgress() {
if (!state.route) return;
if (state.persistTimer != null) {
globalThis.clearTimeout(state.persistTimer);
state.persistTimer = null;
}
ns.PrivacyStorage.saveRouteState(
state.route,
state.routeState,
state.routeProgressIndex,
[...state.triggered],
[...state.viewed],
[...state.playedAudio]
);
}
function scheduleRouteProgressPersist() {
if (!state.route || state.persistTimer != null) return;
state.persistTimer = globalThis.setTimeout(persistRouteProgress, 1200);
}
function restoreRouteProgress(route) {
const saved = ns.PrivacyStorage.activateRoute(route);
state.triggered = new Set(saved?.visitedPoiIds ?? []);
state.viewed = new Set(saved?.viewedPoiIds ?? []);
state.playedAudio = new Set(saved?.playedAudioPoiIds ?? []);
state.routeProgressIndex = Number.isInteger(saved?.routeProgressIndex)
? saved.routeProgressIndex
: null;
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
? 'paused'
: 'idle';
if (state.routeState === 'paused') {
$('#tracking-status')
.prop('hidden', false)
.text('Gespeicherter Streckenfortschritt gefunden. Die Route kann fortgesetzt werden.');
} else {
$('#tracking-status').prop('hidden', true).text('');
}
}
function activate(selector, handler) {
$(document)
.on('pointerup', selector, function (event) {
const pointer = event.originalEvent;
if (pointer?.isPrimary === false) return;
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
event.preventDefault();
handler.call(this, event);
})
.on('keydown', selector, function (event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handler.call(this, event);
});
}
function navigate(pageSelector, options = {}) {
const target = $(pageSelector);
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
const previous = state.activePage;
$('.app-page').attr('hidden', true);
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);
else if (previous !== pageSelector) history.pushState(historyState, '', pageSelector);
}
function normalizeSearch(value) {
return String(value ?? '')
.normalize('NFKD')
.replaceAll(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('de');
}
function routeMatchesSearch(route, searchTerm) {
if (!searchTerm) return true;
return normalizeSearch([
route.name,
route.schoolName,
route.description
].filter(Boolean).join(' ')).includes(searchTerm);
}
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)));
}
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-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}>
<strong>${escapeHtml(route.name)}</strong>
<small>${source === 'local' ? 'Lokaler Entwurf' : 'Veröffentlicht'}</small>
</button>
</li>
`);
});
}
function renderRouteControl() {
const routeControlLists = $('.route-control-list');
routeControlLists.attr('aria-busy', 'true');
$('#route-control-server-title').text(`Aktive Strecken (${state.routes.length})`);
$('#route-control-local-title').text(`Lokale Strecken (${state.localRoutes.length})`);
$('#local-route-title').text(`Lokale Strecken (${state.localRoutes.length})`);
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.'
);
routeControlLists.attr('aria-busy', 'false');
}
function renderRoutes() {
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
const radiusM = ns.Config.routeRadiusKm * 1000;
const nearbyRoutes = state.position
? routes
.filter(route => route.proximityM != null && route.proximityM <= radiusM)
.slice()
.sort((a, b) => a.proximityM - b.proximityM)
: [];
const searchTerm = normalizeSearch($('#route-search').val());
const allRoutes = routes
.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.`
: 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.';
renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
renderRouteItems(
'#all-route-list',
allRoutes,
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} 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) {
const term = normalizeSearch(request.term);
response(state.routes
.filter(route => routeMatchesSearch(route, term))
.slice(0, 12)
.map(route => ({ label: route.name, value: route.name })));
},
select(event, ui) {
input.val(ui.item.value);
renderRoutes();
return false;
}
});
}
function hideNavigation() {
$('#navigation-footer').prop('hidden', true);
$('#app-shell').removeClass('has-navigation');
}
function showNavigation(message = 'Position wird ermittelt', distance = '–') {
$('#navigation-mode').text(message);
$('#navigation-distance').text(distance);
$('#navigation-footer').prop('hidden', false);
$('#app-shell').addClass('has-navigation');
}
function stopRouteSensors() {
ns.Geo.stop();
ns.Orientation.stop();
ns.Vibration.stop();
state.deviceHeading = null;
}
function updateRouteControls() {
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 });
$('#pause-route').prop('disabled', !running);
$('#stop-route').prop('disabled', state.routeState === 'idle');
}
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');
}
function endRoute({ announce = true, returnToRoutes = false } = {}) {
const wasActive = state.routeState !== 'idle';
state.routeState = 'idle';
persistRouteProgress();
state.routeProgressIndex = null;
state.triggered.clear();
state.viewed.clear();
state.playedAudio.clear();
state.activePoiId = null;
stopRouteSensors();
ns.AudioPlayer.unload();
setActivePoi(null);
hideNavigation();
if (announce && wasActive && !returnToRoutes) {
$('#tracking-status').prop('hidden', false).text('Route beendet.');
} else {
$('#tracking-status').prop('hidden', true).text('');
}
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();
ns.AudioPlayer.pause();
$('#tracking-status')
.prop('hidden', false)
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
updateRouteControls();
updateRouteControlSummary();
}
function suspendCurrentRoute() {
if (!state.route) return;
if (state.routeState === 'running') state.routeState = 'paused';
persistRouteProgress();
stopRouteSensors();
ns.AudioPlayer.unload();
state.activePoiId = null;
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 || 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-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">
<svg viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M16 2 27 28 16 23 5 28Z"/>
</svg>
<small>–</small>
</span>
</button>
</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) {
state.activePoiId = Number(poi.id);
state.viewed.add(state.activePoiId);
persistRouteProgress();
setActivePoi(poi.id);
$('#poi-title').text(poi.title);
$('#poi-description').text(poi.description || '');
ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page');
}
function nearestRoutePointIndex(position) {
const points = state.route?.points || [];
if (!points.length) return null;
let start = 0;
let end = points.length;
if (state.routeProgressIndex != null) {
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) {
const distance = ns.Distance.meters(position, points[index]);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
}
return nearestIndex;
}
function effectiveHeading(position) {
const movementHeading = position
&& Number.isFinite(position.heading)
&& Number(position.speed) > 0.5
? position.heading
: null;
return state.deviceHeading ?? movementHeading;
}
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);
return {
poi,
distance,
bearing,
rotation: heading == null
? bearing
: ns.Distance.normalizeDegrees(bearing - heading)
};
});
}
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);
indicator
.attr('aria-label', `${distance} Meter entfernt`)
.attr('title', `${distance} m entfernt`);
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
indicator.find('small').text(`${distance} m`);
});
return metrics;
}
function nearestPendingPoi(metrics) {
return metrics
.filter(metric => !state.triggered.has(metric.poi.id))
.reduce((nearest, metric) => (
!nearest || metric.distance < nearest.distance ? metric : nearest
), null);
}
function updateRouteProgress(position) {
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;
target = points[targetIndex];
distance = ns.Distance.meters(position, target);
}
if (targetIndex === lastIndex && distance <= reachedRadius) {
state.routeProgressIndex = lastIndex;
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
return { reached: true, target, distance: 0 };
}
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
return { reached: false, target, distance };
}
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
? `Norden oben · nächster POI: ${metric.poi.title}`
: `Nächster POI: ${metric.poi.title}`,
`${distance} m`
);
$('#navigation-footer').attr(
'aria-label',
`Nächster POI ${metric.poi.title} in ${distance} Metern`
);
}
function showRoutePointNavigation(position, progress) {
if (!progress) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Keine Navigationspunkte vorhanden', '–');
return;
}
if (progress.reached) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
$('#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',
`${distance} m`
);
$('#navigation-footer').attr(
'aria-label',
`Nächster Routenpunkt in ${distance} Metern`
);
}
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);
}
function updateHeading(heading) {
if (state.deviceHeading == null) {
state.deviceHeading = heading;
} else {
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;
state.triggered.clear();
}
state.routeState = 'running';
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}`)
);
const orientationAvailable = await ns.Orientation.start(updateHeading);
if (state.routeState !== 'running') {
ns.Orientation.stop();
return;
}
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)
.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) {
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) {
try {
const position = await ns.Geo.current();
state.position = position;
updateRouteProximity(position);
$('#location-hint').text(
`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). `
+ `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
);
} catch (error) {
$('#location-hint').text(
`Standort nicht verfügbar: ${error.message}. Alle Routen können weiterhin ausgewählt werden.`
);
} finally {
context.markStep('location', 'done');
renderRoutes();
}
}
function restoreRouteLists() {
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?.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() {
$(document).on('input', '#route-search', renderRoutes);
activate('#clear-route-search', function () {
$('#route-search').val('').trigger('input').trigger('focus');
});
activate('[data-route-select]', async function () {
const button = $(this);
button.prop('disabled', true);
try {
await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
} catch (error) {
alert(error.responseJSON?.message || error.statusText || error.message);
} finally {
button.prop('disabled', false);
}
});
activate('[data-poi-select]', function () {
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
if (poi) showPoi(poi);
});
activate('[data-page-target]', function () {
const target = $(this).data('page-target');
if (history.state?.previous === target) history.back();
else navigate(target, { replace: true });
});
activate('#route-control-button', function () {
setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
});
activate('.route-control-actions #route-control-new', function () {
setRouteControlOpen(false);
ns.EditorUi?.openNewLocalRoute?.();
});
activate('.route-control-actions #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', pauseRoute);
activate('#stop-route', function () {
endRoute({ returnToRoutes: true });
});
$('#poi-audio').on('play', function () {
if (!state.route || state.activePoiId == null) return;
state.playedAudio.add(state.activePoiId);
persistRouteProgress();
});
document.addEventListener('wegwichtel:progress-storage-changed', event => {
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();
else persistRouteProgress();
});
}
ns.App = {
navigate,
currentPosition() {
return state.position ? { ...state.position } : null;
},
async initialize(context) {
bindEvents();
await ns.Api.health();
context.markStep('server', 'done');
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);
}
};
}(globalThis.Wegwichtel, globalThis.jQuery));