Standort wird ermittelt.
+ +Routen in deiner Nähe
+
-
+
-
Alle Routen
-
-
-
+
-
-
-
- Alle Routen
+ + +
+
+
-
- Route
+
+
-
+
+
-
+
- - Länge
- –
- Höhenmeter
- –
- POIs
- –
Stationen
-Station
-
- ![]()
-
+
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 5067d17..cf1212c 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -7,13 +7,31 @@
position: null,
triggered: new Set(),
tracking: false,
- activePage: null
+ activePage: null,
+ routeProgressIndex: null,
+ deviceHeading: null
};
function escapeHtml(value) {
return $('
+
+
+
+
+
+
+
0 / 0
').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}`);
@@ -49,19 +67,20 @@
function renderRouteItems(listSelector, routes, emptyText) {
const list = $(listSelector).empty();
if (!routes.length) {
- list.append(`${escapeHtml(emptyText)} `);
+ list.append(` `);
return;
}
routes.forEach(route => {
const proximity = route.proximityM == null
? ''
- : `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`;
+ : `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`;
+
list.append(`
-
@@ -88,8 +107,11 @@
: '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.');
+ renderRouteItems(
+ '#all-route-list',
+ allRoutes,
+ searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
+ );
$('#all-routes-summary').text(
searchTerm
@@ -117,9 +139,43 @@
});
}
+ 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));
@@ -128,16 +184,16 @@
const list = $('#poi-list').empty();
if (!route.pois.length) {
- list.append('Diese Route enthält noch keine Stationen. ');
+ list.append(' ');
return;
}
route.pois.forEach(poi => {
list.append(`
-
- ${escapeHtml(poi.title)}
- ${escapeHtml(poi.description || '')}
+
+ ${escapeHtml(poi.title)}
+ ${escapeHtml(poi.description || '')}
`);
@@ -153,10 +209,99 @@
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;
@@ -165,8 +310,8 @@
});
$('#tracking-status').text(
- `GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` +
- (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
+ `GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
+ + (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
);
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
@@ -176,22 +321,33 @@
}
}
- 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'
- });
+ 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);
- $(document).on('click', '#clear-route-search', function () {
+ activate('#clear-route-search', function () {
$('#route-search').val('').trigger('input').trigger('focus');
});
- $(document).on('click', '[data-route-id]', async function () {
+ activate('[data-route-id]', async function () {
const button = $(this);
button.prop('disabled', true);
try {
@@ -205,26 +361,20 @@
}
});
- $(document).on('click', '[data-poi-id]', function () {
+ activate('[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 () {
+ activate('[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();
- }
+ activate('#start-tracking', async function () {
+ if (state.tracking) stopTracking();
+ else await startTracking();
});
window.addEventListener('popstate', event => {
@@ -249,8 +399,8 @@
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.`
+ `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). `
+ + `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
);
state.routes = state.routes.map(route => ({
...route,
@@ -266,6 +416,7 @@
initializeRouteSearch();
renderRoutes();
updateTrackingButton();
+ hideNavigation();
}
};
}(window.Wegwichtel, window.jQuery));
diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js
index 6d13d76..d794d63 100644
--- a/public/js/bootstrap-loader.js
+++ b/public/js/bootstrap-loader.js
@@ -71,7 +71,7 @@
initializeJqueryUi();
mark('jquery-ui', 'done');
- for (const module of ['config', 'api-client', 'distance', 'geolocation', 'slideshow', 'audio-player', 'app']) {
+ for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'app']) {
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
}
mark('modules', 'done');
@@ -90,6 +90,14 @@
}
}
- retry.addEventListener('click', () => location.reload());
+ function retryBoot(event) {
+ if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return;
+ if (event.type === 'pointerup' && event.pointerType === 'mouse' && event.button !== 0) return;
+ event.preventDefault();
+ location.reload();
+ }
+
+ retry.addEventListener('pointerup', retryBoot);
+ retry.addEventListener('keydown', retryBoot);
boot();
}());
diff --git a/public/js/distance.js b/public/js/distance.js
index d3f7249..0986261 100644
--- a/public/js/distance.js
+++ b/public/js/distance.js
@@ -1,12 +1,33 @@
(function (ns) {
'use strict';
+
const radians = value => value * Math.PI / 180;
+ const degrees = value => value * 180 / Math.PI;
+ const normalizeDegrees = value => (value % 360 + 360) % 360;
+
ns.Distance = {
meters(a, b) {
- const dLat = radians(b.lat - a.lat); const dLon = radians(b.lon - a.lon);
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2;
+ const dLat = radians(b.lat - a.lat);
+ const dLon = radians(b.lon - a.lon);
+ const h = Math.sin(dLat / 2) ** 2
+ + Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371000 * Math.asin(Math.sqrt(h));
},
- format(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; }
+
+ bearing(a, b) {
+ const lat1 = radians(a.lat);
+ const lat2 = radians(b.lat);
+ const dLon = radians(b.lon - a.lon);
+ const y = Math.sin(dLon) * Math.cos(lat2);
+ const x = Math.cos(lat1) * Math.sin(lat2)
+ - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
+ return normalizeDegrees(degrees(Math.atan2(y, x)));
+ },
+
+ normalizeDegrees,
+
+ format(meters) {
+ return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
+ }
};
}(window.Wegwichtel));
diff --git a/public/js/geolocation.js b/public/js/geolocation.js
index 70cbe3c..0b0d097 100644
--- a/public/js/geolocation.js
+++ b/public/js/geolocation.js
@@ -1,18 +1,61 @@
(function (ns) {
'use strict';
+
let watchId = null;
- function normalize(position) { return { lat: position.coords.latitude, lon: position.coords.longitude, accuracy: position.coords.accuracy, timestamp: position.timestamp }; }
+
+ function normalize(position) {
+ return {
+ lat: position.coords.latitude,
+ lon: position.coords.longitude,
+ accuracy: position.coords.accuracy,
+ altitude: position.coords.altitude,
+ heading: position.coords.heading,
+ speed: position.coords.speed,
+ timestamp: position.timestamp
+ };
+ }
+
ns.Geo = {
current() {
return new Promise((resolve, reject) => {
- if (!navigator.geolocation) return reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
- navigator.geolocation.getCurrentPosition(p => resolve(normalize(p)), reject, { enableHighAccuracy: true, timeout: ns.Config.geolocationTimeoutMs, maximumAge: 30000 });
+ if (!navigator.geolocation) {
+ reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ position => resolve(normalize(position)),
+ reject,
+ {
+ enableHighAccuracy: true,
+ timeout: ns.Config.geolocationTimeoutMs,
+ maximumAge: 30000
+ }
+ );
});
},
+
start(callback, onError) {
this.stop();
- watchId = navigator.geolocation.watchPosition(p => callback(normalize(p)), onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 5000 });
+ if (!navigator.geolocation) {
+ onError?.(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
+ return;
+ }
+
+ watchId = navigator.geolocation.watchPosition(
+ position => callback(normalize(position)),
+ onError,
+ {
+ enableHighAccuracy: true,
+ timeout: 20000,
+ maximumAge: 5000
+ }
+ );
},
- stop() { if (watchId != null) navigator.geolocation.clearWatch(watchId); watchId = null; }
+
+ stop() {
+ if (watchId != null) navigator.geolocation.clearWatch(watchId);
+ watchId = null;
+ }
};
}(window.Wegwichtel));
diff --git a/public/js/orientation.js b/public/js/orientation.js
new file mode 100644
index 0000000..5ed9f1a
--- /dev/null
+++ b/public/js/orientation.js
@@ -0,0 +1,64 @@
+(function (ns) {
+ 'use strict';
+
+ let listener = null;
+ const eventNames = ['deviceorientationabsolute', 'deviceorientation'];
+ let callback = null;
+
+ function normalizeDegrees(value) {
+ return (value % 360 + 360) % 360;
+ }
+
+ function readHeading(event) {
+ if (Number.isFinite(event.webkitCompassHeading)) {
+ return normalizeDegrees(event.webkitCompassHeading);
+ }
+
+ if ((event.absolute || event.type === 'deviceorientationabsolute') && Number.isFinite(event.alpha)) {
+ return normalizeDegrees(360 - event.alpha);
+ }
+
+ return null;
+ }
+
+ async function requestPermission() {
+ const orientation = window.DeviceOrientationEvent;
+ if (!orientation) return false;
+
+ if (typeof orientation.requestPermission === 'function') {
+ return (await orientation.requestPermission()) === 'granted';
+ }
+
+ return true;
+ }
+
+ ns.Orientation = {
+ async start(onHeading) {
+ this.stop();
+ callback = onHeading;
+
+ let permitted = false;
+ try {
+ permitted = await requestPermission();
+ } catch {
+ permitted = false;
+ }
+
+ if (!permitted) return false;
+
+ listener = event => {
+ const heading = readHeading(event);
+ if (heading != null) callback?.(heading);
+ };
+
+ eventNames.forEach(name => window.addEventListener(name, listener, true));
+ return true;
+ },
+
+ stop() {
+ if (listener) eventNames.forEach(name => window.removeEventListener(name, listener, true));
+ listener = null;
+ callback = null;
+ }
+ };
+}(window.Wegwichtel));
diff --git a/public/js/slideshow.js b/public/js/slideshow.js
index 7960c2a..1245c32 100644
--- a/public/js/slideshow.js
+++ b/public/js/slideshow.js
@@ -3,6 +3,23 @@
let images = [];
let index = 0;
+ let swipe = null;
+
+ 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 refreshButton(selector, disabled) {
const button = $(selector).prop('disabled', disabled);
@@ -30,17 +47,42 @@
index = 0;
render();
},
+
next() {
if (images.length) index = (index + 1) % images.length;
render();
},
+
previous() {
if (images.length) index = (index - 1 + images.length) % images.length;
render();
}
};
+ activate('#slide-next', () => ns.Slideshow.next());
+ activate('#slide-prev', () => ns.Slideshow.previous());
+
$(document)
- .on('click', '#slide-next', () => ns.Slideshow.next())
- .on('click', '#slide-prev', () => ns.Slideshow.previous());
+ .on('pointerdown', '#slideshow', function (event) {
+ if ($(event.target).closest('button').length) return;
+ const pointer = event.originalEvent;
+ if (!pointer?.isPrimary) return;
+ swipe = { id: pointer.pointerId, x: pointer.clientX, y: pointer.clientY };
+ })
+ .on('pointerup pointercancel', '#slideshow', function (event) {
+ const pointer = event.originalEvent;
+ if (!swipe || pointer?.pointerId !== swipe.id) return;
+ if (event.type === 'pointercancel') {
+ swipe = null;
+ return;
+ }
+
+ const deltaX = pointer.clientX - swipe.x;
+ const deltaY = pointer.clientY - swipe.y;
+ swipe = null;
+
+ if (Math.abs(deltaX) < 45 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
+ if (deltaX < 0) ns.Slideshow.next();
+ else ns.Slideshow.previous();
+ });
}(window.Wegwichtel, window.jQuery));
${escapeHtml(emptyText)}
Diese Route enthält noch keine Stationen.