reworked app screens and added vibration

This commit is contained in:
Florian Zumpe 2026-06-16 17:30:55 +02:00
parent 40f230864b
commit 44b1700c48
13 changed files with 418 additions and 53 deletions

View File

@ -1,6 +1,6 @@
# Wegwichtel Next # 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 ## Architektur
@ -79,6 +79,7 @@ public/ mobiler Client
index.html reduzierte semantische Seitenstruktur index.html reduzierte semantische Seitenstruktur
js/bootstrap-loader.js lädt und prüft lokale Bibliotheken js/bootstrap-loader.js lädt und prüft lokale Bibliotheken
js/orientation.js Kompassausrichtung für den Navigationspfeil js/orientation.js Kompassausrichtung für den Navigationspfeil
js/vibration.js gekapselte Vibration mit benannten Mustern
vendor/ lokale jQuery-/jQuery-UI-Dateien samt Themebildern vendor/ lokale jQuery-/jQuery-UI-Dateien samt Themebildern
src/routes/ REST-Routing src/routes/ REST-Routing
src/services/ GPX-, Geodaten-, Speicher- und Fachlogik 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. - 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. - 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. - 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. - 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. - 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. 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, - Name und auf ganze Meter gerundete Luftlinienentfernung zum nächsten POI,
- einen frei rotierenden SVG-Pfeil, - einen frei rotierenden SVG-Pfeil relativ zur Geräte- oder Bewegungsrichtung,
- die relative Richtung zum Ziel, sofern Kompass- oder Bewegungsrichtung verfügbar ist,
- andernfalls die absolute Peilung bei Norden oben, - 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 ## 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 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. - **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. 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.

View File

@ -446,7 +446,7 @@ Content-Type: `multipart/form-data`
| `description` | Text | nein | leer | Beschreibung der Station | | `description` | Text | nein | leer | Beschreibung der Station |
| `lat` | Dezimalzahl | ja | | Breitengrad des POIs | | `lat` | Dezimalzahl | ja | | Breitengrad des POIs |
| `lon` | Dezimalzahl | ja | | Längengrad 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 | | `sequence` | Ganzzahl | nein | `0` | Sortierreihenfolge innerhalb der Route |
| `audio` | Datei | nein | keine | eine Audioansage | | `audio` | Datei | nein | keine | eine Audioansage |
| `images` | Datei, wiederholbar | nein | keine | bis zu 20 Bilder; jedes Bild wird als eigenes Feld `images` gesendet | | `images` | Datei, wiederholbar | nein | keine | bis zu 20 Bilder; jedes Bild wird als eigenes Feld `images` gesendet |

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.4.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.4.0", "version": "0.7.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"express": "5.2.1", "express": "5.2.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.4.0", "version": "0.7.0",
"private": true, "private": true,
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
"type": "module", "type": "module",

View File

@ -219,6 +219,43 @@ button,
opacity: .65; 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"] { #poi-list button[aria-current="step"] {
border-color: var(--primary); border-color: var(--primary);
background: #e7f1e9; background: #e7f1e9;

View File

@ -118,7 +118,7 @@
</div> </div>
<p id="poi-description"></p> <p id="poi-description"></p>
<audio id="poi-audio" controls preload="metadata" hidden></audio> <audio id="poi-audio" controls preload="auto" hidden></audio>
</div> </div>
</section> </section>
@ -127,7 +127,7 @@
<path d="M32 4 53 54 32 44 11 54Z"/> <path d="M32 4 53 54 32 44 11 54Z"/>
</svg> </svg>
<p> <p>
<span id="navigation-mode">Richtung zum nächsten Routenpunkt</span> <span id="navigation-mode">Richtung zum nächsten POI</span>
<strong id="navigation-distance"></strong> <strong id="navigation-distance"></strong>
</p> </p>
</footer> </footer>

View File

