272 lines
8.8 KiB
JavaScript
272 lines
8.8 KiB
JavaScript
(function (ns, $) {
|
|
'use strict';
|
|
|
|
const state = {
|
|
routes: [],
|
|
route: null,
|
|
position: null,
|
|
triggered: new Set(),
|
|
tracking: false,
|
|
activePage: null
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return $('<div>').text(value ?? '').html();
|
|
}
|
|
|
|
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 class="empty-list">${escapeHtml(emptyText)}</li>`);
|
|
return;
|
|
}
|
|
|
|
routes.forEach(route => {
|
|
const proximity = route.proximityM == null
|
|
? ''
|
|
: `<span class="route-distance">${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</span>`;
|
|
list.append(`
|
|
<li>
|
|
<button class="card-link" type="button" data-route-id="${route.id}">
|
|
<span class="card-title">${escapeHtml(route.name)}</span>
|
|
<span class="card-description">${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 renderRoute(route) {
|
|
state.route = route;
|
|
state.triggered.clear();
|
|
$('#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 class="empty-list">Diese Route enthält noch keine Stationen.</li>');
|
|
return;
|
|
}
|
|
|
|
route.pois.forEach(poi => {
|
|
list.append(`
|
|
<li>
|
|
<button class="card-link" type="button" data-poi-id="${poi.id}">
|
|
<span class="card-title">${escapeHtml(poi.title)}</span>
|
|
<span class="card-description">${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 evaluatePosition(position) {
|
|
state.position = position;
|
|
if (!state.route || !state.tracking) return;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 bindEvents() {
|
|
$(document).on('input', '#route-search', renderRoutes);
|
|
|
|
$(document).on('click', '#clear-route-search', function () {
|
|
$('#route-search').val('').trigger('input').trigger('focus');
|
|
});
|
|
|
|
$(document).on('click', '[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);
|
|
}
|
|
});
|
|
|
|
$(document).on('click', '[data-poi-id]', function () {
|
|
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
|
if (poi) showPoi(poi, false);
|
|
});
|
|
|
|
$(document).on('click', '[data-page-target]', function () {
|
|
const target = $(this).data('page-target');
|
|
if (history.state?.previous === target) history.back();
|
|
else navigate(target, { replace: true });
|
|
});
|
|
|
|
$(document).on('click', '#start-tracking', function () {
|
|
state.tracking = !state.tracking;
|
|
updateTrackingButton();
|
|
$('#tracking-status').toggle(state.tracking);
|
|
if (state.tracking) {
|
|
ns.Geo.start(evaluatePosition, error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`));
|
|
} else {
|
|
ns.Geo.stop();
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
};
|
|
}(window.Wegwichtel, window.jQuery));
|