2026-06-16 16:20:09 +02:00

423 lines
13 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: [],
route: null,
position: null,
triggered: new Set(),
tracking: false,
activePage: null,
routeProgressIndex: null,
deviceHeading: null
};
function escapeHtml(value) {
return $('<div>').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(`<li><p>${escapeHtml(emptyText)}</p></li>`);
return;
}
routes.forEach(route => {
const proximity = route.proximityM == null
? ''
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
list.append(`
<li>
<button type="button" data-route-id="${route.id}">
<strong>${escapeHtml(route.name)}</strong>
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
${proximity}
</button>
</li>
`);
});
}
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('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
return;
}
route.pois.forEach(poi => {
list.append(`
<li>
<button type="button" data-poi-id="${poi.id}">
<strong>${escapeHtml(poi.title)}</strong>
<span>${escapeHtml(poi.description || '')}</span>
</button>
</li>
`);
});
}
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));