2026-06-16 17:30:55 +02:00

591 lines
18 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(),
routeState: 'idle',
activePage: null,
routeProgressIndex: null,
activePoiId: 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 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) {
state.activePoiId = poiId == null ? null : Number(poiId);
$('#poi-list [data-poi-id]').removeAttr('aria-current');
if (state.activePoiId != null) {
$(`#poi-list [data-poi-id="${state.activePoiId}"]`).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('<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>
<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);
}
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;
let 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);
nearest = nearestPendingPoi(metrics);
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 });
});
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();
updateRouteControls();
hideNavigation();
}
};
}(window.Wegwichtel, window.jQuery));