(function (ns, $) {
'use strict';
const state = {
routes: [],
route: null,
position: null,
triggered: new Set(),
tracking: false,
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 && 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).removeClass('is-active');
target.removeAttr('hidden').addClass('is-active');
state.activePage = pageSelector;
window.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')
.replace(/[\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 updateTrackingButton() {
const button = $('#start-tracking');
button.button('option', {
label: state.tracking ? 'GPS-Begleitung stoppen' : 'GPS-Begleitung starten',
icon: state.tracking ? 'ui-icon-stop' : 'ui-icon-pin-s'
});
}
function stopTracking() {
state.tracking = false;
state.routeProgressIndex = null;
state.deviceHeading = null;
ns.Geo.stop();
ns.Orientation.stop();
$('#tracking-status').prop('hidden', true).text('');
hideNavigation();
updateTrackingButton();
}
function renderRoute(route) {
if (state.tracking) stopTracking();
state.route = route;
state.triggered.clear();
state.routeProgressIndex = 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);
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(`
`);
});
}
function showPoi(poi, automatic) {
$('#poi-title').text(poi.title);
$('#poi-description').text(poi.description || '');
ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page');
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
}
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 updateNavigation(position) {
const points = state.route?.points || [];
if (!state.tracking || !points.length) return;
const nearestIndex = nearestRoutePointIndex(position);
if (nearestIndex == null) return;
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;
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Ziel der Route erreicht', '0 m');
$('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht');
return;
}
const bearing = ns.Distance.bearing(position, target);
const movementHeading = Number.isFinite(position.heading) && Number(position.speed) > 0.5
? position.heading
: null;
const heading = state.deviceHeading ?? movementHeading;
const rotation = heading == null
? bearing
: ns.Distance.normalizeDegrees(bearing - heading);
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
showNavigation(
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
`${Math.round(distance)} m`
);
$('#navigation-footer').attr(
'aria-label',
`Nächster Routenpunkt in ${Math.round(distance)} Metern`
);
}
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 || !state.tracking) return;
updateNavigation(position);
let nearest = null;
state.route.pois.forEach(poi => {
if (state.triggered.has(poi.id)) return;
const distance = ns.Distance.meters(position, poi);
if (!nearest || distance < nearest.distance) nearest = { poi, distance };
});
$('#tracking-status').text(
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
+ (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
);
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
state.triggered.add(nearest.poi.id);
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
showPoi(nearest.poi, true);
}
}
async function startTracking() {
state.tracking = true;
state.routeProgressIndex = null;
state.deviceHeading = null;
updateTrackingButton();
$('#tracking-status').prop('hidden', false).text('GPS wird gestartet.');
showNavigation();
ns.Geo.start(
evaluatePosition,
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
);
const orientationAvailable = await ns.Orientation.start(updateHeading);
if (!orientationAvailable && state.tracking) {
$('#navigation-mode').text('Norden oben · nächster Routenpunkt');
}
}
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, false);
});
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-tracking', async function () {
if (state.tracking) stopTracking();
else await startTracking();
});
window.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();
updateTrackingButton();
hideNavigation();
}
};
}(window.Wegwichtel, window.jQuery));