@ -155,6 +155,7 @@
function stopRouteSensors() { function stopRouteSensors() {
ns.Geo.stop(); ns.Geo.stop();
ns.Orientation.stop(); ns.Orientation.stop();
ns.Vibration.stop();
state.deviceHeading = null; state.deviceHeading = null;
} }
@ -242,20 +243,27 @@
<button type="button" data-poi-id="${poi.id}"> <button type="button" data-poi-id="${poi.id}">
<strong>${escapeHtml(poi.title)}</strong> <strong>${escapeHtml(poi.title)}</strong>
<span>${escapeHtml(poi.description || '')}</span> <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> </button>
</li> </li>
`); `);
}); });
updatePoiIndicators(state.position);
} }
function showPoi(poi, automatic) { function showPoi(poi) {
setActivePoi(poi.id); setActivePoi(poi.id);
$('#poi-title').text(poi.title); $('#poi-title').text(poi.title);
$('#poi-description').text(poi.description || ''); $('#poi-description').text(poi.description || '');
ns.Slideshow.show(poi.images); ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl); ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page'); navigate('#poi-page');
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
} }
function nearestRoutePointIndex(position) { function nearestRoutePointIndex(position) {
@ -282,12 +290,63 @@
return nearestIndex; 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 || []; const points = state.route?.points || [];
if (state.routeState !== 'running' || !points.length) return; if (!points.length) return null;
const nearestIndex = nearestRoutePointIndex(position); const nearestIndex = nearestRoutePointIndex(position);
if (nearestIndex == null) return; if (nearestIndex == null) return null;
state.routeProgressIndex = state.routeProgressIndex == null state.routeProgressIndex = state.routeProgressIndex == null
? nearestIndex ? nearestIndex
@ -308,32 +367,72 @@
if (targetIndex === lastIndex && distance <= reachedRadius) { if (targetIndex === lastIndex && distance <= reachedRadius) {
state.routeProgressIndex = lastIndex; 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)'); $('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Ziel der Route erreicht', '0 m'); showNavigation('Keine Navigationspunkte vorhanden', '');
$('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht');
return; return;
} }
const bearing = ns.Distance.bearing(position, target); if (progress.reached) {
const movementHeading = Number.isFinite(position.heading) && Number(position.speed) > 0.5 $('#navigation-arrow').css('transform', 'rotate(0deg)');
? position.heading showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
: null; $('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
const heading = state.deviceHeading ?? movementHeading; return;
}
const bearing = ns.Distance.bearing(position, progress.target);
const heading = effectiveHeading(position);
const rotation = heading == null const rotation = heading == null
? bearing ? bearing
: ns.Distance.normalizeDegrees(bearing - heading); : ns.Distance.normalizeDegrees(bearing - heading);
const distance = Math.round(progress.distance);
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`); $('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
showNavigation( showNavigation(
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt', heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
`${Math.round(distance)} m` `${distance} m`
); );
$('#navigation-footer').attr( $('#navigation-footer').attr(
'aria-label', '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) { function updateHeading(heading) {
if (state.deviceHeading == null) { if (state.deviceHeading == null) {
state.deviceHeading = heading; state.deviceHeading = heading;
@ -347,26 +446,25 @@
function evaluatePosition(position) { function evaluatePosition(position) {
state.position = 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); 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( $('#tracking-status').text(
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` `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) { if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
state.triggered.add(nearest.poi.id); state.triggered.add(nearest.poi.id);
if (navigator.vibrate) navigator.vibrate([120, 80, 120]); ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
showPoi(nearest.poi, true); showPoi(nearest.poi);
nearest = nearestPendingPoi(metrics);
updateNavigation(position);
} }
} }
@ -399,7 +497,7 @@
} }
if (!orientationAvailable) { 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 () { activate('[data-poi-id]', function () {
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id'))); 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 () { activate('[data-page-target]', function () {

View File

@ -70,7 +70,7 @@
initializeJqueryUi(); initializeJqueryUi();
mark('jquery-ui', 'done'); 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)); await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
} }
mark('modules', 'done'); mark('modules', 'done');

53
public/js/vibration.js Normal file
View File

@ -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));

41
test/api-docs.test.js Normal file
View File

@ -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}`);
}
});

View File

