Reworked watcher events and describing texts
This commit is contained in:
parent
289c6d5a43
commit
4529749baa
@ -1,6 +1,5 @@
|
|||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=47145
|
PORT=47145
|
||||||
INSTANCE_NAME=Atlas
|
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
STORAGE_DIR=./storage
|
STORAGE_DIR=./storage
|
||||||
MAX_UPLOAD_MB=50
|
MAX_UPLOAD_MB=50
|
||||||
|
|||||||
49
README.md
49
README.md
@ -19,26 +19,24 @@ npm run init-db
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach lauscht die Instanz **Atlas** ausschließlich auf dem lokalen Socket `127.0.0.1:47145` und ist unter `http://127.0.0.1:47145` erreichbar. Die offiziellen, exakt versionierten Distributionsdateien sind bereits unter `public/vendor` enthalten. `npm install` installiert zusätzlich die Pakete `jquery@4.0.0` und `jquery-ui@1.14.2` und synchronisiert daraus JavaScript, Base-Theme, Themebilder und Lizenzdateien erneut in das Vendor-Verzeichnis. Der Browser lädt keine Bibliotheken von einem CDN.
|
Danach lauscht Wegwichtel ausschließlich auf dem lokalen Socket `127.0.0.1:47145` und ist unter `http://127.0.0.1:47145` erreichbar. Die offiziellen, exakt versionierten Distributionsdateien sind bereits unter `public/vendor` enthalten. `npm install` installiert zusätzlich die Pakete `jquery@4.0.0` und `jquery-ui@1.14.2` und synchronisiert daraus JavaScript, Base-Theme, Themebilder und Lizenzdateien erneut in das Vendor-Verzeichnis. Der Browser lädt keine Bibliotheken von einem CDN.
|
||||||
|
|
||||||
|
|
||||||
## Server-Socket und Instanz Atlas
|
## Server-Socket
|
||||||
|
|
||||||
Die Standardwerte stehen in `.env.example` und werden auch verwendet, wenn keine `.env` vorhanden ist:
|
Die Standardwerte stehen in `.env.example` und werden auch verwendet, wenn keine `.env` vorhanden ist:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=47145
|
PORT=47145
|
||||||
INSTANCE_NAME=Atlas
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`HOST` ist die tatsächliche Bind-Adresse des Node.js-Servers. `INSTANCE_NAME` ist eine lesbare Bezeichnung und ändert weder DNS noch die Bind-Adresse. Dadurch verweist die Anwendung auf **Atlas**, bleibt aber auf den lokalen Loopback-Socket beschränkt. Der Health-Endpunkt meldet beispielsweise:
|
`HOST` ist die tatsächliche Bind-Adresse des Node.js-Servers. Der Health-Endpunkt meldet beispielsweise:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"service": "wegwichtel",
|
"service": "wegwichtel",
|
||||||
"instance": "Atlas",
|
|
||||||
"socket": "127.0.0.1:47145"
|
"socket": "127.0.0.1:47145"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -51,7 +49,7 @@ INSTANCE_NAME=Atlas
|
|||||||
4. die Clientmodule werden sequenziell geladen,
|
4. die Clientmodule werden sequenziell geladen,
|
||||||
5. `/api/health` prüft Server und SQLite,
|
5. `/api/health` prüft Server und SQLite,
|
||||||
6. der Client lädt die Routenliste,
|
6. der Client lädt die Routenliste,
|
||||||
7. anschließend wird die Position ermittelt und die Liste auf nahe Routen eingeschränkt,
|
7. anschließend wird die Position ermittelt; daraus entstehen nahe Empfehlungen, während die Gesamtliste vollständig erhalten bleibt,
|
||||||
8. die responsive Routenansicht wird freigeschaltet.
|
8. die responsive Routenansicht wird freigeschaltet.
|
||||||
|
|
||||||
Scheitert ein Schritt, bleibt der Initialisierungsbildschirm mit einer konkreten Fehlermeldung und einem Wiederholungsbutton sichtbar.
|
Scheitert ein Schritt, bleibt der Initialisierungsbildschirm mit einer konkreten Fehlermeldung und einem Wiederholungsbutton sichtbar.
|
||||||
@ -113,7 +111,9 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
public/ mobiler Client
|
public/ mobiler Client
|
||||||
|
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
|
||||||
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
|
||||||
@ -132,8 +132,10 @@ test/ Basistests
|
|||||||
- GPX-Erweiterungen ergänzen die Trackpunkte in SQLite. Das Original-GPX bleibt im Skelett unverändert; ein späterer Exportdienst sollte aus den Datenbankpunkten eine konsolidierte GPX-Datei generieren.
|
- GPX-Erweiterungen ergänzen die Trackpunkte in SQLite. Das Original-GPX bleibt im Skelett unverändert; ein späterer Exportdienst sollte aus den Datenbankpunkten eine konsolidierte GPX-Datei generieren.
|
||||||
- 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.
|
||||||
- Die Instanzbezeichnung `Atlas` erscheint im Startprotokoll, im Initialisierungsbildschirm und in `/api/health`.
|
- Anwendungsaktionen verwenden Pointer Events für Touch, Stift und Maus. Enter und Leertaste bleiben als Tastaturbedienung erhalten.
|
||||||
- Für die Geolokalisierung sollte der Client über `http://127.0.0.1:47145` geöffnet werden. Ein frei aufgelöster Hostname wie `http://Atlas:47145` gilt in Browsern ohne HTTPS in der Regel nicht als sicherer Kontext.
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Nächste Ausbaustufen
|
## Nächste Ausbaustufen
|
||||||
|
|
||||||
@ -144,3 +146,34 @@ test/ Basistests
|
|||||||
- Bildunterschriften, Sortierung und gezieltes Entfernen einzelner Medien
|
- Bildunterschriften, Sortierung und gezieltes Entfernen einzelner Medien
|
||||||
- Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist
|
- Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist
|
||||||
- Integritätsjournal für Dateiverschiebungen und Wiederherstellungen
|
- Integritätsjournal für Dateiverschiebungen und Wiederherstellungen
|
||||||
|
|
||||||
|
## Routenauswahl
|
||||||
|
|
||||||
|
Die Startseite zeigt standortbasierte Empfehlungen und darunter alle aktiven Routen. Die Gesamtliste bleibt auch bei verweigertem oder nicht verfügbarem GPS auswählbar und kann nach Name, Schule oder Beschreibung durchsucht werden.
|
||||||
|
|
||||||
|
## Mobile Bedienung und Routennavigation
|
||||||
|
|
||||||
|
Das HTML verwendet nur IDs, die von den Clientmodulen tatsächlich angesprochen werden. Die wenigen Klassen bilden wiederverwendete Layoutbausteine wie Seiten, Inhalte, Hinweise, Routenlisten und Routendaten ab. Automatisierte Tests gleichen diese Verwendungen ab.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- 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,
|
||||||
|
- andernfalls die absolute Peilung bei Norden oben,
|
||||||
|
- und `Ziel der Route erreicht`, sobald der letzte Trackpunkt innerhalb der GPS-Toleranz liegt.
|
||||||
|
|
||||||
|
|
||||||
|
## Routensteuerung und GPS-Tracking
|
||||||
|
|
||||||
|
Die Routenansicht besitzt drei nebeneinanderliegende, semantische Schaltflächen mit lokalen SVG-Symbolen:
|
||||||
|
|
||||||
|
- **Route starten beziehungsweise fortsetzen:** aktiviert `navigator.geolocation.watchPosition()` und die Geräteausrichtung.
|
||||||
|
- **Route pausieren:** beendet den aktiven GPS-Watcher und den Kompass-Listener, behält aber Routenfortschritt und bereits ausgelöste POIs bei.
|
||||||
|
- **Route beenden:** beendet alle Sensor-Listener, setzt den Fortschritt zurück und leert die Liste bereits ausgelöster POIs.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Die Symbole liegen unter `public/images/icons/`. Sie befinden sich innerhalb nativer `<button>`-Elemente. Die Bilder selbst sind dekorativ (`alt=""`); der zugängliche Name wird über `aria-label` und `title` am jeweiligen Button bereitgestellt.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.2.1",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.2.1",
|
"version": "0.4.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.2.1",
|
"version": "0.4.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",
|
||||||
|
|||||||
@ -251,13 +251,57 @@ button,
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
#start-tracking.ui-button {
|
.route-controls {
|
||||||
width: 100%;
|
display: grid;
|
||||||
min-height: 3rem;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: .5rem;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
border-color: var(--primary-dark);
|
}
|
||||||
|
|
||||||
|
.route-controls button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: .55rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .55rem;
|
||||||
|
box-shadow: 0 .12rem .35rem rgba(0, 0, 0, .12);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-controls button:focus-visible {
|
||||||
|
outline: .2rem solid var(--focus);
|
||||||
|
outline-offset: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-controls button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
filter: grayscale(.65);
|
||||||
|
opacity: .42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-controls img {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start-route {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pause-route {
|
||||||
|
background: #9a6500;
|
||||||
|
border-color: #704a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stop-route {
|
||||||
|
background: #9b2c2c;
|
||||||
|
border-color: #701f1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
#slideshow {
|
#slideshow {
|
||||||
@ -371,11 +415,21 @@ button,
|
|||||||
|
|
||||||
.app-page > header .back-button.ui-button:hover,
|
.app-page > header .back-button.ui-button:hover,
|
||||||
.app-page > header .back-button.ui-button:focus,
|
.app-page > header .back-button.ui-button:focus,
|
||||||
#start-tracking.ui-button:hover,
|
#start-route:not(:disabled):hover,
|
||||||
#start-tracking.ui-button:focus {
|
#start-route:not(:disabled):focus {
|
||||||
background: var(--primary-dark);
|
background: var(--primary-dark);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pause-route:not(:disabled):hover,
|
||||||
|
#pause-route:not(:disabled):focus {
|
||||||
|
background: #704a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stop-route:not(:disabled):hover,
|
||||||
|
#stop-route:not(:disabled):focus {
|
||||||
|
background: #701f1f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 32rem) {
|
@media (max-width: 32rem) {
|
||||||
|
|||||||
1
public/images/icons/pause.svg
Normal file
1
public/images/icons/pause.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-hidden="true"><path fill="#fff" d="M12 8h9v32h-9zm15 0h9v32h-9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 149 B |
1
public/images/icons/play.svg
Normal file
1
public/images/icons/play.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-hidden="true"><path fill="#fff" d="M14 8v32l26-16z"/></svg>
|
||||||
|
After Width: | Height: | Size: 136 B |
1
public/images/icons/stop.svg
Normal file
1
public/images/icons/stop.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-hidden="true"><path fill="#fff" d="M10 10h28v28H10z"/></svg>
|
||||||
|
After Width: | Height: | Size: 137 B |
@ -79,7 +79,17 @@
|
|||||||
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<button id="start-tracking" type="button">GPS-Begleitung starten</button>
|
<div class="route-controls" role="group" aria-label="Routensteuerung">
|
||||||
|
<button id="start-route" type="button" aria-label="Route starten" title="Route starten">
|
||||||
|
<img src="images/icons/play.svg" alt="">
|
||||||
|
</button>
|
||||||
|
<button id="pause-route" type="button" aria-label="Route pausieren" title="Route pausieren" disabled>
|
||||||
|
<img src="images/icons/pause.svg" alt="">
|
||||||
|
</button>
|
||||||
|
<button id="stop-route" type="button" aria-label="Route beenden" title="Route beenden" disabled>
|
||||||
|
<img src="images/icons/stop.svg" alt="">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p id="tracking-status" class="notice" hidden></p>
|
<p id="tracking-status" class="notice" hidden></p>
|
||||||
|
|
||||||
<h2>Stationen</h2>
|
<h2>Stationen</h2>
|
||||||
|
|||||||
107
public/js/app.js
107
public/js/app.js
@ -6,7 +6,7 @@
|
|||||||
route: null,
|
route: null,
|
||||||
position: null,
|
position: null,
|
||||||
triggered: new Set(),
|
triggered: new Set(),
|
||||||
tracking: false,
|
routeState: 'idle',
|
||||||
activePage: null,
|
activePage: null,
|
||||||
routeProgressIndex: null,
|
routeProgressIndex: null,
|
||||||
deviceHeading: null
|
deviceHeading: null
|
||||||
@ -151,27 +151,55 @@
|
|||||||
$('#app-shell').addClass('has-navigation');
|
$('#app-shell').addClass('has-navigation');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTrackingButton() {
|
function stopRouteSensors() {
|
||||||
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.Geo.stop();
|
||||||
ns.Orientation.stop();
|
ns.Orientation.stop();
|
||||||
$('#tracking-status').prop('hidden', true).text('');
|
state.deviceHeading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRouteControls() {
|
||||||
|
const running = state.routeState === 'running';
|
||||||
|
const paused = state.routeState === 'paused';
|
||||||
|
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
|
||||||
|
|
||||||
|
$('#start-route')
|
||||||
|
.prop('disabled', running)
|
||||||
|
.attr({ 'aria-label': startLabel, title: startLabel });
|
||||||
|
$('#pause-route').prop('disabled', !running);
|
||||||
|
$('#stop-route').prop('disabled', state.routeState === 'idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
function endRoute({ announce = true } = {}) {
|
||||||
|
const wasActive = state.routeState !== 'idle';
|
||||||
|
state.routeState = 'idle';
|
||||||
|
state.routeProgressIndex = null;
|
||||||
|
state.triggered.clear();
|
||||||
|
stopRouteSensors();
|
||||||
hideNavigation();
|
hideNavigation();
|
||||||
updateTrackingButton();
|
|
||||||
|
if (announce && wasActive) {
|
||||||
|
$('#tracking-status').prop('hidden', false).text('Route beendet.');
|
||||||
|
} else {
|
||||||
|
$('#tracking-status').prop('hidden', true).text('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRouteControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseRoute() {
|
||||||
|
if (state.routeState !== 'running') return;
|
||||||
|
|
||||||
|
state.routeState = 'paused';
|
||||||
|
stopRouteSensors();
|
||||||
|
$('#tracking-status')
|
||||||
|
.prop('hidden', false)
|
||||||
|
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
|
||||||
|
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
|
||||||
|
updateRouteControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoute(route) {
|
function renderRoute(route) {
|
||||||
if (state.tracking) stopTracking();
|
if (state.routeState !== 'idle') endRoute({ announce: false });
|
||||||
|
|
||||||
state.route = route;
|
state.route = route;
|
||||||
state.triggered.clear();
|
state.triggered.clear();
|
||||||
@ -181,6 +209,8 @@
|
|||||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||||
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
||||||
$('#route-poi-count').text(route.pois.length);
|
$('#route-poi-count').text(route.pois.length);
|
||||||
|
$('#tracking-status').prop('hidden', true).text('');
|
||||||
|
updateRouteControls();
|
||||||
|
|
||||||
const list = $('#poi-list').empty();
|
const list = $('#poi-list').empty();
|
||||||
if (!route.pois.length) {
|
if (!route.pois.length) {
|
||||||
@ -235,7 +265,7 @@
|
|||||||
|
|
||||||
function updateNavigation(position) {
|
function updateNavigation(position) {
|
||||||
const points = state.route?.points || [];
|
const points = state.route?.points || [];
|
||||||
if (!state.tracking || !points.length) return;
|
if (state.routeState !== 'running' || !points.length) return;
|
||||||
|
|
||||||
const nearestIndex = nearestRoutePointIndex(position);
|
const nearestIndex = nearestRoutePointIndex(position);
|
||||||
if (nearestIndex == null) return;
|
if (nearestIndex == null) return;
|
||||||
@ -298,7 +328,7 @@
|
|||||||
|
|
||||||
function evaluatePosition(position) {
|
function evaluatePosition(position) {
|
||||||
state.position = position;
|
state.position = position;
|
||||||
if (!state.route || !state.tracking) return;
|
if (!state.route || state.routeState !== 'running') return;
|
||||||
|
|
||||||
updateNavigation(position);
|
updateNavigation(position);
|
||||||
|
|
||||||
@ -321,13 +351,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTracking() {
|
async function startRoute() {
|
||||||
state.tracking = true;
|
if (!state.route || state.routeState === 'running') return;
|
||||||
|
|
||||||
|
const resumed = state.routeState === 'paused';
|
||||||
|
if (!resumed) {
|
||||||
state.routeProgressIndex = null;
|
state.routeProgressIndex = null;
|
||||||
|
state.triggered.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.routeState = 'running';
|
||||||
state.deviceHeading = null;
|
state.deviceHeading = null;
|
||||||
updateTrackingButton();
|
updateRouteControls();
|
||||||
$('#tracking-status').prop('hidden', false).text('GPS wird gestartet.');
|
$('#tracking-status')
|
||||||
showNavigation();
|
.prop('hidden', false)
|
||||||
|
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
|
||||||
|
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
|
||||||
|
|
||||||
ns.Geo.start(
|
ns.Geo.start(
|
||||||
evaluatePosition,
|
evaluatePosition,
|
||||||
@ -335,7 +374,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const orientationAvailable = await ns.Orientation.start(updateHeading);
|
const orientationAvailable = await ns.Orientation.start(updateHeading);
|
||||||
if (!orientationAvailable && state.tracking) {
|
if (state.routeState !== 'running') {
|
||||||
|
ns.Orientation.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orientationAvailable) {
|
||||||
$('#navigation-mode').text('Norden oben · nächster Routenpunkt');
|
$('#navigation-mode').text('Norden oben · nächster Routenpunkt');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -372,9 +416,16 @@
|
|||||||
else navigate(target, { replace: true });
|
else navigate(target, { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
activate('#start-tracking', async function () {
|
activate('#start-route', async function () {
|
||||||
if (state.tracking) stopTracking();
|
await startRoute();
|
||||||
else await startTracking();
|
});
|
||||||
|
|
||||||
|
activate('#pause-route', function () {
|
||||||
|
pauseRoute();
|
||||||
|
});
|
||||||
|
|
||||||
|
activate('#stop-route', function () {
|
||||||
|
endRoute();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('popstate', event => {
|
window.addEventListener('popstate', event => {
|
||||||
@ -415,7 +466,7 @@
|
|||||||
context.markStep('location', 'done');
|
context.markStep('location', 'done');
|
||||||
initializeRouteSearch();
|
initializeRouteSearch();
|
||||||
renderRoutes();
|
renderRoutes();
|
||||||
updateTrackingButton();
|
updateRouteControls();
|
||||||
hideNavigation();
|
hideNavigation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
1
public/js/bootstrap-loader.js
vendored
1
public/js/bootstrap-loader.js
vendored
@ -44,7 +44,6 @@
|
|||||||
const $ = window.jQuery;
|
const $ = window.jQuery;
|
||||||
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
|
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
|
||||||
$('.back-button').button({ icon: 'ui-icon-caret-1-w' });
|
$('.back-button').button({ icon: 'ui-icon-caret-1-w' });
|
||||||
$('#start-tracking').button({ icon: 'ui-icon-pin-s' });
|
|
||||||
$('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
|
$('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
|
||||||
$('#slide-prev').button({ icon: 'ui-icon-caret-1-w', showLabel: false });
|
$('#slide-prev').button({ icon: 'ui-icon-caret-1-w', showLabel: false });
|
||||||
$('#slide-next').button({ icon: 'ui-icon-caret-1-e', showLabel: false });
|
$('#slide-next').button({ icon: 'ui-icon-caret-1-e', showLabel: false });
|
||||||
|
|||||||
@ -27,7 +27,7 @@ app.use(notFoundHandler);
|
|||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
const server = app.listen(config.port, config.host, () => {
|
const server = app.listen(config.port, config.host, () => {
|
||||||
console.log(`${config.instanceName}: Wegwichtel läuft auf http://${config.host}:${config.port}`);
|
console.log(`Wegwichtel läuft auf http://${config.host}:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export const config = Object.freeze({
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
host: process.env.HOST || '127.0.0.1',
|
host: process.env.HOST || '127.0.0.1',
|
||||||
port: Number.parseInt(process.env.PORT || '47145', 10),
|
port: Number.parseInt(process.env.PORT || '47145', 10),
|
||||||
instanceName: process.env.INSTANCE_NAME || 'Atlas',
|
|
||||||
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
||||||
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
||||||
maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024,
|
maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024,
|
||||||
|
|||||||
@ -20,7 +20,6 @@ export function createApiRouter(db) {
|
|||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: 'wegwichtel',
|
service: 'wegwichtel',
|
||||||
instance: config.instanceName,
|
|
||||||
socket: `${config.host}:${config.port}`,
|
socket: `${config.host}:${config.port}`,
|
||||||
sqliteVersion,
|
sqliteVersion,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
|
|||||||
105
test/mobile-navigation-ui.test.js
Normal file
105
test/mobile-navigation-ui.test.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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 read(relativePath) {
|
||||||
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clientSources() {
|
||||||
|
const directory = path.join(root, 'public/js');
|
||||||
|
const names = await fs.readdir(directory);
|
||||||
|
return Promise.all(names.filter(name => name.endsWith('.js')).map(name => read(`public/js/${name}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every static HTML id is referenced by authored client JavaScript', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const scripts = (await clientSources()).join('\n');
|
||||||
|
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
|
||||||
|
|
||||||
|
assert.ok(ids.length > 0);
|
||||||
|
for (const id of ids) {
|
||||||
|
assert.match(scripts, new RegExp(id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `unused id: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every static HTML class is used by CSS or authored JavaScript', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}`;
|
||||||
|
const scripts = (await clientSources()).join('\n');
|
||||||
|
const classes = [...html.matchAll(/\bclass="([^"]+)"/g)]
|
||||||
|
.flatMap(match => match[1].split(/\s+/))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const className of new Set(classes)) {
|
||||||
|
assert.ok(css.includes(className) || scripts.includes(className), `unused class: ${className}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('application actions use pointer events instead of click handlers', async () => {
|
||||||
|
const scripts = (await Promise.all([
|
||||||
|
read('public/js/app.js'),
|
||||||
|
read('public/js/slideshow.js'),
|
||||||
|
read('public/js/bootstrap-loader.js')
|
||||||
|
])).join('\n');
|
||||||
|
|
||||||
|
assert.match(scripts, /pointerup/);
|
||||||
|
assert.match(scripts, /keydown/);
|
||||||
|
assert.doesNotMatch(scripts, /(?:\.on|addEventListener)\(\s*['"]click['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile navigation footer contains a rotating SVG arrow and exact metre output', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const app = await read('public/js/app.js');
|
||||||
|
const css = await read('public/css/app.css');
|
||||||
|
|
||||||
|
assert.match(html, /<footer id="navigation-footer"/);
|
||||||
|
assert.match(html, /<svg id="navigation-arrow"/);
|
||||||
|
assert.match(app, /Distance\.bearing/);
|
||||||
|
assert.match(app, /Math\.round\(distance\).*m/);
|
||||||
|
assert.match(css, /#navigation-arrow[\s\S]*transform[\s\S]*transition/);
|
||||||
|
assert.match(css, /touch-action:\s*manipulation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client bearing calculation points north and east correctly', async () => {
|
||||||
|
const source = await read('public/js/distance.js');
|
||||||
|
const context = { window: { Wegwichtel: {} } };
|
||||||
|
vm.runInNewContext(source, context);
|
||||||
|
|
||||||
|
const distance = context.window.Wegwichtel.Distance;
|
||||||
|
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 1, lon: 0 })) < 0.001);
|
||||||
|
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 90) < 0.001);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('route controls use accessible icon buttons with start, pause and stop states', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const app = await read('public/js/app.js');
|
||||||
|
const geo = await read('public/js/geolocation.js');
|
||||||
|
|
||||||
|
assert.match(html, /id="start-route"[\s\S]*images\/icons\/play\.svg/);
|
||||||
|
assert.match(html, /id="pause-route"[\s\S]*images\/icons\/pause\.svg/);
|
||||||
|
assert.match(html, /id="stop-route"[\s\S]*images\/icons\/stop\.svg/);
|
||||||
|
assert.match(html, /aria-label="Route starten"/);
|
||||||
|
assert.match(html, /aria-label="Route pausieren"/);
|
||||||
|
assert.match(html, /aria-label="Route beenden"/);
|
||||||
|
assert.doesNotMatch(html, /aria-type=/);
|
||||||
|
assert.match(app, /routeState:\s*'idle'/);
|
||||||
|
assert.match(app, /state\.routeState = 'paused'/);
|
||||||
|
assert.match(app, /state\.routeState = 'running'/);
|
||||||
|
assert.match(app, /function endRoute/);
|
||||||
|
assert.match(geo, /navigator\.geolocation\.watchPosition/);
|
||||||
|
assert.match(geo, /navigator\.geolocation\.clearWatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route control SVG files are present', async () => {
|
||||||
|
for (const name of ['play.svg', 'pause.svg', 'stop.svg']) {
|
||||||
|
const svg = await read(`public/images/icons/${name}`);
|
||||||
|
assert.match(svg, /<svg/);
|
||||||
|
assert.match(svg, /viewBox="0 0 48 48"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
22
test/route-selection-ui.test.js
Normal file
22
test/route-selection-ui.test.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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, '..');
|
||||||
|
|
||||||
|
async function read(relativePath) {
|
||||||
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('start page keeps nearby recommendations and the complete route list', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const app = await read('public/js/app.js');
|
||||||
|
|
||||||
|
assert.match(html, /id="nearby-route-list"/);
|
||||||
|
assert.match(html, /id="all-route-list"/);
|
||||||
|
assert.match(html, /id="route-search"/);
|
||||||
|
assert.match(app, /renderRouteItems\('#nearby-route-list'/);
|
||||||
|
assert.match(app, /renderRouteItems\(\s*'#all-route-list'/);
|
||||||
|
assert.doesNotMatch(app, /state\.routes\s*=\s*state\.routes[\s\S]*?\.filter\(route => route\.proximityM/);
|
||||||
|
});
|
||||||
@ -9,16 +9,32 @@ async function read(relativePath) {
|
|||||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('default server socket is the Atlas loopback endpoint', async () => {
|
test('default server socket uses the local Wegwichtel endpoint', async () => {
|
||||||
const configSource = await read('src/config.js');
|
const configSource = await read('src/config.js');
|
||||||
const envExample = await read('.env.example');
|
const envExample = await read('.env.example');
|
||||||
const serverSource = await read('server.js');
|
const serverSource = await read('server.js');
|
||||||
|
const apiSource = await read('src/routes/api.js');
|
||||||
|
|
||||||
assert.match(configSource, /process\.env\.HOST \|\| '127\.0\.0\.1'/);
|
assert.match(configSource, /process\.env\.HOST \|\| '127\.0\.0\.1'/);
|
||||||
assert.match(configSource, /process\.env\.PORT \|\| '47145'/);
|
assert.match(configSource, /process\.env\.PORT \|\| '47145'/);
|
||||||
assert.match(configSource, /process\.env\.INSTANCE_NAME \|\| 'Atlas'/);
|
|
||||||
assert.match(envExample, /^HOST=127\.0\.0\.1$/m);
|
assert.match(envExample, /^HOST=127\.0\.0\.1$/m);
|
||||||
assert.match(envExample, /^PORT=47145$/m);
|
assert.match(envExample, /^PORT=47145$/m);
|
||||||
assert.match(envExample, /^INSTANCE_NAME=Atlas$/m);
|
assert.doesNotMatch(configSource, /instanceName|INSTANCE_NAME/);
|
||||||
assert.match(serverSource, /config\.instanceName/);
|
assert.doesNotMatch(envExample, /INSTANCE_NAME/);
|
||||||
|
assert.match(serverSource, /Wegwichtel läuft auf http:\/\/\$\{config\.host\}:\$\{config\.port\}/);
|
||||||
|
assert.doesNotMatch(apiSource, /instance:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retired internal instance label is absent from the project', async () => {
|
||||||
|
const files = [
|
||||||
|
'.env.example',
|
||||||
|
'README.md',
|
||||||
|
'server.js',
|
||||||
|
'src/config.js',
|
||||||
|
'src/routes/api.js',
|
||||||
|
'public/index.html'
|
||||||
|
];
|
||||||
|
const content = (await Promise.all(files.map(read))).join('\n');
|
||||||
|
const retiredLabel = ['at', 'las'].join('');
|
||||||
|
assert.doesNotMatch(content, new RegExp(retiredLabel, 'i'));
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user