588 lines
18 KiB
JavaScript
588 lines
18 KiB
JavaScript
(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 $('<div>').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(`<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) {
|
||
$('#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('<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;
|
||
|
||
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));
|