@ -60,7 +60,8 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
assert.match(html, /<footer id="navigation-footer"/); assert.match(html, /<footer id="navigation-footer"/);
assert.match(html, /<svg id="navigation-arrow"/); assert.match(html, /<svg id="navigation-arrow"/);
assert.match(app, /Distance\.bearing/); assert.match(app, /Distance\.bearing/);
assert.match(app, /Math\.round\(distance\).*m/); assert.match(app, /Math\.round\((?:metric\.distance|progress\.distance|distance)\)/);
assert.match(app, /`\$\{distance\} m`/);
assert.match(css, /#navigation-arrow[\s\S]*transform[\s\S]*transition/); assert.match(css, /#navigation-arrow[\s\S]*transform[\s\S]*transition/);
assert.match(css, /touch-action:\s*manipulation/); assert.match(css, /touch-action:\s*manipulation/);
}); });
@ -103,3 +104,60 @@ test('route control SVG files are present', async () => {
assert.match(svg, /viewBox="0 0 48 48"/); assert.match(svg, /viewBox="0 0 48 48"/);
} }
}); });
test('pause and stop control audio and stop returns to route selection', async () => {
const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js');
assert.match(app, /function pauseRoute\(\)[\s\S]*ns\.AudioPlayer\.pause\(\)/);
assert.match(app, /function endRoute[\s\S]*ns\.AudioPlayer\.unload\(\)/);
assert.match(app, /endRoute\(\{ returnToRoutes: true \}\)/);
assert.match(app, /navigate\('#routes-page', \{ replace: true \}\)/);
assert.match(audio, /audio\.removeAttribute\('src'\)/);
assert.match(audio, /audio\.load\(\)/);
});
test('the selected or triggered station is highlighted semantically', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
assert.match(app, /setActivePoi\(poi\.id\)/);
assert.match(app, /attr\('aria-current', 'step'\)/);
assert.match(css, /#poi-list button\[aria-current="step"\]/);
assert.match(css, /aktuelle Station/);
});
test('POIs have live direction and metre indicators and take navigation priority', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
const html = await read('public/index.html');
assert.match(app, /class="poi-proximity"/);
assert.match(app, /function updatePoiIndicators\(position\)/);
assert.match(app, /function nearestPendingPoi\(metrics\)/);
assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/);
assert.match(app, /Nächster POI:/);
assert.match(app, /find\('small'\)\.text\(`\$\{distance\} m`\)/);
assert.match(app, /find\('svg'\)\.css\('transform'/);
assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/);
assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/);
assert.match(html, /Richtung zum nächsten POI/);
});
test('reaching a POI preloads audio without autoplay and uses the vibration wrapper', async () => {
const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js');
const html = await read('public/index.html');
const loader = await read('public/js/bootstrap-loader.js');
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl\)/);
assert.doesNotMatch(app, /AudioPlayer\.play\(/);
assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/);
assert.doesNotMatch(app, /navigator\.vibrate/);
assert.match(app, /function stopRouteSensors\(\)[\s\S]*ns\.Vibration\.stop\(\)/);
assert.match(audio, /audio\.load\(\)/);
assert.match(html, /<audio id="poi-audio" controls preload="auto" hidden>/);
assert.match(loader, /'vibration'/);
});

View File

@ -25,16 +25,22 @@ test('default server socket uses the local Wegwichtel endpoint', async () => {
assert.doesNotMatch(apiSource, /instance:/); assert.doesNotMatch(apiSource, /instance:/);
}); });
test('retired internal instance label is absent from the project', async () => { test('retired internal instance label is absent from authored project files', async () => {
const files = [ const extensions = new Set(['.js', '.json', '.md', '.html', '.css', '.env', '.sql', '.gpx', '.svg']);
'.env.example', const ignoredDirectories = new Set(['node_modules', 'vendor', '.git']);
'README.md', const files = [];
'server.js',
'src/config.js', async function collect(directory) {
'src/routes/api.js', for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
'public/index.html' if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
]; const absolute = path.join(directory, entry.name);
const content = (await Promise.all(files.map(read))).join('\n'); if (entry.isDirectory()) await collect(absolute);
else if (extensions.has(path.extname(entry.name)) || entry.name === '.env.example') files.push(absolute);
}
}
await collect(root);
const content = (await Promise.all(files.map(file => fs.readFile(file, 'utf8')))).join('\n');
const retiredLabel = ['at', 'las'].join(''); const retiredLabel = ['at', 'las'].join('');
assert.doesNotMatch(content, new RegExp(retiredLabel, 'i')); assert.doesNotMatch(content, new RegExp(retiredLabel, 'i'));
}); });

61
test/vibration.test.js Normal file
View File

@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
async function source() {
return fs.readFile(path.join(root, 'public/js/vibration.js'), 'utf8');
}
test('legacy vibration patterns are exposed as immutable constants', async () => {
const calls = [];
const context = {
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.Patterns.MICRO, 50);
assert.equal(vibration.Patterns.MINI, 100);
assert.equal(vibration.Patterns.SHORT, 200);
assert.equal(vibration.Patterns.LONG, 2000);
assert.deepEqual(Array.from(vibration.Patterns.ALARM), [100, 50, 100]);
assert.deepEqual(Array.from(vibration.Patterns.POI), [2000, 200, 2000]);
assert.deepEqual(Array.from(vibration.Patterns.ACTIVE_POI), [500, 300, 500]);
assert.equal(Object.isFrozen(vibration.Patterns), true);
});
test('vibration wrapper starts named patterns and can cancel them', async () => {
const calls = [];
const context = {
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.start(vibration.Patterns.ACTIVE_POI), true);
assert.equal(vibration.stop(), true);
assert.deepEqual(Array.from(calls[0]), [500, 300, 500]);
assert.equal(calls[1], 0);
});
test('vibration wrapper fails harmlessly when the browser API is unavailable', async () => {
const context = {
navigator: {},
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.isSupported(), false);
assert.equal(vibration.start(vibration.Patterns.SHORT), false);
assert.equal(vibration.stop(), false);
});