diff --git a/README.md b/README.md index 6254968..9a6dc72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Wegwichtel Next -Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können Bilder und eine Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. +Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können Bilder und eine Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt. ## Architektur @@ -79,6 +79,7 @@ public/ mobiler Client index.html reduzierte semantische Seitenstruktur js/bootstrap-loader.js lädt und prüft lokale Bibliotheken js/orientation.js Kompassausrichtung für den Navigationspfeil + js/vibration.js gekapselte Vibration mit benannten Mustern vendor/ lokale jQuery-/jQuery-UI-Dateien samt Themebildern src/routes/ REST-Routing src/services/ GPX-, Geodaten-, Speicher- und Fachlogik @@ -98,7 +99,7 @@ test/ Basistests - Schreibzugriffe sind noch nicht authentifiziert. Vor einem öffentlichen Einsatz sind Rollen, Login, CSRF-Schutz, Rate-Limits, Dateisignaturprüfung und ein Moderationsworkflow zwingend zu ergänzen. - Der Server bindet standardmäßig nur an `127.0.0.1`; Zugriffe von anderen Geräten sind damit bewusst ausgeschlossen. - Anwendungsaktionen verwenden Pointer Events für Touch, Stift und Maus. Enter und Leertaste bleiben als Tastaturbedienung erhalten. -- Bei gestarteter Route zeigt ein fixierter Footer die Entfernung zum nächsten GPX-Trackpunkt auf ganze Meter und dreht einen SVG-Pfeil relativ zur Geräteausrichtung. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt. +- Bei gestarteter Route zeigt ein fixierter Footer vorrangig Entfernung und Richtung zum nächstgelegenen, noch nicht erreichten POI. Der GPX-Fortschritt wird parallel entlang der Trackpunkte nachgeführt; sind alle POIs erreicht, übernimmt wieder der nächste GPX-Trackpunkt beziehungsweise das Routenziel. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt. - Auf iPhone und iPad wird die Freigabe der Geräteausrichtung beim Start der Route innerhalb der Benutzeraktion angefordert. - Für die Geolokalisierung sollte der Client lokal über `http://127.0.0.1:47145` oder online ausschließlich über HTTPS geöffnet werden. Das betrifft ebenso den Gerätekompass. @@ -122,13 +123,15 @@ Das HTML verwendet nur IDs, die von den Clientmodulen tatsächlich angesprochen Alle selbst implementierten Schaltaktionen reagieren primär auf `pointerup`; dadurch funktionieren dieselben Handler mit Touchscreen, Eingabestift und Maus. Die Diashow kann zusätzlich horizontal gewischt werden. Für die Tastatur werden Enter und Leertaste separat behandelt. -Beim Start der Route wird der aktuellen Position nächstgelegene GPX-Trackpunkt gesucht. Als Navigationsziel dient der folgende Trackpunkt. Der Fortschritt läuft nur vorwärts und wird in einem lokalen Fenster entlang der Punktfolge nachgeführt, um Rücksprünge durch GPS-Schwankungen zu vermeiden. Der Footer zeigt: +Beim Start der Route wird der aktuellen Position nächstgelegene GPX-Trackpunkt gesucht und der Fortschritt anschließend nur vorwärts entlang der Punktfolge nachgeführt. Für die sichtbare Navigation haben jedoch POIs Vorrang: Aus allen noch nicht automatisch aktivierten Stationen wird bei jeder Positionsänderung die räumlich nächstgelegene bestimmt. Der Footer zeigt: -- die auf ganze Meter gerundete Luftlinienentfernung zum nächsten Trackpunkt, -- einen frei rotierenden SVG-Pfeil, -- die relative Richtung zum Ziel, sofern Kompass- oder Bewegungsrichtung verfügbar ist, +- Name und auf ganze Meter gerundete Luftlinienentfernung zum nächsten POI, +- einen frei rotierenden SVG-Pfeil relativ zur Geräte- oder Bewegungsrichtung, - andernfalls die absolute Peilung bei Norden oben, -- und `Ziel der Route erreicht`, sobald der letzte Trackpunkt innerhalb der GPS-Toleranz liegt. +- nach dem Aktivieren unmittelbar den nächsten noch offenen POI, +- und erst nach Erreichen aller POIs wieder den nächsten GPX-Trackpunkt beziehungsweise das Routenziel. + +In der Stationsliste erhält jeder POI rechts einen kleinen Richtungspfeil und seine aktuelle Entfernung in Metern. Diese Werte werden mit jeder GPS- oder Kompassaktualisierung neu berechnet. Die Reihenfolge der Stationsliste bleibt dabei unverändert. ## Routensteuerung und GPS-Tracking @@ -139,7 +142,15 @@ Die Routenansicht besitzt drei nebeneinanderliegende, semantische Schaltflächen - **Route pausieren:** beendet den aktiven GPS-Watcher und den Kompass-Listener, pausiert eine laufende Audioansage und behält Routenfortschritt, Audioquelle, Abspielposition und bereits ausgelöste POIs bei. - **Route beenden:** beendet alle Sensor-Listener, hält die Audioansage an, entlädt ihre Quelldatei, setzt den Fortschritt zurück und kehrt zur Routenauswahl zurück. -Die aktuell geöffnete beziehungsweise automatisch ausgelöste Station wird in der Stationsliste sichtbar hervorgehoben und mit `aria-current="step"` semantisch gekennzeichnet. +Die aktuell geöffnete beziehungsweise automatisch aktivierte Station wird in der Stationsliste sichtbar hervorgehoben und mit `aria-current="step"` semantisch gekennzeichnet. Beim Eintritt in den Auslöseradius wird ihre Audiodatei mit `preload="auto"` vorbereitet, aber niemals automatisch gestartet. Die Wiedergabe beginnt ausschließlich über die nativen Audio-Bedienelemente. + +Die Vibration API ist in `public/js/vibration.js` gekapselt. Die aus dem historischen Projekt übernommenen Konstanten `MICRO`, `MINI`, `SHORT`, `LONG`, `ALARM` und `POI` stehen unter `Wegwichtel.Vibration.Patterns` zur Verfügung. Für eine neu aktivierte Station wird `ACTIVE_POI` mit dem Muster `[500, 300, 500]` verwendet. Nicht unterstützende Browser ignorieren den Aufruf ohne Fehler; Pause und Beenden brechen ein laufendes Muster ab. + +Beispiel für einen Aufruf mit Konstante: + +```javascript +Wegwichtel.Vibration.start(Wegwichtel.Vibration.Patterns.ACTIVE_POI); +``` Während die Route läuft, werden Positionsänderungen fortlaufend verarbeitet. Eine Browser-Webanwendung ist jedoch kein nativer Hintergrunddienst: Betriebssystem und Browser können die Aktualisierung bei gesperrtem Bildschirm, Energiesparmodus oder im Hintergrund drosseln beziehungsweise anhalten. diff --git a/docs/REST-API.md b/docs/REST-API.md index 638e2d0..7e0a56f 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -446,7 +446,7 @@ Content-Type: `multipart/form-data` | `description` | Text | nein | leer | Beschreibung der Station | | `lat` | Dezimalzahl | ja | – | Breitengrad des POIs | | `lon` | Dezimalzahl | ja | – | Längengrad des POIs | -| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS`, standardmäßig `80` | Entfernung in Metern, ab der die Station automatisch ausgelöst wird | +| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS`, standardmäßig `80` | Entfernung in Metern, ab der die Station automatisch aktiviert und ihre Audiodatei vorgeladen wird; die Wiedergabe startet nicht automatisch | | `sequence` | Ganzzahl | nein | `0` | Sortierreihenfolge innerhalb der Route | | `audio` | Datei | nein | keine | eine Audioansage | | `images` | Datei, wiederholbar | nein | keine | bis zu 20 Bilder; jedes Bild wird als eigenes Feld `images` gesendet | diff --git a/package-lock.json b/package-lock.json index ba5f969..e692fda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.4.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.4.0", + "version": "0.7.0", "hasInstallScript": true, "dependencies": { "express": "5.2.1", diff --git a/package.json b/package.json index 6ae2431..cf724c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.4.0", + "version": "0.7.0", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/public/css/app.css b/public/css/app.css index 9c223d9..3744978 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -219,6 +219,43 @@ button, opacity: .65; } +#poi-list button { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +#poi-list button > strong, +#poi-list button > span:not(.poi-proximity) { + grid-column: 1; +} + +.poi-proximity { + display: grid; + grid-column: 2; + grid-row: 1 / span 2; + place-items: center; + min-width: 3.75rem; + gap: .15rem; + padding-left: .5rem; +} + +.poi-proximity svg { + width: 1.65rem; + height: 1.65rem; + fill: var(--primary-dark); + transform: rotate(0deg); + transform-origin: 50% 50%; + transition: transform 180ms linear; + will-change: transform; +} + +.poi-proximity small { + color: var(--text); + font-size: .8rem; + font-weight: 700; + white-space: nowrap; +} + #poi-list button[aria-current="step"] { border-color: var(--primary); background: #e7f1e9; diff --git a/public/index.html b/public/index.html index f1e8bce..84d8e51 100644 --- a/public/index.html +++ b/public/index.html @@ -118,7 +118,7 @@

