(function (ns, $) { 'use strict'; const state = { routes: [], route: null, position: null, triggered: new Set(), routeState: 'idle', activePage: null, routeProgressIndex: null, deviceHeading: null }; function escapeHtml(value) { return $('
').text(value ?? '').html(); } 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 renderRouteItems(listSelector, routes, emptyText) { const list = $(listSelector).empty(); if (!routes.length) { list.append(`
  • ${escapeHtml(emptyText)}

  • `); return; } routes.forEach(route => { const proximity = route.proximityM == null ? '' : `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`; list.append(`
  • `); }); } function renderRoutes() { const radiusM = ns.Config.routeRadiusKm * 1000; const nearbyRoutes = state.position ? state.routes .filter(route => route.proximityM != null && route.proximityM <= radiusM) .sort((a, b) => a.proximityM - b.proximityM) : []; const searchTerm = normalizeSearch($('#route-search').val()); const allRoutes = state.routes .filter(route => routeMatchesSearch(route, searchTerm)) .slice() .sort((a, b) => a.name.localeCompare(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 Routen vorhanden.' ); $('#all-routes-summary').text( searchTerm ? `${allRoutes.length} von ${state.routes.length} Routen gefunden.` : `${state.routes.length} Routen verfügbar.` ); } function initializeRouteSearch() { const input = $('#route-search'); 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'; state.routeProgressIndex = null; state.triggered.clear(); 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; navigate('#routes-page', { replace: true }); } } function pauseRoute() { if (state.routeState !== 'running') return; state.routeState = 'paused'; stopRouteSensors(); ns.AudioPlayer.pause(); $('#tracking-status') .prop('hidden', false) .text('Route pausiert. Die Standortverfolgung ist angehalten.'); showNavigation('Route pausiert', $('#navigation-distance').text() || '–'); updateRouteControls(); } function renderRoute(route) { if (state.routeState !== 'idle') endRoute({ announce: false }); state.route = route; state.triggered.clear(); state.routeProgressIndex = null; setActivePoi(null); $('#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-poi-count').text(route.pois.length); $('#tracking-status').prop('hidden', true).text(''); updateRouteControls(); const list = $('#poi-list').empty(); if (!route.pois.length) { list.append('
  • Diese Route enthält noch keine Stationen.

  • '); return; } route.pois.forEach(poi => { list.append(`
  • `); }); updatePoiIndicators(state.position); } function showPoi(poi) { 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 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; return { reached: true, target, distance: 0 }; } 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; if (!state.route) return; const metrics = updatePoiIndicators(position); 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); ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI); showPoi(nearest.poi); updateNavigation(position); } } 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; updateRouteControls(); $('#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 bindEvents() { $(document).on('input', '#route-search', renderRoutes); activate('#clear-route-search', function () { $('#route-search').val('').trigger('input').trigger('focus'); }); activate('[data-route-id]', 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'); } catch (error) { alert(error.responseJSON?.message || error.statusText || error.message); } finally { button.prop('disabled', false); } }); activate('[data-poi-id]', 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('#start-route', async function () { await startRoute(); }); activate('#pause-route', function () { pauseRoute(); }); activate('#stop-route', function () { endRoute({ returnToRoutes: true }); }); globalThis.addEventListener('popstate', event => { const page = event.state?.page || '#routes-page'; navigate(page, { updateHistory: false, instant: true }); }); } ns.App = { navigate, async initialize(context) { bindEvents(); await ns.Api.health(); context.markStep('server', 'done'); const response = await ns.Api.routes(null); state.routes = response.routes; context.markStep('routes', 'done'); try { const position = await ns.Geo.current(); state.position = position; $('#location-hint').text( `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` + `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.` ); state.routes = state.routes.map(route => ({ ...route, proximityM: route.start ? ns.Distance.meters(position, route.start) : null })); } catch (error) { $('#location-hint').text( `Standort nicht verfügbar: ${error.message}. Alle Routen können weiterhin ausgewählt werden.` ); } context.markStep('location', 'done'); initializeRouteSearch(); renderRoutes(); updateRouteControls(); hideNavigation(); } }; }(globalThis.Wegwichtel, globalThis.jQuery));