overhaul gpx creation and usage
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-03 12:40:26 +02:00
parent 1cb3977269
commit 171ea88d55
29 changed files with 969 additions and 344 deletions
+1
View File
@@ -2,6 +2,7 @@ HOST=127.0.0.1
PORT=47145
DATA_DIR=./data
STORAGE_DIR=./storage
GPX_CLUSTER_METERS=20
MAX_UPLOAD_MB=50
DEFAULT_ROUTE_RADIUS_KM=25
DEFAULT_POI_TRIGGER_METERS=80
+8
View File
@@ -1,5 +1,9 @@
# Wegwichtel: Sprache der App
GPX-Änderungen berücksichtigen `GPX-Aufzeichnung.md`: Stationen sind `wpt`,
Aufzeichnungen sind `trkpt`, der Server normalisiert zu `rtept`. Fehlende
Koordinaten bleiben gespeichert und bearbeitbar; keine Ersatzkoordinaten erfinden.
Diese Vorgabe des Nutzers gilt für alle künftigen Änderungen an der Oberfläche.
Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntnisse.
@@ -29,6 +33,10 @@ Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntni
- Alle eigenen Bedienungstexte liegen zentral in der Klasse `WegwichtelTexts`
in `public/js/texts-de.js`. Keine neuen sichtbaren Texte direkt in HTML oder
Funktionscode schreiben.
- Ausnahme: Impressum und Datenschutzerklärung bleiben ausschließlich statisch
auf Deutsch in `public/index.html` und tragen `lang="de"`. Ihre Texte und
Kontaktangaben gehören nicht in Sprachdateien und erhalten keine i18n-Attribute.
Auch ihre Dialogüberschriften bleiben beim Sprachwechsel auf Deutsch.
- Das HTML enthält daraus erzeugte deutsche Standardtexte, damit Beschriftungen
vor dem Laden der Skripte vorhanden sind. Nach Änderungen am Textkatalog
`node scripts/localize-html.js` ausführen und `public/index.html` mit ablegen.
+80
View File
@@ -0,0 +1,80 @@
# GPX und Wegaufzeichnung
## Stationen und Weg getrennt speichern
- Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`.
- Beim ersten Speichern eines neuen Entwurfs startet eine unabhängige Aufzeichnung:
sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage.
Das Schließen des Bearbeitungsfensters beendet die Aufzeichnung nicht.
- Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden.
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
- Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und,
falls vorhanden, Höhe in der Datei des Entwurfs.
- Ohne Standort entsteht eine Erweiterung `wegwichtel:gpsGap` mit Zeitstempel.
Eine Station ohne Standort steht als `wegwichtel:pendingWaypoint` in den
Erweiterungen. Es werden keine Koordinaten wie `0,0` erfunden. Leere GPX-
`wpt` oder `trkpt` wären ungültig, daher werden Erweiterungen verwendet.
Die Aufzeichnung speichert immer die neueste Fassung des Entwurfs, damit
gleichzeitige Änderungen an Stationen erhalten bleiben. Auch eine manuelle
Bearbeitung ergänzt die neueste Aufzeichnung und bewahrt inzwischen hinzugekommene
Positionen. Speicherfehler pausieren die Aufzeichnung und zeigen einen Hinweis.
## Später Koordinaten ergänzen
Entwürfe und Stationen dürfen ohne GPS erstellt und gespeichert werden.
In der Streckenbearbeitung können unter „Orte auf dem Weg ergänzen“ fehlende
Positionen ergänzt, vorhandene geändert und weitere hinzugefügt werden.
Breiten- und Längengrad müssen gemeinsam angegeben werden oder beide leer bleiben.
Stationen haben eigene Felder unter „Ort ergänzen“.
Die Serverdatenbank erlaubt `NULL` in `pois.lat` und `pois.lon`.
`route_points` enthält ausschließlich gültige Koordinaten. Die Änderung ist in
`database/schema.sql` enthalten; eine bestehende Datenbank bekommt sie nicht
allein durch das Kopieren des Codes. Das Projekt verwendet beim Deployment
die vereinbarte Neuerstellung der Datenbank, keine automatische Migration.
## Normalisierung auf dem Server
Beim Einreichen oder Ersetzen einer GPX-Datei normalisiert der Node-Dienst den
aufgezeichneten Weg zu `rte/rtept`. Bereits vorhandene Routenpunkte haben Vorrang.
Aufeinanderfolgende Trackpunkte innerhalb des eingestellten Abstands werden
zusammengefasst. Der erste Punkt bleibt der Vertreter; der nächste wird erst
übernommen, wenn er weiter entfernt liegt. So werden kleine Schwankungen
während einer Pause nicht fortlaufend zur Weglänge addiert.
`GPX_CLUSTER_METERS` in `.env` steuert den Abstand, standardmäßig 20 Meter.
Das Verfahren ist eine geometrische Vereinfachung ohne externen Routingdienst.
Es berechnet keine Wege auf einer Straßenkarte. GPS-Lücken werden bei der
Routenberechnung übersprungen; zwischen gültigen Nachbarpunkten entsteht eine
direkte Verbindung. Die Lücken bleiben für spätere Bearbeitung als Erweiterungen
erhalten. Gibt es keine gültigen Wegpositionen, bleiben Weglänge und Punktzahl
bei 0 Metern und 0 Punkten; Stationen bleiben trotzdem gespeichert.
Die gespeicherte Serverdatei enthält den normalisierten Weg. Änderungen an
Stationen synchronisieren die `wpt` und die noch unvollständigen Stationen in
dieser Datei. GPX verwendet unverändert den Namespace
`http://www.topografix.com/GPX/1/1`; das ist eine Kennung, kein Netzwerkabruf.
## Anzeige beim Abspielen
Zwei Pfeile zeigen gleichzeitig die Richtung:
- zum nächsten Routenpunkt, ohne Entfernungsangabe;
- zur nächsten noch offenen Station mit Koordinaten, mit Metern.
Bei einer Strecke mit ausschließlich Stationen fehlt nur die Wegführung.
Stationen ohne Koordinaten lösen keine Standort-Erkennung aus. Während einer
Pause werden die Pfeile verborgen.
## Grenzen und Prüfung
Die Aufzeichnung läuft nur im geöffneten App-Fenster. Neuladen beendet sie;
bereits gespeicherte Positionen bleiben erhalten. Browser und Betriebssystem
können Zeitgeber und Standort-Abfragen im Hintergrund oder bei gesperrtem
Bildschirm verzögern. Ein verlässlicher Hintergrunddienst wird nicht zugesichert.
Bei verweigerter Standort-Erlaubnis bleiben die Einträge leer und bearbeitbar.
Die Tests prüfen Aufzeichnung, Pause/Fortsetzen/Ende, verspätete GPS-Antworten,
Speicherfehler, GPS-Lücken, manuelle Ergänzungen, getrennte Pfeile,
Servernormalisierung sowie Stationen ohne Koordinaten über die API.
+7
View File
@@ -35,6 +35,13 @@ HTML verwendet `data-i18n`, `data-i18n-title`, `data-i18n-aria-label`,
stehen Beschriftungen bereits vor dem Laden der Skripte bereit. Nach Änderungen
die erzeugte `public/index.html` mit ins Repository übernehmen.
Impressum und Datenschutzerklärung sind davon ausgenommen. Ihre rechtlichen
Texte und Kontaktangaben stehen ausschließlich statisch auf Deutsch in
`public/index.html`, ohne i18n-Attribute und mit `lang="de"` an beiden Artikeln.
Änderungen an diesen Angaben direkt im HTML vornehmen. Auch die zugehörigen
Dialogüberschriften bleiben auf Deutsch. Die Menüeinträge und die übrige
Bedienoberfläche werden weiterhin übersetzt.
Zum gezielten Prüfen kann `Wegwichtel.I18n.setLanguage('en')` aufgerufen werden.
Das aktualisiert feste Beschriftungen. Bereits aufgebaute dynamische Ansichten
müssen neu aufgebaut werden; die automatische Startauswahl erfolgt vor ihrem
+4
View File
@@ -4,6 +4,10 @@ Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwend
## Architektur
Die Trennung von Stationen und Weg, die Aufzeichnung alle 30 Sekunden und das
nachträgliche Ergänzen fehlender Koordinaten sind in
[`GPX-Aufzeichnung.md`](GPX-Aufzeichnung.md) beschrieben.
- **Server:** Node.js 22.13+, Express 5, `node:sqlite`, Multer, `fast-xml-parser`, `file-type` und `@file-type/av`
- **Client:** klassisches JavaScript, jQuery 4.0.0 und jQuery UI 1.14.2 mit lokalem Base-Theme
- **Medien:** getrennte Verzeichnisse für aktive und zum Löschen markierte Strecken
+2 -2
View File
@@ -50,8 +50,8 @@ CREATE TABLE IF NOT EXISTS pois (
route_id INTEGER NOT NULL REFERENCES routes(id),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
lat REAL NOT NULL,
lon REAL NOT NULL,
lat REAL,
lon REAL,
trigger_radius_m REAL NOT NULL DEFAULT 80,
sequence INTEGER NOT NULL DEFAULT 0,
audio_path TEXT,
+27 -3
View File
@@ -456,7 +456,7 @@ button,
left: 0;
z-index: 40;
display: grid;
grid-template-columns: 3.75rem minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
min-height: var(--footer-height);
box-sizing: border-box;
@@ -468,7 +468,8 @@ button,
box-shadow: 0 -.25rem .8rem rgba(0, 0, 0, .2);
backdrop-filter: blur(.4rem);
}
#navigation-arrow {
#navigation-arrow,
#route-navigation-arrow {
width: 3.5rem;
height: 3.5rem;
fill: currentColor;
@@ -484,7 +485,8 @@ button,
margin: 0;
}
#navigation-mode {
#navigation-mode,
#route-navigation-mode {
overflow: hidden;
font-size: .78rem;
text-overflow: ellipsis;
@@ -845,3 +847,25 @@ button,
padding: .65rem;
}
}
.navigation-target {
display: grid;
grid-template-columns: 2.8rem minmax(0, 1fr);
align-items: center;
gap: .35rem;
min-width: 0;
}
.navigation-target svg { width: 2.7rem; height: 2.7rem; }
#route-recording-control {
position: fixed;
top: calc(env(safe-area-inset-top) + var(--icon-button-size) + 1rem);
left: max(.5rem, env(safe-area-inset-left));
right: max(.5rem, env(safe-area-inset-right));
z-index: 45;
padding: .5rem;
border-radius: .7rem;
background: #f8f5eb;
box-shadow: 0 .25rem 1rem rgba(0, 0, 0, .2);
}
#route-recording-status { display: block; margin-bottom: .35rem; }
#route-recording-control button { min-height: 2.75rem; }
+11
View File
@@ -58,6 +58,17 @@
color: #233326;
box-shadow: 0 .8rem 2rem rgba(20, 32, 22, .28);
touch-action: pan-y;
transform-origin: top right;
transform: scaleY(0);
opacity: 0;
pointer-events: none;
transition: transform 180ms ease-out, opacity 180ms ease-out;
}
.route-control-dropdown.is-open {
transform: scaleY(1);
opacity: 1;
pointer-events: auto;
}
.route-control-dropdown section + section {
+84 -57
View File
@@ -94,7 +94,7 @@
<p id="android-browser-hint" class="android-browser-hint" hidden></p>
<div class="privacy-summary">
<p data-i18n="privacy.no-tracking">Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.</p>
<p data-i18n="app.no-tracking">Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.</p>
<label for="cookie-toggle-1" class="switch-row switch-disabled">
<span>
<strong data-i18n="progress.cookies-label">Optionale Cookies</strong>
@@ -158,6 +158,11 @@
</section>
</div>
<main id="app-shell" hidden>
<aside id="route-recording-control" hidden>
<span id="route-recording-status" aria-live="polite"></span>
<button id="route-recording-pause" type="button" data-i18n="recorder.pause">Aufnahme pausieren</button>
<button id="route-recording-stop" type="button" data-i18n="recorder.stop">Aufnahme beenden</button>
</aside>
<div class="route-control">
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown" data-i18n-title="routes.open-menu" data-i18n-aria-label="routes.open-menu">
<img src="images/icons/route-management.svg" alt="" aria-hidden="true">
@@ -280,13 +285,21 @@
</div>
</section>
<footer id="navigation-footer" hidden aria-live="polite">
<svg id="navigation-arrow" viewbox="0 0 64 64" aria-hidden="true" focusable="false">
<path d="M32 4 53 54 32 44 11 54Z"></path>
</svg>
<p>
<span id="navigation-mode" data-i18n="navigation.station-direction">Richtung zum nächsten Station</span>
<strong id="navigation-distance">–</strong>
</p>
<div class="navigation-target">
<svg id="route-navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<path d="M32 4 53 54 32 44 11 54Z"></path>
</svg>
<p><span id="route-navigation-mode" data-i18n="recorder.path-direction">Dem Weg folgen</span></p>
</div>
<div id="station-navigation" class="navigation-target">
<svg id="navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<path d="M32 4 53 54 32 44 11 54Z"></path>
</svg>
<p>
<span id="navigation-mode" data-i18n="navigation.station-direction">Richtung zur nächsten Station</span>
<strong id="navigation-distance">–</strong>
</p>
</div>
</footer>
</main>
<dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title">
@@ -296,62 +309,64 @@
<button id="info-close" type="button" aria-label="Informationsfenster schließen" title="Schließen" data-i18n-title="info.close" data-i18n-aria-label="info.close-label">×</button>
</header>
<div class="info-dialog-content">
<article class="info-page" data-info-page="privacy" hidden>
<p class="legal-warning"><strong data-i18n="common.notice">Hinweis:</strong><span data-i18n="privacy.incomplete-notice">Diese Angaben sind noch nicht vollständig. Die Kontakte stammen von der Grundschule Herpf. Wer die Website betreibt, welche Verbindungsdaten gespeichert werden und wie lange sie bleiben, muss noch ergänzt werden.</span></p>
<h3 data-i18n="privacy.controller-title">1. Verantwortliche Stelle und Datenschutzkontakt</h3>
<article class="info-page" data-info-page="privacy" lang="de" hidden>
<p class="legal-warning"><strong>Hinweis:</strong><span>Diese Angaben sind noch nicht vollständig. Die Kontakte stammen von der Grundschule Herpf. Wer die Website betreibt, welche Verbindungsdaten gespeichert werden und wie lange sie bleiben, muss noch ergänzt werden.</span></p>
<h3>1. Verantwortliche Stelle und Datenschutzkontakt</h3>
<p>
<strong data-i18n="contacts.school.name">Staatliche Grundschule Herpf</strong><br><span data-i18n="contacts.school.address">Zum Eichig 3</span><br><span data-i18n="contacts.school.city">98617 Meiningen / OT Herpf</span><br><span data-i18n="contacts.school.phone">Telefon: (036943) 63722</span><br><span data-i18n="contacts.school.email">E-Mail: info@grundschule-herpf.de</span></p>
<p><span data-i18n="contacts.school.privacy-label">Datenschutzangaben der Schule:</span><br>
<strong data-i18n="contacts.privacy.name">Staatliches Schulamt Südthüringen</strong><br><span data-i18n="contacts.privacy.role">Datenschutzbeauftragter</span><br><span data-i18n="contacts.privacy.address">Hölderlinstraße 1</span><br><span data-i18n="contacts.privacy.city">98527 Suhl</span><br><span data-i18n="contacts.privacy.phone">Telefon: 03681 / 7341 25</span><br><span data-i18n="contacts.privacy.fax">Fax: 03681 / 7341 09</span><br><span data-i18n="contacts.privacy.email">E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de</span></p>
<p data-i18n="contacts.privacy.postal-hint">Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
<h3 data-i18n="privacy.connection-title">2. Wenn du Wegwichtel öffnest</h3>
<p data-i18n="privacy.connection-data">Wenn du Wegwichtel öffnest, können Verbindungsdaten verarbeitet werden. Dazu gehören zum Beispiel die Internetadresse deines Geräts, Datum, Uhrzeit und Zeitzone, die aufgerufene Seite, übertragene Daten und ob der Aufruf geklappt hat. Auch die vorherige Seite und Angaben zu deinem Gerät, Internetbrowser und dessen Sprache können dazugehören. Diese Daten helfen dabei, die Website bereitzustellen, Fehler zu finden und sie zu schützen.</p>
<p><span data-i18n="privacy.provider-label">Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:</span><strong data-i18n="privacy.provider-placeholder">[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]</strong></p>
<h3 data-i18n="privacy.location-title">3. Standortdaten</h3>
<p data-i18n="privacy.location-data">Du entscheidest, ob Wegwichtel deinen Standort nutzen darf. Damit findest du Strecken in deiner Nähe und siehst den Weg zur nächsten Station. Dein laufender Standort wird auf deinem Gerät verarbeitet. Wegwichtel speichert daraus keinen Verlauf auf der Website.</p>
<h3 data-i18n="privacy.progress-title">4. Deinen Fortschritt merken</h3>
<p data-i18n="privacy.progress-data">Wenn du „Meinen Fortschritt merken“ einschaltest, speichert Wegwichtel deine gewählte Strecke, besuchte und angesehene Stationen sowie angehörte Tonaufnahmen. Auch ob du unterwegs bist, eine Pause machst oder fertig bist und der Zeitpunkt werden gespeichert. Diese Angaben bleiben auf deinem Gerät und werden nicht an die Website gesendet.</p>
<p data-i18n="privacy.progress-deletion">Wenn du deinen Fortschritt mindestens einen Tag lang nicht verändert hast, wird er beim nächsten Öffnen gelöscht. Beim Ausschalten wird er sofort gelöscht. Deine eigenen und für unterwegs gespeicherten Strecken bleiben erhalten.</p>
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span><br><span>Telefon: (036943) 63722</span><br><span>E-Mail: info@grundschule-herpf.de</span></p>
<p><span>Datenschutzangaben der Schule:</span><br>
<strong>Staatliches Schulamt Südthüringen</strong><br><span>Datenschutzbeauftragter</span><br><span>Hölderlinstraße 1</span><br><span>98527 Suhl</span><br><span>Telefon: 03681 / 7341 25</span><br><span>Fax: 03681 / 7341 09</span><br><span>E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de</span></p>
<p>Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
<h3>2. Wenn du Wegwichtel öffnest</h3>
<p>Wenn du Wegwichtel öffnest, können Verbindungsdaten verarbeitet werden. Dazu gehören zum Beispiel die Internetadresse deines Geräts, Datum, Uhrzeit und Zeitzone, die aufgerufene Seite, übertragene Daten und ob der Aufruf geklappt hat. Auch die vorherige Seite und Angaben zu deinem Gerät, Internetbrowser und dessen Sprache können dazugehören. Diese Daten helfen dabei, die Website bereitzustellen, Fehler zu finden und sie zu schützen.</p>
<p><span>Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:</span><strong>[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]</strong></p>
<h3>3. Standortdaten</h3>
<p>Du entscheidest, ob Wegwichtel deinen Standort nutzen darf. Damit findest du Strecken in deiner Nähe und siehst den Weg zur nächsten Station. Dein laufender Standort wird auf deinem Gerät verarbeitet. Wegwichtel speichert daraus keinen Verlauf auf der Website.</p>
<h3>4. Deinen Fortschritt merken</h3>
<p>Wenn du „Meinen Fortschritt merken“ einschaltest, speichert Wegwichtel deine gewählte Strecke, besuchte und angesehene Stationen sowie angehörte Tonaufnahmen. Auch ob du unterwegs bist, eine Pause machst oder fertig bist und der Zeitpunkt werden gespeichert. Diese Angaben bleiben auf deinem Gerät und werden nicht an die Website gesendet.</p>
<p>Wenn du deinen Fortschritt mindestens einen Tag lang nicht verändert hast, wird er beim nächsten Öffnen gelöscht. Beim Ausschalten wird er sofort gelöscht. Deine eigenen und für unterwegs gespeicherten Strecken bleiben erhalten.</p>
<label for="progress-storage-toggle-2" class="switch-row compact-switch">
<span>
<strong data-i18n="progress.remember">Meinen Fortschritt merken</strong>
<small data-i18n="progress.choice-hint">Du entscheidest, ob sich Wegwichtel deinen Fortschritt merkt.</small>
<strong>Meinen Fortschritt merken</strong>
<small>Du entscheidest, ob sich Wegwichtel deinen Fortschritt merkt.</small>
</span>
<input id="progress-storage-toggle-2" class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span>
</label>
<h3 data-i18n="privacy.drafts-title">5. Deine eigenen Strecken</h3>
<p data-i18n="privacy.draft-data">Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
<p data-i18n="privacy.draft-deletion">Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.</p>
<h3 data-i18n="privacy.settings-title">6. Werbung und gespeicherte Einstellungen</h3>
<p data-i18n="privacy.settings-data">Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.</p>
<h3 data-i18n="privacy.children-title">7. Inhalte von Schülerinnen und Schülern</h3>
<p data-i18n="privacy.children-permission">Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.</p>
<h3 data-i18n="privacy.rights-title">8. Deine Rechte</h3>
<p data-i18n="privacy.rights-data">Du kannst fragen, welche Daten über dich gespeichert sind. Unter den gesetzlichen Voraussetzungen kannst du verlangen, dass sie berichtigt, gelöscht, nur noch eingeschränkt genutzt oder dir zur Weitergabe bereitgestellt werden. Du kannst der Nutzung widersprechen und eine erteilte Erlaubnis für die Zukunft zurücknehmen. Wenn du dich über den Umgang mit deinen Daten beschweren möchtest, kannst du dich an die folgende Datenschutzbehörde wenden.</p>
<p><span data-i18n="contacts.authority.label">Zuständige Datenschutzaufsicht:</span><br>
<strong data-i18n="contacts.authority.name">Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)</strong><br><span data-i18n="contacts.authority.postal-address">Postfach 90 04 55, 99107 Erfurt</span><br><span data-i18n="contacts.authority.address">Häßlerstraße 8, 99096 Erfurt</span><br><span data-i18n="contacts.authority.phone">Telefon: 0361 / 57 311 29 00</span><br><span data-i18n="contacts.authority.fax">Telefax: 0361 / 57 311 29 04</span><br><span data-i18n="contacts.authority.email">E-Mail: poststelle@datenschutz.thueringen.de</span><br><span data-i18n="contacts.authority.website">Website: www.tlfdi.de</span></p>
<h3>5. Deine eigenen Strecken</h3>
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
<p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.</p>
<h3>6. Werbung und gespeicherte Einstellungen</h3>
<p>Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.</p>
<h3>7. Inhalte von Schülerinnen und Schülern</h3>
<p>Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.</p>
<h3>8. Deine Rechte</h3>
<p>Du kannst fragen, welche Daten über dich gespeichert sind. Unter den gesetzlichen Voraussetzungen kannst du verlangen, dass sie berichtigt, gelöscht, nur noch eingeschränkt genutzt oder dir zur Weitergabe bereitgestellt werden. Du kannst der Nutzung widersprechen und eine erteilte Erlaubnis für die Zukunft zurücknehmen. Wenn du dich über den Umgang mit deinen Daten beschweren möchtest, kannst du dich an die folgende Datenschutzbehörde wenden.</p>
<p><span>Zuständige Datenschutzaufsicht:</span><br>
<strong>Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)</strong><br><span>Postfach 90 04 55, 99107 Erfurt</span><br><span>Häßlerstraße 8, 99096 Erfurt</span><br><span>Telefon: 0361 / 57 311 29 00</span><br><span>Telefax: 0361 / 57 311 29 04</span><br><span>E-Mail: poststelle@datenschutz.thueringen.de</span><br><span>Website: www.tlfdi.de</span></p>
</article>
<article class="info-page" data-info-page="imprint" hidden>
<p class="legal-warning"><strong data-i18n="common.notice">Hinweis:</strong><span data-i18n="imprint.incomplete-notice">Diese Angaben stammen von der Grundschule Herpf. Vor der Veröffentlichung muss geprüft werden, ob für Wegwichtel noch weitere oder andere Angaben nötig sind.</span></p>
<h3 data-i18n="imprint.controller-title">Wer für Wegwichtel verantwortlich ist</h3>
<article class="info-page" data-info-page="imprint" lang="de" hidden>
<p class="legal-warning"><strong>Hinweis:</strong><span>Diese Angaben stammen von der Grundschule Herpf. Vor der Veröffentlichung muss geprüft werden, ob für Wegwichtel noch weitere oder andere Angaben nötig sind.</span></p>
<h3>Wer für Wegwichtel verantwortlich ist</h3>
<p>
<strong data-i18n="contacts.school.name">Staatliche Grundschule Herpf</strong><br><span data-i18n="contacts.school.address">Zum Eichig 3</span><br><span data-i18n="contacts.school.city">98617 Meiningen / OT Herpf</span></p>
<h3 data-i18n="imprint.contact-title">Kontakt</h3>
<p><span data-i18n="contacts.school.phone">Telefon: (036943) 63722</span><br><span data-i18n="contacts.school.fax">Telefax: (036943) 63843</span><br><span data-i18n="contacts.school.email">E-Mail: info@grundschule-herpf.de</span></p>
<h3 data-i18n="imprint.head-title">Leitung der Schule</h3>
<p data-i18n="contacts.school.head">Frau Wichert, Schulleiterin</p>
<h3 data-i18n="imprint.sponsor-title">Schulträger</h3>
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span></p>
<h3>Kontakt</h3>
<p><span>Telefon: (036943) 63722</span><br><span>Telefax: (036943) 63843</span><br><span>E-Mail: info@grundschule-herpf.de</span></p>
<h3>Leitung der Schule</h3>
<p>Frau Wichert, Schulleiterin</p>
<h3>Schulträger</h3>
<p>
<strong data-i18n="contacts.sponsor.name">Landkreis Schmalkalden-Meiningen</strong><br><span data-i18n="contacts.sponsor.role">Landratsamt</span><br><span data-i18n="contacts.sponsor.address">Obertshäuser Platz 1</span><br><span data-i18n="contacts.sponsor.city">98617 Meiningen</span></p>
<h3 data-i18n="imprint.content-title">Inhaltlich verantwortlich</h3>
<p data-i18n="contacts.school.content-owner">Frau Wichert</p>
<h3 data-i18n="imprint.required-title">Weitere Pflichtangaben</h3>
<p data-i18n="imprint.required-data">Je nachdem, wer Wegwichtel betreibt, können weitere Angaben nötig sein. Dazu gehören zum Beispiel die Verantwortlichen für den Betrieb der Website. Diese Angaben müssen noch geprüft werden.</p>
<h3 data-i18n="imprint.liability-title">Haftung für Inhalte und Verweise</h3>
<p data-i18n="imprint.liability-data">Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Falls etwas falsch, veraltet oder nicht erlaubt ist, melde dich beim Kontakt oben. Für andere verlinkte Seiten sind deren Betreiber verantwortlich.</p>
<h3 data-i18n="imprint.copyright-title">Urheber- und Nutzungsrechte</h3>
<p data-i18n="imprint.copyright-data">Wenn nichts anderes angegeben ist, liegen die Rechte bei der Grundschule Herpf. Du darfst Texte, Bilder, Tonaufnahmen und Wege nur so nutzen, wie es jeweils erlaubt wurde. Auch die Rechte der beteiligten Kinder, Familien, Lehrkräfte und anderen Personen bleiben bestehen.</p>
<strong>Landkreis Schmalkalden-Meiningen</strong><br><span>Landratsamt</span><br><span>Obertshäuser Platz 1</span><br><span>98617 Meiningen</span></p>
<h3>Inhaltlich verantwortlich</h3>
<p>Frau Wichert</p>
<h3>Technisch verantwortlich</h3>
<p>Florian Zumpe<br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: [E-Mail-Adresse von Florian Zumpe ergänzen]</span></p>
<h3>Weitere Pflichtangaben</h3>
<p>Je nachdem, wer Wegwichtel betreibt, können weitere Angaben nötig sein. Dazu gehören zum Beispiel die Verantwortlichen für den Betrieb der Website. Diese Angaben müssen noch geprüft werden.</p>
<h3>Haftung für Inhalte und Verweise</h3>
<p>Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Falls etwas falsch, veraltet oder nicht erlaubt ist, melde dich beim Kontakt oben. Für andere verlinkte Seiten sind deren Betreiber verantwortlich.</p>
<h3>Urheber- und Nutzungsrechte</h3>
<p>Wenn nichts anderes angegeben ist, liegen die Rechte bei der Grundschule Herpf. Du darfst Texte, Bilder, Tonaufnahmen und Wege nur so nutzen, wie es jeweils erlaubt wurde. Auch die Rechte der beteiligten Kinder, Familien, Lehrkräfte und anderen Personen bleiben bestehen.</p>
</article>
<article class="info-page" data-info-page="technology" hidden>
<h3 data-i18n="storage-help.progress-title">Was merkt sich Wegwichtel?</h3>
@@ -429,6 +444,12 @@
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
</div>
<details id="editor-path-positions">
<summary data-i18n="recorder.edit-path">Orte auf dem Weg ergänzen</summary>
<p data-i18n="recorder.edit-help">Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.</p>
<div id="editor-recorded-positions"></div>
<button id="editor-add-position" type="button" data-i18n="recorder.add-position">Ort auf dem Weg hinzufügen</button>
</details>
<p id="editor-route-activated" class="editor-muted" data-i18n="editor.new-draft-hint">Deine neue Strecke bleibt zuerst auf diesem Gerät. Mit „Zur Prüfung senden“ kannst du sie zur Freigabe einreichen.</p>
<div class="editor-form-actions">
@@ -458,8 +479,13 @@
<label for="editor-poi-description" data-i18n="editor.station-text">Text</label>
<textarea id="editor-poi-description" rows="6"></textarea>
<input id="editor-poi-lat" type="hidden">
<input id="editor-poi-lon" type="hidden">
<details>
<summary data-i18n="recorder.manual-location">Ort ergänzen oder ändern</summary>
<label for="editor-poi-lat" data-i18n="recorder.latitude">Breitengrad</label>
<input id="editor-poi-lat" type="number" step="any" min="-90" max="90">
<label for="editor-poi-lon" data-i18n="recorder.longitude">Längengrad</label>
<input id="editor-poi-lon" type="number" step="any" min="-180" max="180">
</details>
<input id="editor-poi-radius" type="hidden" value="25">
<div class="editor-media-actions" aria-label="Bilder und Ton zur Station hinzufügen" data-i18n-aria-label="editor.media-actions-label">
@@ -500,6 +526,7 @@
<script src="js/i18n.js?v=0.12.14"></script>
<script src="js/user-messages.js?v=0.12.14"></script>
<script src="js/local-routes.js?v=0.12.14"></script>
<script src="js/route-recorder.js?v=0.12.14"></script>
<script src="js/editor-ui.js?v=0.12.14"></script>
<script src="js/privacy-storage.js?v=0.12.14"></script>
<script src="js/legal-ui.js?v=0.12.14"></script>
@@ -508,4 +535,4 @@
<script src="js/offline-cache.js?v=0.12.14"></script>
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
</body>
</html>
</html>
+40 -26
View File
@@ -1,6 +1,10 @@
(function (ns, $) {
'use strict';
const DROPDOWN_ANIMATION_MS = 180;
let routeControlOpen = false;
let routeControlCloseTimer = null;
const state = {
routes: [],
localRoutes: [],
@@ -319,6 +323,9 @@
.prop('hidden', false)
.text(ns.I18n.t("app.pause-hint"));
showNavigation(ns.I18n.t("app.route-paused"), $('#navigation-distance').text() || '–');
$('#navigation-arrow').prop('hidden', true);
$('#route-navigation-arrow').prop('hidden', true);
$('#route-navigation-mode').text(ns.I18n.t('app.route-paused'));
updateRouteControls();
}
@@ -421,7 +428,7 @@
function poiMetrics(position) {
if (!position || !state.route?.pois?.length) return [];
const heading = effectiveHeading(position);
return state.route.pois.map(poi => {
return state.route.pois.filter(poi => Number.isFinite(poi.lat) && Number.isFinite(poi.lon)).map(poi => {
const distance = ns.Distance.meters(position, poi);
const bearing = ns.Distance.bearing(position, poi);
return {
@@ -498,49 +505,41 @@
: ns.I18n.t("app.next-station", { value1: metric.poi.title }),
`${distance} m`
);
$('#navigation-footer').attr(
$('#station-navigation').attr(
'aria-label',
ns.I18n.t("app.next-station-distance", { value1: metric.poi.title, value2: distance })
);
}
function showRoutePointNavigation(position, progress) {
const arrow = $('#route-navigation-arrow');
const mode = $('#route-navigation-mode');
if (!progress) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation(ns.I18n.t("app.path.missing"), '–');
arrow.css('transform', 'rotate(0deg)').prop('hidden', true);
mode.text(ns.I18n.t('app.path.missing'));
return;
}
if (progress.reached) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation(ns.I18n.t("app.destination-reached"), '0 m');
$('#navigation-footer').attr('aria-label', ns.I18n.t("app.destination-reached"));
arrow.css('transform', 'rotate(0deg)').prop('hidden', true);
mode.text(ns.I18n.t('app.destination-reached'));
return;
}
const bearing = ns.Distance.bearing(position, progress.target);
const heading = effectiveHeading(position);
const rotation = heading == null
? bearing
: ns.Distance.normalizeDegrees(bearing - heading);
const distance = Math.round(progress.distance);
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
showNavigation(
heading == null ? ns.I18n.t("app.next-path-point-north") : ns.I18n.t("app.next-path-point"),
`${distance} m`
);
$('#navigation-footer').attr(
'aria-label',
ns.I18n.t("app.next-path-point-distance", { value1: distance })
);
const rotation = heading == null ? bearing : ns.Distance.normalizeDegrees(bearing - heading);
arrow.prop('hidden', false).css('transform', `rotate(${rotation.toFixed(1)}deg)`);
mode.text(ns.I18n.t(heading == null ? 'app.next-path-point-north' : 'app.next-path-point'));
}
function updateNavigation(position) {
if (state.routeState !== 'running') return;
const progress = updateRouteProgress(position);
showRoutePointNavigation(position, progress);
const metrics = updatePoiIndicators(position);
const nextPoi = nearestPendingPoi(metrics);
$('#navigation-arrow').prop('hidden', !nextPoi);
if (nextPoi) showPoiNavigation(position, nextPoi);
else showRoutePointNavigation(position, progress);
else showNavigation(ns.I18n.t('recorder.no-station'), '–');
}
function updateHeading(heading) {
@@ -659,8 +658,23 @@
}
function setRouteControlOpen(open) {
$('#route-control-dropdown').prop('hidden', !open);
$('#route-control-button').attr('aria-expanded', String(open));
routeControlOpen = Boolean(open);
globalThis.clearTimeout(routeControlCloseTimer);
routeControlCloseTimer = null;
const dropdown = $('#route-control-dropdown');
$('#route-control-button').attr('aria-expanded', String(routeControlOpen));
if (routeControlOpen) {
dropdown.prop('hidden', false);
const nextFrame = globalThis.requestAnimationFrame ?? (callback => callback());
nextFrame(() => nextFrame(() => {
if (routeControlOpen) dropdown.addClass('is-open');
}));
} else {
dropdown.removeClass('is-open');
routeControlCloseTimer = globalThis.setTimeout(() => {
if (!routeControlOpen) dropdown.prop('hidden', true);
}, DROPDOWN_ANIMATION_MS);
}
}
async function selectRoute(source, id) {
@@ -718,7 +732,7 @@
});
activate('#route-control-button', function () {
setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
setRouteControlOpen(!routeControlOpen);
});
activate('.route-control-actions #route-control-new', function () {
@@ -731,7 +745,7 @@
});
$(document).on('pointerup', event => {
if ($('#route-control-dropdown').prop('hidden')) return;
if (!routeControlOpen) return;
if ($(event.target).closest('.route-control').length) return;
setRouteControlOpen(false);
});
+11 -11
View File
@@ -495,6 +495,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function fillRouteForm(route) {
setHidden(byId('editor-path-positions'), state.currentSource !== 'local');
if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name));
byId(ids.routeId).value = routeId(route);
byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(route);
byId(ids.routeDescription).value = text(route?.description);
@@ -814,13 +816,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function saveLocalRoute(values) {
const file = byId(ids.routeGpx)?.files?.[0];
let gpx = state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
let gpx = ns.LocalRoutes.get?.(routeId(state.currentRoute))?.gpx || state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
if (file) {
gpx = await ns.LocalRoutes.readFileAsText(file);
const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
if (!state.pois.length && importedPois.length) state.pois = importedPois;
}
gpx = ns.RouteRecorder?.editGpx(gpx, values.name) ?? gpx;
const isNew = !routeId(state.currentRoute);
const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
const saved = ns.LocalRoutes.save({
id,
@@ -835,6 +839,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
});
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.currentSource = 'local';
if (isNew) ns.RouteRecorder?.start(saved.id);
state.pois = saved.pois;
await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' });
@@ -989,17 +994,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function currentPosition() {
if (ns.RouteRecorder?.routeId === routeId(state.currentRoute)) return ns.RouteRecorder.position;
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
}
function newPoi() {
const position = currentPosition();
const lat = Number(position?.lat);
const lon = Number(position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setStatus(ns.I18n.t("editor.error.location-required"), true);
return;
}
const lat = position?.lat ?? null;
const lon = position?.lon ?? null;
const accuracy = Number(position?.accuracy);
state.currentPoi = null;
@@ -1067,7 +1069,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const currentPoiId = poiId(state.currentPoi);
const saved = ns.LocalRoutes.save({
id,
gpx: state.currentRoute.gpx,
gpx: ns.LocalRoutes.get?.(routeId(state.currentRoute))?.gpx || state.currentRoute.gpx,
route: state.currentRoute,
pois: state.pois
});
@@ -1081,9 +1083,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError(ns.I18n.t("editor.error.station-location"));
}
const currentId = numericPoiId(byId(ids.poiId).value);
const identifier = currentId ?? nextLocalPoiId();
byId(ids.poiId).value = String(identifier);
+3 -2
View File
@@ -29,7 +29,7 @@
}
ns.Geo = {
current() {
current(options = {}) {
return new Promise((resolve, reject) => {
const service = geolocation();
if (!service) {
@@ -43,7 +43,8 @@
{
...WATCH_OPTIONS,
timeout: ns.Config.geolocationTimeoutMs,
maximumAge: 30000
maximumAge: 30000,
...options
}
);
});
+8 -3
View File
@@ -5,8 +5,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const DROPDOWN_ANIMATION_MS = 180;
const pageTitles = Object.freeze({
get privacy() { return ns.I18n.t("info.privacy"); },
get imprint() { return ns.I18n.t("info.imprint"); },
privacy: 'Datenschutzerklärung',
imprint: 'Impressum',
get technology() { return ns.I18n.t("info.storage"); }
});
function menuEntries() { return [
@@ -438,7 +438,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function updateDialogTitle(pageName) {
if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? ns.I18n.t("info.title");
if (!elements?.title) return;
const isLegalPage = pageName === 'privacy' || pageName === 'imprint';
if (isLegalPage) delete elements.title.dataset.i18n;
else elements.title.dataset.i18n = 'info.storage';
setAttribute(elements.title, 'lang', isLegalPage ? 'de' : ns.I18n.language);
elements.title.textContent = pageTitles[pageName] ?? ns.I18n.t("info.title");
}
function focusSafely(element) {
+83 -20
View File
@@ -298,7 +298,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function parseWaypoints(gpx) {
const document = parseXml(gpx);
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
return [...Array.from(document.getElementsByTagNameNS('*', 'wpt')), ...Array.from(document.getElementsByTagNameNS('*', 'pendingWaypoint'))].map((node, index) => ({
id: waypointId(node),
title: textOfChild(node, 'name') || ns.I18n.t("editor.station-number", { value1: index + 1 }),
description: textOfChild(node, 'desc'),
@@ -308,7 +308,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
sequence: index,
audio: null,
images: []
})).filter(poi => poi.lat != null && poi.lon != null);
}));
}
function createElement(document, localName, text = null) {
@@ -337,7 +337,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
let track = directChildrenByLocalName(root, 'trk')[0];
if (!track) {
track = createElement(document, 'trk');
root.appendChild(track);
root.insertBefore(track, directChildrenByLocalName(root, 'extensions')[0] || null);
}
let trackName = directChildrenByLocalName(track, 'name')[0];
@@ -364,24 +364,26 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const document = parseXml(gpx || emptyGpx(routeName));
updateMetadataName(document, routeName);
// Wegwichtel verwaltet neue Stationen direkt als Punkte im GPX-Track.
// Stationen sind Wegpunkte; aufgezeichnete Trackpunkte bleiben getrennt.
// Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten.
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
Array.from(document.getElementsByTagNameNS('*', 'pendingWaypoint')).forEach(node => node.remove());
Array.from(document.getElementsByTagNameNS('*', 'trkpt'))
.filter(isManagedGpxPoint)
.forEach(node => node.remove());
const segment = ensureTrackSegment(document, routeName);
const root = document.documentElement;
const following = Array.from(root.children).find(node => ['rte', 'trk', 'extensions'].includes(node.localName));
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
orderedPois.forEach((poi, index) => {
const title = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 });
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
throw new TypeError(ns.I18n.t("local.error.station-location", { value1: title }));
const located = poi.lat != null && poi.lon != null && Number.isFinite(Number(poi.lat)) && Number.isFinite(Number(poi.lon));
const gpxPoint = located ? createElement(document, 'wpt')
: document.createElementNS(POI_NAMESPACE, 'wegwichtel:pendingWaypoint');
if (located) {
gpxPoint.setAttribute('lat', String(Number(poi.lat)));
gpxPoint.setAttribute('lon', String(Number(poi.lon)));
}
const gpxPoint = createElement(document, 'trkpt');
gpxPoint.setAttribute('lat', String(Number(poi.lat)));
gpxPoint.setAttribute('lon', String(Number(poi.lon)));
gpxPoint.appendChild(createElement(document, 'name', title));
if (poi.description) gpxPoint.appendChild(createElement(document, 'desc', poi.description));
const extensions = createElement(document, 'extensions');
@@ -389,12 +391,71 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
poiId.textContent = String(poi.id);
extensions.appendChild(poiId);
gpxPoint.appendChild(extensions);
segment.appendChild(gpxPoint);
if (located) root.insertBefore(gpxPoint, following || null);
else {
let pending = directChildrenByLocalName(root, 'extensions')[0];
if (!pending) { pending = createElement(document, 'extensions'); root.appendChild(pending); }
pending.appendChild(gpxPoint);
}
});
return new XMLSerializer().serializeToString(document);
}
function appendTrackPoint(gpx, position, routeName) {
const document = parseXml(gpx);
appendPosition(document, position, routeName);
return new XMLSerializer().serializeToString(document);
}
function appendPosition(document, position, routeName) {
const lat = position.lat == null ? NaN : Number(position.lat);
const lon = position.lon == null ? NaN : Number(position.lon);
const timestamp = new Date(position.timestamp ?? Date.now()).toISOString();
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
const root = document.documentElement;
let extensions = directChildrenByLocalName(root, 'extensions')[0];
if (!extensions) { extensions = createElement(document, 'extensions'); root.appendChild(extensions); }
const gap = document.createElementNS(POI_NAMESPACE, 'wegwichtel:gpsGap');
gap.setAttribute('time', timestamp);
extensions.appendChild(gap);
return;
}
const point = createElement(document, 'trkpt');
point.setAttribute('lat', String(lat));
point.setAttribute('lon', String(lon));
if (Number.isFinite(position.altitude)) point.appendChild(createElement(document, 'ele', position.altitude));
point.appendChild(createElement(document, 'time', timestamp));
ensureTrackSegment(document, routeName).appendChild(point);
}
function recordedPositions(gpx) {
const document = parseXml(gpx);
const routes = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
const tracks = Array.from(document.getElementsByTagNameNS('*', 'trkpt')).filter(node => !isManagedGpxPoint(node));
const nodes = routes.length ? routes : tracks;
const positions = nodes.map(node => ({
lat: numberAttribute(node, 'lat'), lon: numberAttribute(node, 'lon'),
altitude: Number.parseFloat(textOfChild(node, 'ele')),
timestamp: textOfChild(node, 'time') || null
}));
for (const node of Array.from(document.getElementsByTagNameNS('*', 'gpsGap'))) {
positions.push({ lat: null, lon: null, altitude: null, timestamp: node.getAttribute('time') });
}
return positions.sort((left, right) => left.timestamp && right.timestamp ? Date.parse(left.timestamp) - Date.parse(right.timestamp) : 0);
}
function replaceRecordedPositions(gpx, positions, routeName) {
const document = parseXml(gpx);
for (const name of ['trkpt', 'rtept', 'gpsGap']) {
Array.from(document.getElementsByTagNameNS('*', name)).forEach(node => node.remove());
}
for (const position of positions) appendPosition(document, {
...position, timestamp: position.timestamp ? Date.parse(position.timestamp) : Date.now()
}, routeName);
return new XMLSerializer().serializeToString(document);
}
function readPackage(id) {
const target = storage();
const gpx = target.getItem(routeKey(id, 'gpx'));
@@ -501,7 +562,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const document = parseXml(gpx);
const gpxPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
const selected = gpxPoints.length ? gpxPoints : routePoints;
const selected = routePoints.length ? routePoints : gpxPoints.filter(node => !isManagedGpxPoint(node));
return selected.map(node => {
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
return {
@@ -515,7 +576,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function gpxForUpload(routePackage) {
if (!routePackage?.gpx) throw new Error(ns.I18n.t("local.error.path-missing"));
if (!routePoints(routePackage.gpx).length) {
if (!routePoints(routePackage.gpx).length && !parseWaypoints(routePackage.gpx).length && !recordedPositions(routePackage.gpx).length) {
throw new Error(ns.I18n.t("local.error.path-empty"));
}
return routePackage.gpx;
@@ -579,8 +640,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
id: Number(poi.id),
title: poi.title || ns.I18n.t("editor.untitled-station"),
description: poi.description || '',
lat: Number(poi.lat),
lon: Number(poi.lon),
lat: poi.lat == null ? null : Number(poi.lat),
lon: poi.lon == null ? null : Number(poi.lon),
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
sequence: Number(poi.sequence) || 0,
audioUrl: poi.audio?.url || poi.audio?.dataUrl || null,
@@ -596,10 +657,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function toAppRoute(routePackage) {
const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
const parsedPoints = routePoints(routePackage.gpx);
const points = parsedPoints.length
? parsedPoints
: pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
const routeMetrics = metrics(points);
const points = parsedPoints;
const routeMetrics = metrics(points.length ? points : pois.filter(poi => poi.lat != null && poi.lon != null));
if (!points.length) { routeMetrics.distanceM = 0; routeMetrics.elevationGainM = 0; }
return {
id: String(routePackage.id),
source: 'local',
@@ -664,6 +724,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
ns.LocalRoutes = Object.freeze({
STORAGE_PREFIX,
appendTrackPoint,
recordedPositions,
replaceRecordedPositions,
createPoiId,
createRouteId,
emptyGpx,
+159
View File
@@ -0,0 +1,159 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
const INTERVAL_MS = 30000;
let id = null;
let status = 'idle';
let timer = null;
let generation = 0;
let pending = false;
let message = '';
let manualFields = [];
let lastPosition = null;
function render() {
const control = document.getElementById('route-recording-control');
if (!control) return;
control.hidden = status === 'idle';
document.getElementById('route-recording-status').textContent = status === 'idle' ? '' : message || ns.I18n.t(`recorder.${status}`);
document.getElementById('route-recording-pause').textContent = ns.I18n.t(status === 'paused' ? 'recorder.resume' : 'recorder.pause');
}
async function sample() {
if (pending || status !== 'recording') return;
pending = true;
const currentGeneration = generation;
try {
let position;
try { position = await ns.Geo.current({ maximumAge: 0 }); }
catch { position = { timestamp: Date.now() }; }
if (currentGeneration !== generation || status !== 'recording') return;
lastPosition = Number.isFinite(position.lat) && Number.isFinite(position.lon) ? position : null;
const route = ns.LocalRoutes.get(id);
if (!route) { stop(); return; }
const gpx = ns.LocalRoutes.appendTrackPoint(route.gpx, position, route.route.name);
ns.LocalRoutes.save({ ...route, gpx });
message = ns.I18n.t(Number.isFinite(position.lat) && Number.isFinite(position.lon) ? 'recorder.recording' : 'recorder.no-location');
} catch (error) {
pause();
ns.StorageUsage?.reportError(error);
message = ns.I18n.t('recorder.save-error');
} finally {
pending = false;
render();
}
}
function clearTimer() {
if (timer !== null) globalThis.clearInterval(timer);
timer = null;
generation += 1;
}
function resume() {
if (!id) return;
clearTimer();
status = 'recording';
message = '';
timer = globalThis.setInterval(sample, INTERVAL_MS);
sample();
render();
}
function pause() {
clearTimer();
status = id ? 'paused' : 'idle';
message = '';
render();
}
function stop() {
clearTimer();
id = null;
lastPosition = null;
status = 'idle';
message = '';
render();
}
function start(routeId) {
lastPosition = null;
id = String(routeId);
resume();
}
function addPositionField(position = {}, index = null) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
const row = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = ns.I18n.t('recorder.position-number', { number: manualFields.length + 1 });
row.appendChild(legend);
const fields = {};
for (const [key, textKey, minimum, maximum] of [['lat', 'latitude', -90, 90], ['lon', 'longitude', -180, 180]]) {
const label = document.createElement('label');
const input = document.createElement('input');
input.id = `recorded-position-${manualFields.length}-${key}`;
label.setAttribute('for', input.id);
label.textContent = ns.I18n.t(`recorder.${textKey}`);
input.type = 'number'; input.step = 'any'; input.min = minimum; input.max = maximum;
input.value = position[key] == null ? '' : String(position[key]);
row.appendChild(label); row.appendChild(input);
fields[key] = input;
}
manualFields.push({ index, original: position, ...fields });
container.appendChild(row);
}
function renderPoints(gpx) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
container.replaceChildren();
manualFields = [];
ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField);
}
function editGpx(gpx, routeName) {
let changed = false;
const positions = ns.LocalRoutes.recordedPositions(gpx);
for (const field of manualFields) {
const values = ['lat', 'lon'].map(key => field[key].value.trim() === '' ? null : Number(field[key].value));
const [lat, lon] = values;
if ((lat === null) !== (lon === null) || (lat !== null && (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180))) {
throw new Error(ns.I18n.t('editor.error.station-location'));
}
if (field.index !== null && lat === field.original.lat && lon === field.original.lon) continue;
const point = { ...field.original, lat, lon };
if (field.index === null) positions.push(point);
else positions[field.index] = point;
changed = true;
}
return changed ? ns.LocalRoutes.replaceRecordedPositions(gpx, positions, routeName) : gpx;
}
function bind() {
const activate = (elementId, action) => {
const element = document.getElementById(elementId);
element?.addEventListener('pointerup', event => {
if (event.isPrimary === false || (event.pointerType === 'mouse' && event.button !== 0)) return;
event.preventDefault();
action();
});
element?.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
action();
});
};
activate('route-recording-pause', () => status === 'recording' ? pause() : resume());
activate('route-recording-stop', stop);
activate('editor-add-position', () => addPositionField({ timestamp: new Date().toISOString() }));
document.addEventListener('wegwichtel:local-routes-changed', () => {
if (id && !ns.LocalRoutes.get(id)) stop();
});
}
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true });
else bind();
}(globalThis.Wegwichtel));
+20 -77
View File
@@ -7,6 +7,7 @@ globalThis.Wegwichtel ??= {};
static messages = Object.freeze(
{
"app": {
"no-tracking": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.",
"resume-hint": "Du kannst dort weitermachen, wo du aufgehört hast.",
"draft": "Eigener Entwurf",
"choose-route": "Strecke wählen",
@@ -367,29 +368,6 @@ globalThis.Wegwichtel ??= {};
"experiences": "Erlebnisse"
}
},
"privacy": {
"no-tracking": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.",
"incomplete-notice": "Diese Angaben sind noch nicht vollständig. Die Kontakte stammen von der Grundschule Herpf. Wer die Website betreibt, welche Verbindungsdaten gespeichert werden und wie lange sie bleiben, muss noch ergänzt werden.",
"controller-title": "1. Verantwortliche Stelle und Datenschutzkontakt",
"connection-title": "2. Wenn du Wegwichtel öffnest",
"connection-data": "Wenn du Wegwichtel öffnest, können Verbindungsdaten verarbeitet werden. Dazu gehören zum Beispiel die Internetadresse deines Geräts, Datum, Uhrzeit und Zeitzone, die aufgerufene Seite, übertragene Daten und ob der Aufruf geklappt hat. Auch die vorherige Seite und Angaben zu deinem Gerät, Internetbrowser und dessen Sprache können dazugehören. Diese Daten helfen dabei, die Website bereitzustellen, Fehler zu finden und sie zu schützen.",
"provider-label": "Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:",
"provider-placeholder": "[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]",
"location-title": "3. Standortdaten",
"location-data": "Du entscheidest, ob Wegwichtel deinen Standort nutzen darf. Damit findest du Strecken in deiner Nähe und siehst den Weg zur nächsten Station. Dein laufender Standort wird auf deinem Gerät verarbeitet. Wegwichtel speichert daraus keinen Verlauf auf der Website.",
"progress-title": "4. Deinen Fortschritt merken",
"progress-data": "Wenn du „Meinen Fortschritt merken“ einschaltest, speichert Wegwichtel deine gewählte Strecke, besuchte und angesehene Stationen sowie angehörte Tonaufnahmen. Auch ob du unterwegs bist, eine Pause machst oder fertig bist und der Zeitpunkt werden gespeichert. Diese Angaben bleiben auf deinem Gerät und werden nicht an die Website gesendet.",
"progress-deletion": "Wenn du deinen Fortschritt mindestens einen Tag lang nicht verändert hast, wird er beim nächsten Öffnen gelöscht. Beim Ausschalten wird er sofort gelöscht. Deine eigenen und für unterwegs gespeicherten Strecken bleiben erhalten.",
"drafts-title": "5. Deine eigenen Strecken",
"draft-data": "Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.",
"draft-deletion": "Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.",
"settings-title": "6. Werbung und gespeicherte Einstellungen",
"settings-data": "Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.",
"children-title": "7. Inhalte von Schülerinnen und Schülern",
"children-permission": "Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.",
"rights-title": "8. Deine Rechte",
"rights-data": "Du kannst fragen, welche Daten über dich gespeichert sind. Unter den gesetzlichen Voraussetzungen kannst du verlangen, dass sie berichtigt, gelöscht, nur noch eingeschränkt genutzt oder dir zur Weitergabe bereitgestellt werden. Du kannst der Nutzung widersprechen und eine erteilte Erlaubnis für die Zukunft zurücknehmen. Wenn du dich über den Umgang mit deinen Daten beschweren möchtest, kannst du dich an die folgende Datenschutzbehörde wenden."
},
"progress": {
"cookies-label": "Optionale Cookies",
"cookies-unused": "Diese Funktion wird nicht verwendet.",
@@ -428,60 +406,7 @@ globalThis.Wegwichtel ??= {};
"unsupported": "Dieses Gerät kann den Ton leider nicht abspielen."
},
"navigation": {
"station-direction": "Richtung zum nächsten Station"
},
"contacts": {
"school": {
"address": "Zum Eichig 3",
"city": "98617 Meiningen / OT Herpf",
"phone": "Telefon: (036943) 63722",
"email": "E-Mail: info@grundschule-herpf.de",
"name": "Staatliche Grundschule Herpf",
"privacy-label": "Datenschutzangaben der Schule:",
"fax": "Telefax: (036943) 63843",
"head": "Frau Wichert, Schulleiterin",
"content-owner": "Frau Wichert"
},
"privacy": {
"role": "Datenschutzbeauftragter",
"address": "Hölderlinstraße 1",
"city": "98527 Suhl",
"phone": "Telefon: 03681 / 7341 25",
"fax": "Fax: 03681 / 7341 09",
"email": "E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de",
"name": "Staatliches Schulamt Südthüringen",
"postal-hint": "Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden."
},
"authority": {
"label": "Zuständige Datenschutzaufsicht:",
"postal-address": "Postfach 90 04 55, 99107 Erfurt",
"address": "Häßlerstraße 8, 99096 Erfurt",
"phone": "Telefon: 0361 / 57 311 29 00",
"fax": "Telefax: 0361 / 57 311 29 04",
"email": "E-Mail: poststelle@datenschutz.thueringen.de",
"website": "Website: www.tlfdi.de",
"name": "Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)"
},
"sponsor": {
"role": "Landratsamt",
"address": "Obertshäuser Platz 1",
"city": "98617 Meiningen",
"name": "Landkreis Schmalkalden-Meiningen"
}
},
"imprint": {
"incomplete-notice": "Diese Angaben stammen von der Grundschule Herpf. Vor der Veröffentlichung muss geprüft werden, ob für Wegwichtel noch weitere oder andere Angaben nötig sind.",
"controller-title": "Wer für Wegwichtel verantwortlich ist",
"contact-title": "Kontakt",
"head-title": "Leitung der Schule",
"sponsor-title": "Schulträger",
"content-title": "Inhaltlich verantwortlich",
"required-title": "Weitere Pflichtangaben",
"required-data": "Je nachdem, wer Wegwichtel betreibt, können weitere Angaben nötig sein. Dazu gehören zum Beispiel die Verantwortlichen für den Betrieb der Website. Diese Angaben müssen noch geprüft werden.",
"liability-title": "Haftung für Inhalte und Verweise",
"liability-data": "Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Falls etwas falsch, veraltet oder nicht erlaubt ist, melde dich beim Kontakt oben. Für andere verlinkte Seiten sind deren Betreiber verantwortlich.",
"copyright-title": "Urheber- und Nutzungsrechte",
"copyright-data": "Wenn nichts anderes angegeben ist, liegen die Rechte bei der Grundschule Herpf. Du darfst Texte, Bilder, Tonaufnahmen und Wege nur so nutzen, wie es jeweils erlaubt wurde. Auch die Rechte der beteiligten Kinder, Familien, Lehrkräfte und anderen Personen bleiben bestehen."
"station-direction": "Richtung zur nächsten Station"
},
"storage-help": {
"progress-title": "Was merkt sich Wegwichtel?",
@@ -521,6 +446,24 @@ globalThis.Wegwichtel ??= {};
"read": {
"failed": "Die Datei konnte nicht geöffnet werden. Wähle sie noch einmal aus."
}
},
"recorder": {
"recording": "Dein Weg wird aufgenommen.",
"paused": "Die Aufnahme macht eine Pause.",
"pause": "Aufnahme pausieren",
"resume": "Weiter aufnehmen",
"stop": "Aufnahme beenden",
"no-location": "Dein Standort fehlt gerade. Die Aufnahme läuft weiter.",
"save-error": "Die Aufnahme wurde pausiert. Schaffe Platz auf deinem Gerät und versuche es erneut.",
"manual-location": "Ort ergänzen oder ändern",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"path-direction": "Dem Weg folgen",
"no-station": "Keine weitere Station mit bekanntem Ort",
"edit-path": "Orte auf dem Weg ergänzen",
"edit-help": "Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.",
"add-position": "Ort auf dem Weg hinzufügen",
"position-number": "Ort {number}"
}
}
);
+19 -76
View File
@@ -7,6 +7,7 @@ globalThis.Wegwichtel ??= {};
static messages = Object.freeze(
{
"app": {
"no-tracking": "There are no adverts here. We do not track which other websites you visit.",
"resume-hint": "You can carry on where you left off.",
"draft": "My draft",
"choose-route": "Choose a trail",
@@ -367,29 +368,6 @@ globalThis.Wegwichtel ??= {};
"experiences": "Adventures"
}
},
"privacy": {
"no-tracking": "There are no adverts here. We do not track which other websites you visit.",
"incomplete-notice": "This information is not complete yet. The contact details are from Grundschule Herpf. The website operator, the connection data stored and how long it is kept still need to be added.",
"controller-title": "1. Who is responsible and the privacy contact",
"connection-title": "2. When you open Wegwichtel",
"connection-data": "When you open Wegwichtel, connection data may be processed. This may include your device’s internet address, the date, time and time zone, the page you opened, data transferred and whether the request worked. It may also include the previous page and details of your device, internet app and its language. This data helps to provide the website, find problems and keep it safe.",
"provider-label": "Who provides the website, which connection data is stored and how long it is kept:",
"provider-placeholder": "[Add provider, address, stored data and retention period]",
"location-title": "3. Location data",
"location-data": "You decide whether Wegwichtel may use your location. This helps you find trails nearby and see the way to the next stop. Your current location is processed on your device. Wegwichtel does not save a history of your movements on the website.",
"progress-title": "4. Remembering your progress",
"progress-data": "When you turn on “Remember my progress”, Wegwichtel saves your selected trail, stops you have reached or viewed and recordings you have listened to. It also saves whether you are on the trail, taking a break or finished, and the time. This information stays on your device and is not sent to the website.",
"progress-deletion": "If your progress has not changed for at least one day, it is deleted the next time you open the app. Turning the switch off deletes it straight away. Your own trails and trails saved for your trip are kept.",
"drafts-title": "5. Your own trails",
"draft-data": "New trails stay on your device for now. The path, stop locations, text and selected pictures and recordings are saved.",
"draft-deletion": "Your drafts are kept when old progress is deleted or you turn off “Remember my progress”. They stay on this device until you delete them or clear Wegwichtel’s saved data in your internet app. Your trail is only sent to the website when you choose “Send for review”.",
"settings-title": "6. Adverts and saved settings",
"settings-data": "Wegwichtel does not use cookies. It does not count visits for advertising or track which other websites you visit. Your device saves whether you have turned on “Remember my progress”.",
"children-title": "7. Content from schoolchildren",
"children-permission": "Pictures, text and recordings may only be shared when permission has been given. If children can be recognised in pictures or recordings, the school’s rules must be followed. Parents or other people with parental responsibility must also give the necessary permission.",
"rights-title": "8. Your rights",
"rights-data": "You can ask what data about you is stored. Where the law allows, you can ask for it to be corrected, deleted, used only in a limited way or provided to you so you can pass it on. You can object to its use and withdraw permission for future use. If you want to complain about how your data is handled, you can contact the data protection authority below."
},
"progress": {
"cookies-label": "Optional cookies",
"cookies-unused": "This feature is not used.",
@@ -430,59 +408,6 @@ globalThis.Wegwichtel ??= {};
"navigation": {
"station-direction": "Direction to the next stop"
},
"contacts": {
"school": {
"address": "Zum Eichig 3",
"city": "98617 Meiningen / OT Herpf",
"phone": "Phone: (036943) 63722",
"email": "Email: info@grundschule-herpf.de",
"name": "Staatliche Grundschule Herpf",
"privacy-label": "The school’s privacy contact:",
"fax": "Fax: (036943) 63843",
"head": "Ms Wichert, headteacher",
"content-owner": "Ms Wichert"
},
"privacy": {
"role": "Data protection officer",
"address": "Hölderlinstraße 1",
"city": "98527 Suhl",
"phone": "Phone: 03681 / 7341 25",
"fax": "Fax: 03681 / 7341 09",
"email": "Email: datenschutz.suedthueriungen@schulamt.thueringen.de",
"name": "Staatliches Schulamt Südthüringen",
"postal-hint": "You can also contact the data protection officer by writing to the school’s postal address and adding “der Datenschutzbeauftragte”."
},
"authority": {
"label": "Responsible data protection authority:",
"postal-address": "Postfach 90 04 55, 99107 Erfurt",
"address": "Häßlerstraße 8, 99096 Erfurt",
"phone": "Phone: 0361 / 57 311 29 00",
"fax": "Fax: 0361 / 57 311 29 04",
"email": "Email: poststelle@datenschutz.thueringen.de",
"website": "Website: www.tlfdi.de",
"name": "Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)"
},
"sponsor": {
"role": "District administration",
"address": "Obertshäuser Platz 1",
"city": "98617 Meiningen",
"name": "Landkreis Schmalkalden-Meiningen"
}
},
"imprint": {
"incomplete-notice": "This information comes from Grundschule Herpf. Before publication, check whether Wegwichtel needs any additional or different information.",
"controller-title": "Who is responsible for Wegwichtel",
"contact-title": "Contact",
"head-title": "School leadership",
"sponsor-title": "School authority",
"content-title": "Responsible for content",
"required-title": "Further required information",
"required-data": "Depending on who operates Wegwichtel, further information may be required. This may include the people responsible for operating the website. These details still need to be checked.",
"liability-title": "Responsibility for content and links",
"liability-data": "The content is prepared carefully and checked regularly. If something is wrong, out of date or not allowed, please use the contact details above to let us know. Other website operators are responsible for their own linked pages.",
"copyright-title": "Copyright and rights of use",
"copyright-data": "Unless stated otherwise, the rights belong to Grundschule Herpf. You may only use text, pictures, recordings and paths in the ways permitted for each item. The rights of the children, families, teachers and other people involved remain in place."
},
"storage-help": {
"progress-title": "What does Wegwichtel remember?",
"progress-data": "You can save your progress on this device. Next time you open Wegwichtel, it will know which stops you have visited or viewed and which recordings you have listened to.",
@@ -521,6 +446,24 @@ globalThis.Wegwichtel ??= {};
"read": {
"failed": "The file could not be opened. Choose it again."
}
},
"recorder": {
"recording": "Your trail is being recorded.",
"paused": "Recording is paused.",
"pause": "Pause recording",
"resume": "Resume recording",
"stop": "Finish recording",
"no-location": "Your location is unavailable. Recording will continue.",
"save-error": "Recording was paused. Free up space on your device and try again.",
"manual-location": "Add or change the location",
"latitude": "Latitude",
"longitude": "Longitude",
"path-direction": "Follow the trail",
"no-station": "No further stop with a known location",
"edit-path": "Add locations along the trail",
"edit-help": "You can fill in missing locations later. Empty fields remain saved.",
"add-position": "Add a location along the trail",
"position-number": "Location {number}"
}
}
);
+3
View File
@@ -6,12 +6,15 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
const maxUploadBytes = Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024;
const clusterMeters = Number.parseFloat(process.env.GPX_CLUSTER_METERS ?? '20');
export const config = Object.freeze({
projectRoot,
host: process.env.HOST || '127.0.0.1',
port: Number.parseInt(process.env.PORT || '47145', 10),
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
gpxClusterMeters: Number.isFinite(clusterMeters) ? Math.max(0, clusterMeters) : 20,
maxUploadBytes,
maxJsonBodyBytes: Math.ceil(maxUploadBytes * 4 / 3) + 1024 * 1024,
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
+64 -29
View File
@@ -1,12 +1,11 @@
import { XMLParser } from 'fast-xml-parser';
import { XMLParser, XMLBuilder, XMLValidator } from 'fast-xml-parser';
import { HttpError } from '../middleware/errors.js';
import { routeMetrics } from './geo.js';
import { distanceMeters, routeMetrics } from './geo.js';
import { config } from '../config.js';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
trimValues: true
});
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
const parser = new XMLParser(xmlOptions);
const builder = new XMLBuilder({ ...xmlOptions, format: true });
function asList(value) {
if (value == null) return [];
@@ -14,11 +13,8 @@ function asList(value) {
}
function parseXml(xml) {
try {
return parser.parse(xml);
} catch (error) {
throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message);
}
if (XMLValidator.validate(xml) !== true) throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.');
return parser.parse(xml);
}
function toRoutePoint(point, sequence) {
@@ -31,27 +27,66 @@ function toRoutePoint(point, sequence) {
};
}
function trackPoints(tracks) {
return tracks
.flatMap(track => asList(track.trkseg))
.flatMap(segment => asList(segment.trkpt))
.map(toRoutePoint)
.filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
function validPoints(raw) {
return raw.map(toRoutePoint).filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon)
&& Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180).map((point, sequence) => ({ ...point, sequence }));
}
export function parseGpx(xml) {
// Each cluster stays within the radius of its first point, rather than chaining
// neighbouring points together and accidentally collapsing a walked section.
export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
const result = [];
for (const point of points) {
if (!result.length || distanceMeters(result.at(-1), point) > radiusM) result.push(point);
}
return result.map((point, sequence) => ({ ...point, sequence }));
}
export function parseGpx(xml, replacementPoints = null) {
const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
const tracks = asList(gpx.trk);
const points = trackPoints(tracks);
if (!points.length) {
throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
}
return {
name: tracks[0]?.name || gpx.metadata?.name || null,
points,
metrics: routeMetrics(points)
const routes = asList(gpx.rte);
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
const waypoints = validPoints(asList(gpx.wpt));
if (!points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
const metrics = points.length || waypoints.length ? routeMetrics(points.length ? points : waypoints) : {
startLat: null, startLon: null, centerLat: null, centerLon: null,
minLat: null, minLon: null, maxLat: null, maxLon: null,
distanceM: 0, elevationGainM: 0, pointCount: 0
};
if (!points.length) { metrics.distanceM = 0; metrics.elevationGainM = 0; metrics.pointCount = 0; }
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
delete gpx.trk;
delete gpx.rte;
// GPX 1.1 order: metadata, waypoints, route, extensions.
const { extensions, ...base } = gpx;
const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
'@_lat': point.lat, '@_lon': point.lon,
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
...(point.recordedAt ? { time: point.recordedAt } : {})
})) };
if (extensions) normalized.extensions = extensions;
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
}
export function gpxWithWaypoints(xml, pois) {
const gpx = parseXml(xml).gpx;
const { wpt: _oldWaypoints, rte, trk, extensions = {}, ...base } = gpx;
const waypoint = poi => ({ '@_lat': poi.lat, '@_lon': poi.lon, name: poi.title,
...(poi.description ? { desc: poi.description } : {}),
extensions: { 'wegwichtel:poiId': poi.id } });
const located = pois.filter(poi => Number.isFinite(poi.lat) && Number.isFinite(poi.lon));
const pending = pois.filter(poi => !Number.isFinite(poi.lat) || !Number.isFinite(poi.lon));
const { 'wegwichtel:pendingWaypoint': _oldPending, ...extra } = extensions;
if (pending.length) extra['wegwichtel:pendingWaypoint'] = pending.map(poi => ({ name: poi.title,
...(poi.description ? { desc: poi.description } : {}), extensions: { 'wegwichtel:poiId': poi.id } }));
const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi',
...(located.length ? { wpt: located.map(waypoint) } : {}),
...(rte ? { rte } : {}), ...(trk ? { trk } : {}),
...(Object.keys(extra).length ? { extensions: extra } : {}) };
return '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized });
}
+33 -10
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { transaction } from '../database.js';
import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js';
import { parseGpx } from './gpx.js';
import { parseGpx, gpxWithWaypoints } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import {
ensureRouteDirectories,
@@ -203,6 +203,7 @@ export function createRoute(db, fields, gpxFile) {
return id;
});
ensureRouteDirectories(routeId);
fs.writeFileSync(gpxFile.path, parsed.normalizedXml);
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(gpxPath, routeId);
@@ -231,7 +232,11 @@ export function updateRoute(db, id, fields, gpxFile) {
updateMetrics(db, id, parsed.metrics);
});
if (gpxFile) moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
if (gpxFile) {
fs.writeFileSync(gpxFile.path, parsed.normalizedXml);
moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
syncRouteWaypoints(db, id);
}
return getRoute(db, id);
}
@@ -250,6 +255,9 @@ export function appendRoute(db, id, gpxFile) {
});
removeUpload(gpxFile);
const target = resolveStoredFile(db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(id).gpx_path);
const original = fs.readFileSync(target, 'utf8');
fs.writeFileSync(target, parseGpx(original, allPoints).normalizedXml);
return getRoute(db, id);
}
@@ -283,15 +291,28 @@ export function getPoi(db, routeId, poiId) {
return listPois(db, routeId).find(poi => poi.id === Number(poiId));
}
function syncRouteWaypoints(db, routeId) {
const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8');
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
}
function poiCoordinates(fields, existing = {}) {
const values = ['lat', 'lon'].map(key => Object.hasOwn(fields, key) ? fields[key] : existing[key]);
const [lat, lon] = values.map(value => value == null || value === '' ? null : Number.parseFloat(value));
const absent = lat === null && lon === null;
if (!absent && (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)) {
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
}
return { lat, lon };
}
export function createPoi(db, routeId, fields) {
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
const lat = Number.parseFloat(fields.lat);
const lon = Number.parseFloat(fields.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
}
const { lat, lon } = poiCoordinates(fields);
const result = db.prepare(`INSERT INTO pois
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
routeId,
@@ -302,6 +323,7 @@ export function createPoi(db, routeId, fields) {
Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters),
Number.parseInt(fields.sequence || '0', 10)
);
syncRouteWaypoints(db, routeId);
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
}
@@ -309,13 +331,12 @@ export function updatePoi(db, routeId, poiId, fields) {
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
if (existing?.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
const { lat, lon } = poiCoordinates(fields, existing);
const radius = fields.triggerRadiusM == null
? existing.trigger_radius_m
: Number.parseFloat(fields.triggerRadiusM);
const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10);
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
if (!Number.isFinite(radius) || !Number.isInteger(sequence) || sequence < 0) {
throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.');
}
db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?,
@@ -328,6 +349,7 @@ export function updatePoi(db, routeId, poiId, fields) {
sequence,
poiId
);
syncRouteWaypoints(db, existing.route_id);
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
}
@@ -341,6 +363,7 @@ export function deletePoi(db, routeId, poiId) {
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
});
syncRouteWaypoints(db, routeId);
images.forEach(image => removeStoredFile(image.path));
removeStoredFile(existing.audio_path);
return { id: poiId, routeId, deleted: true };
+71 -4
View File
@@ -234,6 +234,16 @@ async function loadApp({ language = 'de-DE' } = {}) {
const savedStates = [];
const editor = { opened: 0, refreshed: 0 };
const apiCalls = [];
const frames = [];
const animationTimers = new Map();
let animationTimerId = 0;
function finishAnimations() {
while (frames.length) frames.shift()();
for (const [id, callback] of animationTimers) {
animationTimers.delete(id);
callback();
}
}
harness.node('#route-control-dropdown').properties.set('hidden', true);
harness.node('#route-search').value = '';
@@ -284,7 +294,9 @@ async function loadApp({ language = 'de-DE' } = {}) {
Vibration: { Patterns: { ACTIVE_POI: [100] }, start() {}, stop() {} }
},
alert() {},
clearTimeout,
clearTimeout(id) {
if (!animationTimers.delete(id)) clearTimeout(id);
},
history: {
state: null,
back() {},
@@ -292,7 +304,13 @@ async function loadApp({ language = 'de-DE' } = {}) {
replaceState(state) { this.state = state; }
},
scrollTo() {},
setTimeout,
requestAnimationFrame: callback => frames.push(callback),
setTimeout(callback, delay) {
if (delay !== 180) return setTimeout(callback, delay);
const id = --animationTimerId;
animationTimers.set(id, callback);
return id;
},
structuredClone,
globalThis: null
};
@@ -309,6 +327,7 @@ async function loadApp({ language = 'de-DE' } = {}) {
return {
apiCalls,
finishAnimations,
context,
editor,
harness,
@@ -329,6 +348,39 @@ test('English browser settings localize app controls and retain user-authored tr
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
});
test('route menu animates opening and closing and can reopen during closing', async () => {
const setup = await loadApp();
const { context, harness } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
const button = new FakeNode('toggle');
const dropdown = harness.node('#route-control-dropdown');
await harness.activate('#route-control-button', button);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(dropdown.classes.has('is-open'), false);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
await harness.activate('#route-control-button', button);
assert.equal(dropdown.classes.has('is-open'), false);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'false');
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
assert.equal(dropdown.properties.get('hidden'), false);
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
await harness.activate('#route-control-button', button);
harness.fireDocumentKey('Escape');
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
assert.equal(dropdown.classes.has('is-open'), false);
});
test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
const setup = await loadApp();
const { context, harness, apiCalls, editor } = setup;
@@ -352,6 +404,7 @@ test('route control runtime loads server/local routes and supports switching wit
const newButton = new FakeNode('#route-control-new');
await harness.activate('.route-control-actions #route-control-new', newButton);
assert.equal(editor.opened, 1);
setup.finishAnimations();
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
const localButton = new FakeNode('local-select');
@@ -409,6 +462,7 @@ test('route control closes on escape and reports local storage failures', async
await harness.activate('#route-control-button', new FakeNode('toggle'));
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
harness.fireDocumentKey('Escape');
setup.finishAnimations();
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
@@ -443,7 +497,17 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
await selectServer(setup);
await harness.activate('#start-route', new FakeNode('start'));
assert.equal(harness.node('#pause-route').properties.get('disabled'), false);
const bearingTargets = [];
context.Wegwichtel.Distance.bearing = (position, target) => {
bearingTargets.push(target);
return target.title ? 45 : 120;
};
calls.position({ lat: 50, lon: 10, accuracy: 5, heading: 90, speed: 1 });
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), false);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), false);
assert.notEqual(harness.node('#route-navigation-arrow').styles.get('transform'), harness.node('#navigation-arrow').styles.get('transform'));
assert.ok(bearingTargets.some(target => target.title));
assert.ok(bearingTargets.some(target => !target.title));
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Start');
assert.equal(calls.vibrations, 1);
assert.match(harness.node('#navigation-mode').textValue, /Serverweg Ziel/);
@@ -459,10 +523,13 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
await harness.activate('#pause-route', new FakeNode('pause'));
assert.match(harness.node('#tracking-status').textValue, /Du machst eine Pause/);
assert.equal(savedStates.at(-1)[1], 'paused');
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), true);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), true);
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 });
assert.equal(calls.vibrations, 2);
assert.equal(harness.node('#navigation-distance').textValue, '0 m');
assert.equal(harness.node('#navigation-distance').textValue, '–');
assert.equal(harness.node('#route-navigation-mode').textValue, 'Alle Stationen und das Ziel erreicht');
await harness.activate('#stop-route', new FakeNode('stop'));
assert.equal(savedStates.at(-1)[1], 'idle');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
@@ -515,7 +582,7 @@ test('tracking handles an empty route and orientation permission resolving after
assert.match(harness.node('#poi-list').appended.join(''), /keine Stationen/);
await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 });
assert.equal(harness.node('#navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
assert.equal(harness.node('#route-navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
await harness.activate('#pause-route', new FakeNode('pause'));
const stopped = calls.stopped;
grant(true);
+7 -7
View File
@@ -32,10 +32,10 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple[^>]+hidden/);
assert.match(html, /id="editor-poi-lat"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-lat"[^>]+type="number"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="number"/);
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Stationen<\/h3>/);
@@ -327,11 +327,11 @@ function buildEditorContext() {
const now = '2026-09-19T14:00:00.000Z';
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
const gpxPoints = pois
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
.map(poi => `<wpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></wpt>`)
.join('');
const saved = {
id,
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`,
gpx: `<gpx>${gpxPoints}</gpx>`,
route: { ...input.route, id, updatedAt: now },
pois
};
@@ -426,7 +426,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
assert.equal(local.pois[0].images.length, 1);
assert.equal(local.pois[0].images[0].storage, 'indexeddb');
assert.ok(local.pois[0].images[0].mediaKey);
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(local.gpx, /<wpt lat="50\.123456789" lon="10\.987654321">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
@@ -445,7 +445,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
assert.ok(gpxUpload);
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
assert.match(await gpxUpload[1].text(), /<trkpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.match(await gpxUpload[1].text(), /<wpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
+24
View File
@@ -7,3 +7,27 @@ test('parseGpx liest Beispielstrecke', () => {
const parsed = parseGpx(fs.readFileSync(new URL('../examples/sample-route.gpx', import.meta.url), 'utf8'));
assert.equal(parsed.name, 'Beispielweg'); assert.equal(parsed.points.length, 3); assert.ok(parsed.metrics.distanceM > 0);
});
test('normalization separates waypoints and collapses stationary GPS drift', () => {
const xml = '<gpx><metadata><name>Pause</name></metadata><wpt lat="51" lon="11"><name>Station</name></wpt><trk><trkseg>'
+ '<trkpt lat="50" lon="10"><time>2026-10-03T10:00:00Z</time></trkpt>'
+ '<trkpt lat="50.00001" lon="10.00001"/><trkpt lat="50.00002" lon="10.00002"/>'
+ '<trkpt lat="50.001" lon="10.001"/></trkseg></trk></gpx>';
const result = parseGpx(xml);
assert.equal(result.points.length, 2);
assert.equal(result.points[0].recordedAt, '2026-10-03T10:00:00Z');
assert.match(result.normalizedXml, /<wpt /);
assert.match(result.normalizedXml, /<rtept /);
assert.doesNotMatch(result.normalizedXml, /<trkpt /);
assert.deepEqual(parseGpx(result.normalizedXml).points, result.points);
});
test('waypoint-only routes and missing-position recordings have no invented route coordinates', () => {
const waypoint = parseGpx('<gpx><wpt lat="50" lon="10"/></gpx>');
assert.equal(waypoint.points.length, 0); assert.equal(waypoint.metrics.distanceM, 0);
const empty = parseGpx('<gpx xmlns:wegwichtel="urn:wegwichtel:poi"><extensions><wegwichtel:gpsGap time="2026-10-03T10:00:00Z"/></extensions></gpx>');
assert.equal(empty.points.length, 0); assert.equal(empty.metrics.startLat, null);
assert.match(empty.normalizedXml, /gpsGap/);
assert.throws(() => parseGpx('<gpx><trk>'));
assert.throws(() => parseGpx('<gpx><trk><trkseg><trkpt lat="100" lon="10"/></trkseg></trk></gpx>'));
});
+20
View File
@@ -8,6 +8,26 @@ import { localizeHtml } from '../scripts/localize-html.js';
const root = path.resolve(import.meta.dirname, '..');
test('German legal pages and contacts remain static when the app uses English', async () => {
const context = { navigator: { language: 'en-GB' } };
const i18n = await loadI18n(context);
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
const englishHtml = localizeHtml(html, key => i18n.t(key));
for (const name of ['privacy', 'imprint']) {
const pattern = new RegExp(`<article[^>]+data-info-page="${name}"[^>]*>[\\s\\S]*?</article>`);
const page = pattern.exec(html)?.[0];
assert.ok(page, name);
assert.match(page, /lang="de"/);
assert.doesNotMatch(page, /data-i18n/);
assert.equal(pattern.exec(englishHtml)?.[0], page);
}
for (const catalog of context.Wegwichtel.TextCatalogs.values()) {
for (const key of ['privacy', 'imprint', 'contacts']) {
assert.equal(Object.hasOwn(catalog, key), false, key);
}
}
});
test('checked-in HTML has synchronized German defaults and accessible headings, labels and links before scripts load', async () => {
const i18n = await loadI18n({});
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
+12
View File
@@ -42,6 +42,9 @@ test('information button opens scrollable overlay pages for privacy, imprint and
assert.match(html, /data-info-target="privacy"/);
assert.match(html, /data-info-target="imprint"/);
assert.match(html, /data-info-target="technology"/);
assert.match(html, /<h3>Technisch verantwortlich<\/h3>\s*<p>Florian Zumpe<br>/);
assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/);
assert.match(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
@@ -193,6 +196,9 @@ test('legal overlay handlers synchronize switches without advancing the start sc
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('info-dialog-title').dataset.i18n = 'info.title';
context.Wegwichtel.I18n.setLanguage('en');
elements.get('info-button').dispatchEvent(pointerEvent());
assert.equal(elements.get('info-dropdown').hidden, false);
assert.equal(elements.get('info-button').attributes.get('aria-expanded'), 'true');
@@ -221,6 +227,12 @@ test('legal overlay handlers synchronize switches without advancing the start sc
assert.equal(elements.get('info-back').hidden, true);
assert.equal(privacyPage.hidden, false);
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
assert.equal(elements.get('info-dialog-title').attributes.get('lang'), 'de');
assert.equal(Object.hasOwn(elements.get('info-dialog-title').dataset, 'i18n'), false);
targetButtons[1].dispatchEvent(pointerEvent());
assert.equal(elements.get('info-dialog-title').textContent, 'Impressum');
assert.equal(elements.get('info-dialog-title').attributes.get('lang'), 'de');
progressToggles[0].checked = true;
progressToggles[0].dispatchEvent(new Event('change'));
+34 -13
View File
@@ -361,6 +361,27 @@ const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
</trkseg></trk>
</gpx>`;
test('missing GPS samples and stations survive storage and later manual coordinates', async () => {
const { api } = await loadLocalRoutes();
let gpx = api.appendTrackPoint(api.emptyGpx('Ohne Empfang'), { timestamp: 1000 }, 'Ohne Empfang');
gpx = api.appendTrackPoint(gpx, { lat: 50, lon: 10, timestamp: 2000 }, 'Ohne Empfang');
const saved = api.save({ id: 'missing-gps', gpx, route: { name: 'Ohne Empfang' }, pois: [
{ id: 42, title: 'Drinnen', lat: null, lon: null, sequence: 0 }
] });
const positions = api.recordedPositions(saved.gpx);
assert.equal(positions.length, 2);
assert.equal(positions[0].lat, null);
assert.equal(api.parseWaypoints(saved.gpx)[0].lat, null);
assert.match(api.gpxForUpload(saved), /gpsGap/);
positions[0].lat = 49.999;
positions[0].lon = 9.999;
const edited = api.replaceRecordedPositions(saved.gpx, positions, 'Ohne Empfang');
assert.doesNotMatch(edited, /gpsGap/);
assert.equal(api.recordedPositions(edited)[0].lat, 49.999);
assert.equal(api.parseWaypoints(edited)[0].title, 'Drinnen');
assert.equal(api.parseWaypoints(edited)[0].lat, null);
});
test('route id generation uses randomUUID when available', async () => {
const { api } = await loadLocalRoutes();
@@ -448,10 +469,10 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
assert.match(gpx, /<trkpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.doesNotMatch(gpx, /<wpt/);
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(gpx, /<trkpt/);
assert.equal((gpx.match(/<wpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Strecke');
assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
@@ -465,8 +486,8 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.equal(appRoute.pois.length, 2);
assert.equal(appRoute.pois[0].audioUrl, 'data:audio/mpeg;base64,QQ==');
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
assert.equal(appRoute.points.length, 2);
assert.ok(appRoute.distanceM > 0);
assert.equal(appRoute.points.length, 0);
assert.equal(appRoute.distanceM, 0);
assert.equal(Math.round(appRoute.elevationGainM), 0);
const listed = api.listAppRoutes();
assert.equal(listed.length, 1);
@@ -485,15 +506,15 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
assert.match(api.emptyGpx('A & B'), /<name>A &amp; B<\/name>/);
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /keinen lesbaren Weg/);
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /Ort von Ohne Position fehlt/);
assert.match(api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /pendingWaypoint/);
const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Station' },
[{ id: 2, title: 'Einzelner Station', lat: 50.2, lon: 10.2, sequence: 0 }]
);
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<trkpt\b/);
});
test('local points are stored directly as managed GPX Stationen and uploaded unchanged', async () => {
@@ -508,11 +529,11 @@ test('local points are stored directly as managed GPX Stationen and uploaded unc
]
});
assert.match(saved.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<trkpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<wpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wegwichtel:poiId>1<\/wegwichtel:poiId>/);
assert.equal((saved.gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<wpt\b/);
assert.equal((saved.gpx.match(/<wpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<trkpt\b/);
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
@@ -525,7 +546,7 @@ test('sync keeps imported foreign Stationen and upload rejects a route without S
assert.match(synced, /<ele>100<\/ele>/);
assert.match(synced, /<ele>112<\/ele>/);
assert.match(synced, /<wegwichtel:poiId>200<\/wegwichtel:poiId>/);
assert.equal((synced.match(/<trkpt /g) || []).length, 3);
assert.equal((synced.match(/<trkpt /g) || []).length, 2);
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
assert.throws(() => api.gpxForUpload({
gpx: api.emptyGpx('Leer'),
+22 -1
View File
@@ -1,3 +1,4 @@
import { parseGpx } from '../src/services/gpx.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
@@ -209,7 +210,27 @@ assert result['ok'] is True
assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`);
const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/);
assert.deepEqual(downloadedGpx, gpx);
assert.match(downloadedGpx.toString(), /<rtept /);
assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /);
assert.deepEqual(parseGpx(downloadedGpx.toString()).points, parseGpx(gpx.toString()).points);
const { body: pendingPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null })
}, 201);
assert.equal(pendingPoi.lat, null);
assert.equal(pendingPoi.lon, null);
const { body: pendingGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.match(pendingGpx.toString(), /wegwichtel:pendingWaypoint/);
const { body: locatedPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${pendingPoi.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: 50, lon: 10 })
});
assert.equal(locatedPoi.lat, 50);
const { body: locatedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.doesNotMatch(locatedGpx.toString(), /wegwichtel:pendingWaypoint/);
assert.match(locatedGpx.toString(), /<wpt lat="50" lon="10">/);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${pendingPoi.id}`, { method: 'DELETE' });
const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST',
+7 -3
View File
@@ -57,13 +57,17 @@ test('application actions use pointer events instead of click handlers', async (
assert.doesNotMatch(scripts, /(?:\.on|addEventListener)\(\s*['"]click['"]/);
});
test('mobile navigation footer contains a rotating SVG arrow and exact metre output', async () => {
test('mobile navigation shows a path arrow without distance and a station arrow with metres', 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(html, /<svg id="route-navigation-arrow"/);
const pathCard = html.slice(html.indexOf('<svg id="route-navigation-arrow"'), html.indexOf('id="station-navigation"'));
assert.match(pathCard, /route-navigation-mode/);
assert.doesNotMatch(pathCard, /navigation-distance/);
assert.match(app, /Distance\.bearing/);
assert.match(app, /Math\.round\((?:metric\.distance|progress\.distance|distance)\)/);
assert.match(app, /`\$\{distance\} m`/);
@@ -144,7 +148,7 @@ test('the selected or triggered Station is highlighted semantically', async () =
assert.match(css, /aktuelle Station/);
});
test('Stationen have live direction and metre indicators and take navigation priority', async () => {
test('stations retain live direction and metre indicators beside path navigation', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
const html = await read('public/index.html');
@@ -158,7 +162,7 @@ test('Stationen have live direction and metre indicators and take navigation pri
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 Station/);
assert.match(html, /Richtung zur nächsten Station/);
});
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import { loadI18n } from '../test-support/i18n.js';
const sourcePath = new URL('../public/js/route-recorder.js', import.meta.url);
const tick = () => new Promise(resolve => setImmediate(resolve));
async function setup() {
class Element extends EventTarget {
constructor() { super(); this.children = []; this.value = ''; this.attributes = {}; }
setAttribute(key, value) { this.attributes[key] = value; }
appendChild(child) { this.children.push(child); }
replaceChildren() { this.children = []; }
}
const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = 'complete';
document.getElementById = id => nodes.get(id);
document.createElement = () => new Element();
const intervals = new Map();
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
const context = { document, Date, console,
setInterval(callback, delay) { assert.equal(delay, 30000); intervals.set(1, callback); return 1; },
clearInterval(id) { intervals.delete(id); },
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
LocalRoutes: {
get: id => packages.get(id),
save: value => packages.set(value.id, value),
appendTrackPoint: (gpx, position) => JSON.stringify([...JSON.parse(gpx), position]),
recordedPositions: JSON.parse,
replaceRecordedPositions: (_gpx, positions) => JSON.stringify(positions)
}
}
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath.pathname });
return { context, nodes, intervals, packages, recorder: context.Wegwichtel.RouteRecorder };
}
function activate(node, type = 'pointerup', key) {
const event = new Event(type, { cancelable: true });
if (key) Object.defineProperty(event, 'key', { value: key });
node.dispatchEvent(event);
}
test('recording persists every 30 seconds independently of the editor, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
await intervals.get(1)();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 2);
activate(nodes.get('route-recording-pause'));
assert.equal(recorder.status, 'paused'); assert.equal(intervals.size, 0);
activate(nodes.get('route-recording-pause'), 'keydown', 'Enter'); await tick();
assert.equal(recorder.status, 'recording');
activate(nodes.get('route-recording-stop'));
assert.equal(recorder.status, 'idle'); assert.equal(intervals.size, 0);
assert.equal(nodes.get('route-recording-control').hidden, true);
});
test('GPS failures remain as empty samples and late results after stopping are discarded', async () => {
const { context, recorder, packages, intervals } = await setup();
context.Wegwichtel.Geo.current = async () => { throw new Error('GPS fehlt'); };
recorder.start('draft'); await tick();
const samples = JSON.parse(packages.get('draft').gpx);
assert.equal(samples.length, 1); assert.equal(samples[0].lat, undefined);
let finish;
context.Wegwichtel.Geo.current = () => new Promise(resolve => { finish = resolve; });
const pending = intervals.get(1)();
recorder.stop(); finish({ lat: 51, lon: 11 }); await pending;
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
});
test('storage errors pause recording and deleted drafts cannot be recreated by the recorder', async () => {
const { context, recorder, packages, nodes } = await setup();
context.Wegwichtel.LocalRoutes.save = () => { throw new Error('voll'); };
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'paused');
assert.match(nodes.get('route-recording-status').textContent, /Schaffe Platz/);
packages.delete('draft');
context.document.dispatchEvent(new Event('wegwichtel:local-routes-changed'));
assert.equal(recorder.status, 'idle');
recorder.resume(); assert.equal(recorder.status, 'idle');
});
test('missing recorded locations can be filled manually while newer samples remain intact', async () => {
const { recorder, nodes } = await setup();
const initial = JSON.stringify([{ lat: null, lon: null, timestamp: '2026-10-03T10:00:00Z' }]);
recorder.renderPoints(initial);
const row = nodes.get('editor-recorded-positions').children[0];
row.children[2].value = '50'; row.children[4].value = '10';
const latest = JSON.stringify([...JSON.parse(initial), { lat: 51, lon: 11 }]);
const result = JSON.parse(recorder.editGpx(latest, 'Entwurf'));
assert.equal(result[0].lat, 50); assert.equal(result[1].lat, 51);
row.children[4].value = '';
assert.throws(() => recorder.editGpx(latest, 'Entwurf'));
recorder.renderPoints('[]');
activate(nodes.get('editor-add-position'));
assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1);
});