- + @@ -127,7 +127,7 @@

- Richtung zum nächsten Routenpunkt + Richtung zum nächsten POI

diff --git a/public/js/app.js b/public/js/app.js index 8a81a57..e4df5bc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -155,6 +155,7 @@ function stopRouteSensors() { ns.Geo.stop(); ns.Orientation.stop(); + ns.Vibration.stop(); state.deviceHeading = null; } @@ -242,20 +243,27 @@ `); }); + + updatePoiIndicators(state.position); } - function showPoi(poi, automatic) { + 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'); - if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {}); } function nearestRoutePointIndex(position) { @@ -282,12 +290,63 @@ return nearestIndex; } - function updateNavigation(position) { + 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 (state.routeState !== 'running' || !points.length) return; + if (!points.length) return null; const nearestIndex = nearestRoutePointIndex(position); - if (nearestIndex == null) return; + if (nearestIndex == null) return null; state.routeProgressIndex = state.routeProgressIndex == null ? nearestIndex @@ -308,32 +367,72 @@ 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('Ziel der Route erreicht', '0 m'); - $('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht'); + showNavigation('Keine Navigationspunkte vorhanden', '–'); 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; + 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', - `${Math.round(distance)} m` + `${distance} m` ); $('#navigation-footer').attr( 'aria-label', - `Nächster Routenpunkt in ${Math.round(distance)} Metern` + `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; @@ -347,26 +446,25 @@ function evaluatePosition(position) { state.position = position; - if (!state.route || state.routeState !== 'running') return; + if (!state.route) return; + const metrics = updatePoiIndicators(position); + if (state.routeState !== 'running') return; + + let nearest = nearestPendingPoi(metrics); 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` : '') + + (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); - if (navigator.vibrate) navigator.vibrate([120, 80, 120]); - showPoi(nearest.poi, true); + ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI); + showPoi(nearest.poi); + nearest = nearestPendingPoi(metrics); + updateNavigation(position); } } @@ -399,7 +497,7 @@ } if (!orientationAvailable) { - $('#navigation-mode').text('Norden oben · nächster Routenpunkt'); + $('#navigation-mode').text('Norden oben · Navigation'); } } @@ -426,7 +524,7 @@ activate('[data-poi-id]', function () { const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id'))); - if (poi) showPoi(poi, false); + if (poi) showPoi(poi); }); activate('[data-page-target]', function () { diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js index 66b7dd6..b59b89c 100644 --- a/public/js/bootstrap-loader.js +++ b/public/js/bootstrap-loader.js @@ -70,7 +70,7 @@ initializeJqueryUi(); mark('jquery-ui', 'done'); - for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'app']) { + for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'vibration', 'app']) { await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel)); } mark('modules', 'done'); diff --git a/public/js/vibration.js b/public/js/vibration.js new file mode 100644 index 0000000..b81f8a4 --- /dev/null +++ b/public/js/vibration.js @@ -0,0 +1,53 @@ +(function (ns) { + 'use strict'; + + const Patterns = Object.freeze({ + MICRO: 50, + MINI: 100, + SHORT: 200, + LONG: 2000, + ALARM: Object.freeze([100, 50, 100]), + POI: Object.freeze([2000, 200, 2000]), + ACTIVE_POI: Object.freeze([500, 300, 500]) + }); + + function supported() { + return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; + } + + function normalize(pattern) { + const selected = pattern ?? Patterns.LONG; + + if (Array.isArray(selected)) { + const values = selected.map(Number); + if (!values.length || values.some(value => !Number.isFinite(value) || value < 0)) { + throw new TypeError('Das Vibrationsmuster muss aus nichtnegativen Millisekunden bestehen.'); + } + return values; + } + + const duration = Number(selected); + if (!Number.isFinite(duration) || duration < 0) { + throw new TypeError('Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.'); + } + return duration; + } + + function start(pattern = Patterns.LONG) { + const normalized = normalize(pattern); + if (!supported()) return false; + return navigator.vibrate(normalized); + } + + function stop() { + if (!supported()) return false; + return navigator.vibrate(0); + } + + ns.Vibration = Object.freeze({ + Patterns, + isSupported: supported, + start, + stop + }); +}(window.Wegwichtel)); diff --git a/test/api-docs.test.js b/test/api-docs.test.js new file mode 100644 index 0000000..0a6ef84 --- /dev/null +++ b/test/api-docs.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); +const read = relativePath => fs.readFile(path.join(root, relativePath), 'utf8'); + +test('complete REST API documentation is kept outside the README', async () => { + const readme = await read('README.md'); + const api = await read('docs/REST-API.md'); + + assert.match(readme, /\[.*REST-API.*\]\(docs\/REST-API\.md\)/i); + assert.doesNotMatch(readme, /^## REST-API$/m); + assert.doesNotMatch(readme, /curl .*\/api\//); + + for (const endpoint of [ + 'GET /api/health', + 'GET /api/routes', + 'GET /api/routes/:id', + 'GET /api/routes/:id/pois', + 'GET /api/pois/:id', + 'POST /api/routes', + 'PUT /api/routes/:id', + 'POST /api/routes/:id/append', + 'POST /api/routes/:id/pois', + 'PUT /api/pois/:id', + 'DELETE /api/routes/:id', + 'POST /api/routes/:id/restore' + ]) { + assert.ok(api.includes(endpoint), `missing API documentation: ${endpoint}`); + } + + for (const parameter of [ + 'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug', + 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', + 'audio', 'images' + ]) { + assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`); + } +}); diff --git a/test/mobile-navigation-ui.test.js b/test/mobile-navigation-ui.test.js index e572438..c49da6f 100644 --- a/test/mobile-navigation-ui.test.js +++ b/test/mobile-navigation-ui.test.js @@ -60,7 +60,8 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out assert.match(html, /