reworked app screens and added vibration
This commit is contained in:
parent
40f230864b
commit
44b1700c48
27
README.md
27
README.md
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
152
public/js/app.js
152
public/js/app.js
@ -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 () {
|
||||||
|
|||||||
2
public/js/bootstrap-loader.js
vendored
2
public/js/bootstrap-loader.js
vendored
@ -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
53
public/js/vibration.js
Normal 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
41
test/api-docs.test.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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'/);
|
||||||
|
});
|
||||||
|
|||||||
@ -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
61
test/vibration.test.js
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user