From 49a9de0355c81b4f109c165762068a66046cf003 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Mon, 5 Oct 2026 01:18:14 +0200 Subject: [PATCH] Multiple improvements --- docs/Bilder-und-Ton-Nutzungsrechte.md | 9 ++- docs/CREATOR-ID.md | 3 +- docs/Offline-und-Wegaufzeichnung.md | 37 +++++++++ docs/PUBLICATION-CHECKLIST.md | 3 +- docs/README.md | 2 +- docs/agents/AGENTS.md | 32 ++++++++ docs/agents/GPX-Aufzeichnung.md | 36 +++++++-- docs/agents/TODO-MEDIENRECHTE.md | 6 +- docs/agents/TODO-RAETSEL-STATIONEN.md | 36 +++++++++ public/css/editor.css | 26 +++++-- public/images/icons/current-location.svg | 1 + public/index.html | 18 ++++- public/js/app.js | 40 +++++++++- public/js/editor-ui.js | 97 +++++++++++++++++------ public/js/local-routes.js | 43 ++++++++-- public/js/route-recorder.js | 75 +++++++++++++++--- public/js/texts-de.js | 8 ++ public/js/texts-en.js | 8 ++ src/services/gpx.js | 59 ++++++++++++-- src/services/routes-service.js | 10 ++- test/app-route-control-runtime.test.js | 42 ++++++++++ test/backend-services.test.js | 6 ++ test/editor-ui.test.js | 99 ++++++++++++++++++++++++ test/gpx.test.js | 72 ++++++++++++++++- test/legal-ui.test.js | 3 +- test/local-routes.test.js | 32 ++++++++ test/route-recorder.test.js | 74 ++++++++++++++++-- 27 files changed, 797 insertions(+), 80 deletions(-) create mode 100644 docs/Offline-und-Wegaufzeichnung.md create mode 100644 docs/agents/TODO-RAETSEL-STATIONEN.md create mode 100644 public/images/icons/current-location.svg diff --git a/docs/Bilder-und-Ton-Nutzungsrechte.md b/docs/Bilder-und-Ton-Nutzungsrechte.md index 2a92b16..eb49e99 100644 --- a/docs/Bilder-und-Ton-Nutzungsrechte.md +++ b/docs/Bilder-und-Ton-Nutzungsrechte.md @@ -46,12 +46,17 @@ Die notwendigen Arbeiten stehen in Vor verbindlicher Verwendung sind insbesondere diese Platzhalter zu ersetzen: - vollständiger Name und Anschrift des tatsächlichen Betreibers als Lizenznehmer; -- Kontakt für Rechtefragen, Widerruf und Entfernung; - Rechtsgrundlagen, Empfänger, Hostinganbieter sowie Lösch- und Sicherungsfristen. +Kontakt für Wegwichtel, Rechtefragen, Datenschutzanfragen, Widerruf und Entfernung: +[info@wegwichtel.de](mailto:info@wegwichtel.de). Die Adresse ist eingerichtet +und ersetzt die bisherigen Kontaktplatzhalter. Die Kontaktdaten der Schule +und der Datenschutzstellen bleiben als Kontakte dieser Stellen erhalten. + Außerdem müssen die Texte und das Zustimmungsverfahren zum tatsächlichen Betriebsmodell der Schule passen und rechtlich geprüft werden. Die bereits -vorhandenen Kontaktangaben im Impressum wurden nicht verändert. Die frühere +vorhandenen Schul- und Behördenkontakte im Impressum wurden nicht verändert; +die Kontaktadresse für Wegwichtel und die technische Verantwortung wurde ergänzt. Die frühere pauschale Rechtezuordnung zur Schule wurde durch die Trennung von Urheberschaft und Nutzungsrechten ersetzt. diff --git a/docs/CREATOR-ID.md b/docs/CREATOR-ID.md index 7bf0d76..3a1452d 100644 --- a/docs/CREATOR-ID.md +++ b/docs/CREATOR-ID.md @@ -76,5 +76,6 @@ stehen in [TODO-MODERATION.md](agents/TODO-MODERATION.md) weiterhin aus. Die GPS-Aufzeichnung läuft nur innerhalb einer Bearbeitungssitzung. Schließen, Abbrechen, Rückkehr zur Liste, Streckenwechsel und Verlassen der Seite beenden sie; ein Neustart übernimmt keinen aktiven Aufzeichnungszustand. Beim erneuten -Öffnen eines lokalen Entwurfs beginnt eine neue Sitzung. Einzelheiten stehen in +Öffnen eines lokalen Entwurfs beginnt eine neue Sitzung mit ausgeschalteter +Aufnahme. Der Schalter „Meinen Weg aufnehmen“ aktiviert sie. Einzelheiten stehen in [GPX-Aufzeichnung.md](agents/GPX-Aufzeichnung.md). diff --git a/docs/Offline-und-Wegaufzeichnung.md b/docs/Offline-und-Wegaufzeichnung.md new file mode 100644 index 0000000..8ba00cc --- /dev/null +++ b/docs/Offline-und-Wegaufzeichnung.md @@ -0,0 +1,37 @@ +# Entwürfe ohne Internet bearbeiten + +„Meine Entwürfe“ erscheint sofort beim Öffnen der Streckenbearbeitung. Erst danach +werden veröffentlichte Strecken geladen. Du kannst deine Entwürfe auch bearbeiten, +wenn der Server nicht erreichbar ist. Lade die App zuvor auf dein Gerät. + +## Den Weg aufnehmen + +Schalte „Meinen Weg aufnehmen“ beim Bearbeiten eines Entwurfs ein. Dein Standort +wird sofort und danach alle zwei Minuten auf diesem Gerät gespeichert. Speichere +einen neuen Entwurf zuerst. Beim Verlassen der Bearbeitung endet die Aufnahme. +Beim nächsten Öffnen ist sie wieder aus. Du kannst die Aufnahme auch pausieren. + +Erlaube der App den Zugriff auf deinen Standort. Das funktioniert auch ohne Internet, +wenn dein Gerät seinen Standort bestimmen kann. Ohne Standort bleibt ein leerer +Eintrag, den du später ergänzen kannst. Bei gesperrtem Bildschirm oder im Hintergrund +kann der Internetbrowser die Aufnahme verzögern. + +## Einen Ort ergänzen + +Unter „Orte auf dem Weg ergänzen“ setzt das Zielkreuz deinen aktuellen Standort +in die beiden Felder des gewählten Punktes ein. Speichere danach deine Änderungen. +Die Aufnahme wird dadurch nicht eingeschaltet. + +## Bilder und Tonaufnahmen + +Beim Öffnen oder Speichern einer Strecke werden die Bilder und Tonaufnahmen +nicht alle geladen. Erst wenn du eine Station ansiehst oder bearbeitest, +werden deren Dateien geöffnet. Auch beim Senden zur Prüfung werden die +benötigten Dateien gelesen. + +## Den Weg zur Prüfung senden + +Beim Senden werden die aufgenommenen Orte und die Stationen mit bekanntem +Standort zu einem gemeinsamen Weg verbunden. Auch ohne Aufnahme können die +Stationen einen Weg bilden. Fehlende Orte kannst du später ergänzen. +Die Verbindung zeigt nicht automatisch den Verlauf von Straßen oder Wanderwegen. diff --git a/docs/PUBLICATION-CHECKLIST.md b/docs/PUBLICATION-CHECKLIST.md index 37110cd..caef136 100644 --- a/docs/PUBLICATION-CHECKLIST.md +++ b/docs/PUBLICATION-CHECKLIST.md @@ -7,7 +7,8 @@ Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung - vollständiger Name und Rechtsform der Betreiberin - vertretungsberechtigte Person - ladungsfähige Anschrift -- E-Mail-Adresse und weiterer unmittelbarer Kontaktweg +- eingerichtete Kontaktadresse für Wegwichtel: info@wegwichtel.de +- weiterer unmittelbarer Kontaktweg - inhaltlich verantwortliche Person - gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben diff --git a/docs/README.md b/docs/README.md index 37f8c1d..37d9150 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Alle folgenden Befehle werden im Projektverzeichnis ausgeführt. ## Architektur -Die Trennung von Stationen und Weg, die Aufzeichnung alle 30 Sekunden und das +Die Trennung von Stationen und Weg, die zuschaltbare Aufzeichnung alle zwei Minuten und das nachträgliche Ergänzen fehlender Koordinaten sind in [`GPX-Aufzeichnung.md`](agents/GPX-Aufzeichnung.md) beschrieben. diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md index aa20a75..75fe6c4 100644 --- a/docs/agents/AGENTS.md +++ b/docs/agents/AGENTS.md @@ -51,6 +51,13 @@ Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntni und Code-Kommentare. Code-Bezeichner, Dateiformate und Schnittstellen bleiben fachlich präzise. - Prüfe neue Texte und angepasste Text-Erwartungen in den vorhandenen Tests. +## Kontaktadresse + +Die eingerichtete Kontaktadresse für Wegwichtel ist `info@wegwichtel.de`. +Sie gilt für App-Kontakt, technische Fragen, Rechtefragen, Datenschutzanfragen, +Widerruf und Entfernung von Aufnahmen. In Rechtstexten statisch einsetzen. +Schul- und Behördenadressen bleiben die Kontakte der jeweiligen Stellen. + ## Texte und Übersetzungen - Alle eigenen Bedienungstexte liegen zentral in der Klasse `WegwichtelTexts` @@ -83,3 +90,28 @@ Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntni - Deutsch und Englisch enthalten dieselben Schlüssel, Platzhalter und Pluralformen. Texte in beiden Sprachen einfach und sinngemäß schreiben. Sprachwechsel aktualisieren feste Beschriftungen; bereits erzeugte dynamische Ansichten bei Bedarf neu rendern. + +## Ausrichtung von Bedienelementen + +Button-Gruppen in Strecken- und Stationslisten am rechten Kartenrand ausrichten. +Icon-Buttons bekommen einheitliche Breiten, Höhen und Abstände, auch wenn sie +inaktiv sind. Titel dürfen umbrechen und verändern die Button-Spalten nicht. +Die Bedienflächen bleiben mindestens 44 × 44 Pixel groß. + +## Lokale Bilder und Tonaufnahmen erst bei Bedarf laden + +Beim Öffnen oder Speichern einer Strecke und beim Umordnen von Stationen +nur Metadaten lesen. Keine Dateien aller Stationen hydratisieren. +Dateireferenzen bzw. IndexedDB-Dateien erst beim Bearbeiten oder Aufrufen +der jeweiligen Station lesen. Der Upload liest die benötigten Dateien direkt. +Temporäre Blob-URLs beim Verlassen der Ansicht freigeben; verspätete +Ladeergebnisse dürfen eine andere Station oder geschlossene Ansicht nicht ändern. + +## Stationsorte in der Routenberechnung + +Beim Hochladen und erneuten Berechnen sowohl aufgezeichnete Wegpositionen als +auch `wpt` mit gültigen Koordinaten berücksichtigen. Stationsanker dürfen nicht +durch die GPS-Clusterbildung wegfallen. Nur gültige Koordinaten einbeziehen; +Stationen ohne Standort bleiben bearbeitbar. Berechnete Stationsanker im GPX +kennzeichnen und bei Verschieben/Löschen ersetzen, statt veraltete Orte zu behalten. +Rätsel-Stationen sind für später in `docs/agents/TODO-RAETSEL-STATIONEN.md` vorgemerkt. diff --git a/docs/agents/GPX-Aufzeichnung.md b/docs/agents/GPX-Aufzeichnung.md index b1cce4e..dd6dcd0 100644 --- a/docs/agents/GPX-Aufzeichnung.md +++ b/docs/agents/GPX-Aufzeichnung.md @@ -3,12 +3,12 @@ ## Stationen und Weg getrennt speichern - Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`. -- Beim ersten Speichern eines neuen Entwurfs startet während der Bearbeitung eine Aufzeichnung: - sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage. +- Der Schalter „Meinen Weg aufnehmen“ ist beim Start und beim Öffnen eines + Entwurfs aus. Einschalten fragt sofort den Standort ab, danach alle zwei Minuten. + Bei noch nicht gespeicherten Entwürfen beginnt die Aufnahme nach dem ersten Speichern. Schließen, Abbrechen, Rückkehr zur Streckenliste oder Wechsel der Strecke - beendet die Aufzeichnung. Nach einem Neustart ist sie inaktiv. Beim erneuten - Öffnen eines lokalen Entwurfs zur Bearbeitung beginnt eine neue Sitzung. - Speichern innerhalb derselben Sitzung hebt Pause oder bewusstes Stoppen nicht auf. + beendet die Aufzeichnung und schaltet sie aus. Speichern innerhalb derselben + Sitzung hebt Pause oder bewusstes Stoppen nicht auf. - 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, @@ -28,6 +28,9 @@ Positionen. Speicherfehler pausieren die Aufzeichnung und zeigen einen Hinweis. 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. +Das Zielkreuz an jedem Wegpunkt fragt frische GPS-Koordinaten ab und füllt nur +dessen Felder. Es startet keine Aufnahme; die Änderung muss gespeichert werden. +Verspätete Antworten nach dem Wechseln oder Schließen werden verworfen. Breiten- und Längengrad müssen gemeinsam angegeben werden oder beide leer bleiben. Stationen haben eigene Felder unter „Ort ergänzen“. @@ -56,18 +59,34 @@ Coverage-Lauf geprüft. ## 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. +aufgezeichneten Weg und gültige Stations-Wegpunkte zu `rte/rtept`. Bereits +vorhandene Routenpunkte bilden anstelle von Trackpunkten den Grundverlauf. 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. +Nach dem Vereinfachen des Tracks werden alle gültigen `wpt` ergänzt. Sie werden +nicht durch den Clusterabstand entfernt. Ihre Reihenfolge in der Datei bleibt +erhalten; der Einfügeort im verbleibenden Weg wird anhand der kleinsten +zusätzlichen Weglänge gewählt. Übereinstimmende Orte werden zusammengeführt. +Eine Strecke mit nur Stationen erhält einen Weg in Stationsreihenfolge. +Weglänge, Routenpunktzahl und Suchgrenzen beziehen sich auf den gemeinsamen Weg. + +Aus Stationen ergänzte Routenpunkte tragen in ihren GPX-Erweiterungen die +Kennung `wegwichtel:waypointRoutePoint`. Dadurch lassen sich bei erneuter +Normalisierung bzw. Verschieben/Löschen einer Station alte ergänzte Anker +entfernen und anhand der aktuellen `wpt` neu berechnen. Ursprüngliche Wegpunkte +im aufgezeichneten Verlauf bleiben erhalten. Beim Anhängen einer anderen Strecke +wird deren berechneter Weg als zusätzlicher Verlauf übernommen. Wiederholte +Normalisierung darf keine zusätzlichen Stationsanker erzeugen. + `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 +erhalten. Gibt es weder gültige Wegpositionen noch Stationen mit Koordinaten, 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 @@ -82,7 +101,8 @@ 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. +Bei einem lokalen Entwurf mit ausschließlich Stationen fehlt vor dem Hochladen +die Wegführung. Nach dem Hochladen verbindet der berechnete Weg die Stationen. Stationen ohne Koordinaten lösen keine Standort-Erkennung aus. Während einer Pause werden die Pfeile verborgen. diff --git a/docs/agents/TODO-MEDIENRECHTE.md b/docs/agents/TODO-MEDIENRECHTE.md index d5eaae5..bed82c1 100644 --- a/docs/agents/TODO-MEDIENRECHTE.md +++ b/docs/agents/TODO-MEDIENRECHTE.md @@ -4,9 +4,13 @@ Ausgangspunkt: [Recherche und umgesetzte Hinweise](../Bilder-und-Ton-Nutzungsrec Die statischen Rechtstexte und X-Copyright sind eingebunden; ein belegbares Zustimmungs- und Namensnennungssystem ist noch nicht umgesetzt. +Kontakt für Wegwichtel, Rechtefragen, Datenschutzanfragen, Widerruf und Entfernung: +`info@wegwichtel.de` (vom Betreiber eingerichtet). Diesen Kontakt nicht erneut +als offenen Platzhalter führen. + ## Vor verbindlicher Verwendung und Freigabe zu erledigen -1. Den tatsächlichen Lizenznehmer, Rechtekontakt, Rechtsgrundlagen und Fristen +1. Den tatsächlichen Lizenznehmer, Rechtsgrundlagen und Fristen ergänzen. Den Entwurf und die Zustimmung für Kinder rechtlich prüfen lassen. 2. Die relevante Vereinbarung vor dem Einsenden zugänglich machen und eine nicht vorausgewählte, ausdrückliche Zustimmung einholen. Eine bloße diff --git a/docs/agents/TODO-RAETSEL-STATIONEN.md b/docs/agents/TODO-RAETSEL-STATIONEN.md new file mode 100644 index 0000000..2448204 --- /dev/null +++ b/docs/agents/TODO-RAETSEL-STATIONEN.md @@ -0,0 +1,36 @@ +# TODO: Rätsel-Stationen + +Vom Nutzer am 04.10.2026 vorgemerkt. Noch nicht implementieren. + +## Gewünschtes Verhalten + +- Eine Station kann als Rätsel-Station markiert werden und online gespeichert sein. +- Zunächst Beschreibung mit einem Rätseltext bzw. Hinweis zum Ermitteln der + Koordinaten anzeigen. Dafür ein eigenes Beschreibungsfeld vorsehen. +- Statt der echten Position zunächst Eingabefelder für Breitengrad und Längengrad + anbieten. Bedienungstexte einfach halten und aus DE-/EN-Katalogen beziehen. +- Eingegebene Koordinaten serverseitig mit dem Ziel vergleichen. Liegen sie nahe + genug, die Station freischalten und erst dann die echte Position abrufbar machen. +- Nach der Freischaltung Station wie eine normale Station anzeigen und verwenden. + +## Lösungsansätze und offene Entscheidungen + +- Rätselart, Hinweistext und akzeptierten Abstand in Metern im Stationsmodell + vorsehen; geeigneten Standardabstand und Wahlmöglichkeit später festlegen. +- Distanz auf dem Server berechnen. Die Prüfung ausschließlich im Client würde + die echte Position vorzeitig offenlegen. Wertebereiche validieren und falsche + Eingaben mit verständlichem Hinweis beantworten. +- Freischaltung an eine Bearbeitungs-/Spielgruppe, Sitzung oder Gerätekennung + binden. Die vorhandene creator_id ist allein keine sichere Authentifizierung. + Entscheiden, ob die Freischaltung gespeichert wird und Gruppen sie teilen. +- Echte Position vorher aus öffentlichen Stationsantworten, GPX-Wegpunkten, + Routenpunkten, Richtungspfeilen und Offline-Paketen heraushalten. Auch Suchgrenzen, + Medienmetadaten und geordnete Wegverläufe können die Lösung verraten. Diese + Auswirkungen auf die neue gemeinsame Routenberechnung ausdrücklich evaluieren. +- Für den späteren Koordinatenabruf eine überprüfbare Freigabe verwenden; + wiederholte Versuche angemessen begrenzen. Kein bloßes UI-Verstecken. +- Offline-Verhalten festlegen: serverseitige Prüfung braucht eine Verbindung; + eine vollständig offline prüfbare Lösung hat andere Schutzgrenzen. +- Tests für Treffer innerhalb des erlaubten Abstands, Grenzfälle, falsche und + ungültige Eingaben, Geräte-/Gruppenwechsel sowie verfrühten Koordinatenzugriff + und Datenlecks in GPX/Offline-Paketen vorsehen. diff --git a/public/css/editor.css b/public/css/editor.css index 235df78..f25db11 100644 --- a/public/css/editor.css +++ b/public/css/editor.css @@ -465,12 +465,18 @@ background: #fff; border: 1px solid rgba(49, 93, 58, .18); border-radius: .9rem; - display: flex; + display: grid; gap: .75rem; - justify-content: space-between; + grid-template-columns: minmax(0, 1fr) auto; padding: .75rem; } +.editor-route-item > :first-child, +.editor-poi-item > :first-child { + min-width: 0; + overflow-wrap: anywhere; +} + .editor-route-item strong, .editor-poi-item strong { display: block; @@ -517,16 +523,23 @@ } .editor-icon-row { - display: flex; - flex: 0 0 auto; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 2.75rem; gap: .35rem; + justify-self: end; } .editor-icon-button { align-items: center; display: inline-flex; justify-content: center; - min-width: 2.4rem; + box-sizing: border-box; + width: 2.75rem; + min-width: 2.75rem; + height: 2.75rem; + margin: 0; + padding: .35rem; } .editor-icon-danger { @@ -577,3 +590,6 @@ grid-template-columns: 1fr; } } + +.current-location-button { min-width: 44px; min-height: 44px; } +.current-location-button img { width: 24px; height: 24px; vertical-align: middle; } diff --git a/public/images/icons/current-location.svg b/public/images/icons/current-location.svg new file mode 100644 index 0000000..6243a03 --- /dev/null +++ b/public/images/icons/current-location.svg @@ -0,0 +1 @@ + diff --git a/public/index.html b/public/index.html index 7197942..04d4baf 100644 --- a/public/index.html +++ b/public/index.html @@ -319,6 +319,7 @@

Datenschutzangaben der Schule:
Staatliches Schulamt Südthüringen
Datenschutzbeauftragter
Hölderlinstraße 1
98527 Suhl
Telefon: 03681 / 7341 25
Fax: 03681 / 7341 09
E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de

Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.

+

Für Fragen zu Wegwichtel, Datenschutzanfragen, Widerruf und Entfernung von Aufnahmen erreichst du die Betreiber unter info@wegwichtel.de.

2. Wenn du Wegwichtel öffnest

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.

Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]

@@ -348,7 +349,7 @@

Die Nutzungsvereinbarung regelt, welche Nutzungsrechte du für diese Zwecke einräumst. Die Urheberrechte bleiben bei den jeweiligen Urhebern. Eine Nutzungserlaubnis für ein Bild oder eine Tonaufnahme ersetzt keine erforderliche Einwilligung der darin erkennbaren oder hörbaren Personen. Diese Datenschutzerklärung allein erteilt keine solche Erlaubnis.

Personenbezogene Aufnahmen werden nur veröffentlicht, wenn eine passende Rechtsgrundlage vorliegt. Soweit eine Einwilligung erforderlich ist, muss sie vor der Veröffentlichung eingeholt werden. Bei Kindern sind insbesondere die erforderlichen Zustimmungen ihrer Sorgeberechtigten zu beachten. Eine Einwilligung kann jederzeit für die Zukunft widerrufen werden; die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt davon unberührt.

Bei einem Widerruf oder einer berechtigten Löschanfrage prüfen die Betreiber die weitere Nutzung und entfernen die betroffenen Aufnahmen, soweit dies rechtlich erforderlich ist, aus der öffentlichen Bereitstellung. Bereits gespeicherte Kopien auf fremden Geräten können sie nicht vollständig zurückholen. Nicht öffentliche Sicherungskopien und Nachweise dürfen nur im rechtlich erforderlichen Umfang und für die dafür festgelegte Dauer aufbewahrt werden.

-

[Vor Veröffentlichung ergänzen: Rechtsgrundlagen für Einsendung, Prüfung, Veröffentlichung und Rechtenachweise; Empfänger und Hostinganbieter; Lösch- und Sicherungsfristen; Kontakt für Widerruf und Entfernung.]

+

[Vor Veröffentlichung ergänzen: Rechtsgrundlagen für Einsendung, Prüfung, Veröffentlichung und Rechtenachweise; Empfänger und Hostinganbieter; Lösch- und Sicherungsfristen.]

9. Deine Rechte

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.

Zuständige Datenschutzaufsicht:
@@ -361,6 +362,7 @@ Staatliche Grundschule Herpf
Zum Eichig 3
98617 Meiningen / OT Herpf

Kontakt

Telefon: (036943) 63722
Telefax: (036943) 63843
E-Mail: info@grundschule-herpf.de

+

Kontakt für Wegwichtel: info@wegwichtel.de

Leitung der Schule

Frau Wichert, Schulleiterin

Schulträger

@@ -369,7 +371,7 @@

Inhaltlich verantwortlich

Frau Wichert

Technisch verantwortlich

-

Florian Zumpe
[Anschrift von Florian Zumpe ergänzen]
E-Mail: [E-Mail-Adresse von Florian Zumpe ergänzen]

+

Florian Zumpe
[Anschrift von Florian Zumpe ergänzen]
E-Mail: info@wegwichtel.de

Weitere Pflichtangaben

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.

Haftung für Inhalte und Verweise

@@ -380,7 +382,7 @@

Die Urheberrechte an Bildern, Tonaufnahmen und anderen Beiträgen bleiben bei den jeweiligen Urhebern. Die Betreiber nutzen eingesandte Aufnahmen im Rahmen der vereinbarten Nutzungsrechte. Die Veröffentlichung erlaubt anderen Personen keine freie Weiterverwendung. Gesetzlich erlaubte Nutzungen und ausdrücklich erteilte Lizenzen bleiben unberührt.

@@ -485,6 +487,14 @@ +
+ +

Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.

+
Orte auf dem Weg ergänzen

Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.

diff --git a/public/js/app.js b/public/js/app.js index b5de889..e23c46a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -22,6 +22,19 @@ persistTimer: null }; + let poiMediaGeneration = 0; + let localPoiMedia = null; + + function releaseLocalPoiMedia() { + poiMediaGeneration += 1; + if (localPoiMedia) { + ns.AudioPlayer.unload(); + ns.Slideshow.show([]); + ns.LocalRoutes.releasePoiMedia(localPoiMedia); + localPoiMedia = null; + } + } + function escapeHtml(value) { return $('
').text(value ?? '').html(); } @@ -88,6 +101,7 @@ const target = $(pageSelector); if (!target.length) throw new Error(ns.I18n.t("app.page.missing")); + if (pageSelector !== '#poi-page') releaseLocalPoiMedia(); const previous = state.activePage; $('.app-page').attr('hidden', true); target.removeAttr('hidden'); @@ -287,6 +301,7 @@ } function endRoute({ announce = true, returnToRoutes = false } = {}) { + releaseLocalPoiMedia(); const wasActive = state.routeState !== 'idle'; state.routeState = 'idle'; persistRouteProgress(); @@ -330,6 +345,7 @@ } function suspendCurrentRoute() { + releaseLocalPoiMedia(); if (!state.route) return; if (state.routeState === 'running') state.routeState = 'paused'; persistRouteProgress(); @@ -383,6 +399,7 @@ } function showPoi(poi) { + releaseLocalPoiMedia(); state.activePoiId = Number(poi.id); state.viewed.add(state.activePoiId); persistRouteProgress(); @@ -392,6 +409,27 @@ ns.Slideshow.show(poi.images); ns.AudioPlayer.load(poi.audioUrl); navigate('#poi-page'); + if (state.route?.source === 'local') void loadLocalPoiMedia(poi); + } + + async function loadLocalPoiMedia(poi) { + const route = state.route; + const requestGeneration = poiMediaGeneration; + try { + const loaded = await ns.LocalRoutes.getAppPoi(route.id, poi.id, { requestPermission: true }); + if (requestGeneration !== poiMediaGeneration || state.route !== route) { + ns.LocalRoutes.releasePoiMedia(loaded); + return; + } + if (!loaded) return; + localPoiMedia = loaded; + ns.Slideshow.show(loaded.images); + ns.AudioPlayer.load(loaded.audioUrl); + } catch (error) { + if (requestGeneration === poiMediaGeneration) { + $('#tracking-status').prop('hidden', false).text(ns.UserMessages?.error(error) || ns.I18n.t('app.error.open-draft')); + } + } } function nearestRoutePointIndex(position) { @@ -680,7 +718,7 @@ async function selectRoute(source, id) { setRouteControlOpen(false); const route = source === 'local' - ? await ns.LocalRoutes.getAppRoute(id, { requestPermission: true }) + ? await ns.LocalRoutes.getAppRoute(id) : { ...(await ns.Api.route(id)), source: 'server', local: false }; renderRoute(route); navigate('#route-page'); diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index a606fb1..4995a10 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -202,7 +202,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; return ns.I18n.t("editor.view-route"); } + let poiMediaGeneration = 0; + let previewPoi = null; + + function releasePoiPreview() { + poiMediaGeneration += 1; + ns.LocalRoutes?.releasePoiMedia?.(previewPoi); + previewPoi = null; + } + function showListView() { + releasePoiPreview(); ns.RouteRecorder?.endEditing(); setDialogTitle('Streckenverwaltung'); setHidden(byId(ids.routeListView), false); @@ -211,6 +221,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } function showRouteView() { + releasePoiPreview(); setDialogTitle(routeDialogTitle()); setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeDetailView), false); @@ -411,8 +422,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; list.textContent = ''; const serverRoutes = state.routes.filter(route => routeSource(route) === 'server'); const localRoutes = state.routes.filter(route => routeSource(route) === 'local'); - renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route")); renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft")); + renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route")); } function renderPoiList() { @@ -438,19 +449,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; })); } + let routeListGeneration = 0; + async function loadRoutes() { - setStatus(ns.I18n.t("editor.loading-routes")); - const payload = await readJson('/routes?activatedOnly=true'); - const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' })); - let localRoutes = []; - try { - localRoutes = localRouteSummaries(); - } catch (error) { - setStatus(error.message, true); - } - state.routes = [...serverRoutes, ...localRoutes]; + const requestGeneration = ++routeListGeneration; + state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')]; renderRouteList(); - if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus(''); + setStatus(''); + // Local drafts are usable even while this request is waiting for a connection. + void refreshPublicRoutes(requestGeneration); + } + + async function refreshPublicRoutes(requestGeneration) { + const listIsVisible = () => byId(ids.dialog)?.open && !byId(ids.routeListView)?.hidden; + try { + const payload = await readJson('/routes?activatedOnly=true'); + if (requestGeneration !== routeListGeneration) return; + const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' })); + state.routes = [...localRouteSummaries(), ...serverRoutes]; + renderRouteList(); + } catch { + if (requestGeneration === routeListGeneration && listIsVisible()) { + setStatus(ns.I18n.t('editor.public-unavailable')); + } + } } async function openEditor() { @@ -471,6 +493,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } function closeEditor() { + releasePoiPreview(); ns.RouteRecorder?.endEditing(); const dialog = byId(ids.dialog); if (dialog?.open && typeof dialog.close === 'function') dialog.close(); @@ -499,6 +522,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function fillRouteForm(route) { setHidden(byId('editor-path-positions'), state.currentSource !== 'local'); + setHidden(byId('editor-recording-options'), 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); @@ -764,14 +788,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; async function loadRoute(route) { const id = routeId(route); if (!id) return; - if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) { - ns.RouteRecorder.endEditing(); + if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) { + ns.RouteRecorder?.endEditing(); } setStatus(ns.I18n.t("editor.route.loading")); state.currentSource = routeSource(route); if (state.currentSource === 'local') { - const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true }); + const routePackage = ns.LocalRoutes.get(id); if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing")); state.currentRoute = { ...routePackage.route, @@ -1048,6 +1072,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } function newPoi() { + releasePoiPreview(); const position = currentPosition(); const lat = position?.lat ?? null; const lon = position?.lon ?? null; @@ -1065,11 +1090,26 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; showPoiView(); } - function editPoi(poi) { + async function editPoi(poi) { + if (!poi) return; + releasePoiPreview(); state.currentPoi = poi; fillPoiForm(poi); byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station"); showPoiView(); + if (state.currentSource !== 'local') return; + const requestGeneration = poiMediaGeneration; + const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true }); + if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) { + ns.LocalRoutes.releasePoiMedia(hydrated); + return; + } + if (!hydrated) return; + previewPoi = hydrated; + state.currentPoi = hydrated; + // Preserve any text edits and newly selected files while existing previews load. + renderPoiMediaPreview(); + updatePoiMediaStatus(); } async function mediaFromLocalForm(existingPoi, poiIdentifier) { @@ -1122,13 +1162,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; route: state.currentRoute, pois: state.pois }); - const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved; - state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' }; - state.pois = hydrated.pois; + const previousPoi = state.currentPoi; + state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' }; + state.pois = saved.pois; state.currentPoi = currentPoiId ? state.pois.find(poi => poiId(poi) === currentPoiId) || null : null; - return hydrated; + if (state.currentPoi && previousPoi) { + state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images }; + } + return saved; } async function saveLocalPoi(values) { @@ -1241,7 +1284,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } updateCurrentPoi(updated); await persistLocalCurrentRoute(); - if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia); + if (removedMedia) { + ns.LocalRoutes.releasePoiMedia({ audio: removedMedia }); + await ns.LocalRoutes.removeMedia(removedMedia); + } state.selectedMediaKey = null; renderPoiMediaPreview(); updatePoiMediaStatus(); @@ -1416,7 +1462,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; async function poiListAction(event) { const action = event.target.closest('[data-editor-action]')?.dataset.editorAction; if (action === 'edit-poi') { - editPoi(poiForItem(event.target)); + await editPoi(poiForItem(event.target)); return; } if (action === 'delete-poi') { @@ -1442,12 +1488,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function bind() { for (const eventName of ['cancel', 'close']) { - byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing()); + byId(ids.dialog)?.addEventListener(eventName, () => { + releasePoiPreview(); + ns.RouteRecorder?.endEditing(); + }); } activate(byId(ids.button), () => guarded(openEditor)); activate(byId(ids.close), closeEditor); activate(byId(ids.newRoute), newRoute); - activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView)); + activate(byId(ids.routeBack), () => { showListView(); void guarded(loadRoutes); }); activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute)); activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms')); activate(byId(ids.addPoi), newPoi); diff --git a/public/js/local-routes.js b/public/js/local-routes.js index 369cfd3..5a04d37 100644 --- a/public/js/local-routes.js +++ b/public/js/local-routes.js @@ -12,6 +12,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; const MEDIA_STORE_NAME = 'media'; let mediaDatabasePromise = null; let poiCounter = 0; + const objectUrls = new Set(); function storage() { const target = globalThis.localStorage; @@ -195,7 +196,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; const blob = await mediaBlob(media, options); if (!blob) return { ...media, unavailable: true }; if (typeof globalThis.URL?.createObjectURL === 'function') { - return { ...media, url: globalThis.URL.createObjectURL(blob), unavailable: false }; + const url = globalThis.URL.createObjectURL(blob); + objectUrls.add(url); + return { ...media, url, unavailable: false }; } const fallback = await fileToDataUrl(blob); return { ...media, dataUrl: fallback.dataUrl, unavailable: false }; @@ -204,16 +207,39 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } } - async function withMedia(routePackage, options = {}) { - if (!routePackage) return null; - const pois = await Promise.all((routePackage.pois || []).map(async poi => ({ + async function poiWithMedia(poi, options = {}) { + if (!poi) return null; + return { ...poi, audio: await mediaForUse(poi.audio, options), images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options))) - }))); + }; + } + + function releasePoiMedia(poi) { + const urls = [poi?.audio?.url, poi?.audioUrl, ...(poi?.images || []).map(image => image.url)]; + for (const url of urls) { + if (!objectUrls.delete(url)) continue; + globalThis.URL?.revokeObjectURL?.(url); + } + } + + async function withMedia(routePackage, options = {}) { + if (!routePackage) return null; + const pois = await Promise.all((routePackage.pois || []).map(poi => poiWithMedia(poi, options))); return { ...routePackage, pois }; } + async function getPoiWithMedia(id, poiId, options = {}) { + const poi = readPackage(id)?.pois.find(item => String(item.id) === String(poiId)); + return poiWithMedia(poi, options); + } + + async function getAppPoi(id, poiId, options = {}) { + const poi = await getPoiWithMedia(id, poiId, options); + return poi ? appPoi(poi) : null; + } + function mediaForStorage(media) { if (!media) return null; const stored = { ...media }; @@ -709,8 +735,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; return listPackages().map(toAppRoute); } - async function getAppRoute(id, options = {}) { - const routePackage = await withMedia(readPackage(id), options); + async function getAppRoute(id) { + const routePackage = readPackage(id); if (!routePackage) throw new Error(ns.I18n.t("local.error.route-missing")); return toAppRoute(routePackage); } @@ -762,6 +788,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; get: readPackage, getAppRoute, getWithMedia, + getPoiWithMedia, + getAppPoi, + releasePoiMedia, gpxForUpload, list: listPackages, listAppRoutes, diff --git a/public/js/route-recorder.js b/public/js/route-recorder.js index c8d08be..b6e8287 100644 --- a/public/js/route-recorder.js +++ b/public/js/route-recorder.js @@ -1,18 +1,22 @@ globalThis.Wegwichtel ??= {}; (function (ns) { 'use strict'; - const INTERVAL_MS = 30000; + const INTERVAL_MS = 120000; let id = null; let editingId = null; let status = 'idle'; let timer = null; let generation = 0; - let pending = false; + let pendingGeneration = null; + let enabled = false; + let manualGeneration = 0; let message = ''; let manualFields = []; let lastPosition = null; function render() { + const toggle = document.getElementById('route-recording-toggle'); + if (toggle) { toggle.checked = enabled; toggle.setAttribute('aria-checked', String(enabled)); } const control = document.getElementById('route-recording-control'); if (!control) return; control.hidden = status === 'idle'; @@ -21,9 +25,9 @@ globalThis.Wegwichtel ??= {}; } async function sample() { - if (pending || status !== 'recording') return; - pending = true; + if (pendingGeneration === generation || status !== 'recording') return; const currentGeneration = generation; + pendingGeneration = currentGeneration; try { let position; try { position = await ns.Geo.current({ maximumAge: 0 }); } @@ -40,7 +44,7 @@ globalThis.Wegwichtel ??= {}; ns.StorageUsage?.reportError(error); message = ns.I18n.t('recorder.save-error'); } finally { - pending = false; + if (pendingGeneration === currentGeneration) pendingGeneration = null; render(); } } @@ -69,6 +73,7 @@ globalThis.Wegwichtel ??= {}; } function stop() { + enabled = false; clearTimer(); id = null; lastPosition = null; @@ -78,6 +83,7 @@ globalThis.Wegwichtel ??= {}; } function start(routeId) { + enabled = true; lastPosition = null; id = String(routeId); editingId = id; @@ -85,16 +91,46 @@ globalThis.Wegwichtel ??= {}; } function beginEditing(routeId) { - if (editingId === String(routeId)) return; - stop(); - start(routeId); + const nextId = String(routeId); + if (editingId === nextId) return; + if (editingId !== null) endEditing(); + editingId = nextId; + if (enabled) { id = editingId; resume(); } + render(); + } + + function setEnabled(value) { + if (!value) { stop(); return; } + enabled = true; + if (editingId) { id = editingId; resume(); } + render(); } function endEditing() { editingId = null; + manualGeneration += 1; stop(); } + async function fillCurrentLocation(field, button, notice) { + if (button.disabled) return; + const currentGeneration = manualGeneration; + button.disabled = true; + notice.textContent = ns.I18n.t('recorder.locating'); + try { + const position = await ns.Geo.current({ maximumAge: 0 }); + if (currentGeneration !== manualGeneration || !manualFields.includes(field)) return; + if (!Number.isFinite(position.lat) || !Number.isFinite(position.lon) || Math.abs(position.lat) > 90 || Math.abs(position.lon) > 180) throw new Error('Invalid location'); + field.lat.value = String(position.lat); + field.lon.value = String(position.lon); + notice.textContent = ns.I18n.t('recorder.location-set'); + } catch { + if (currentGeneration === manualGeneration && manualFields.includes(field)) notice.textContent = ns.I18n.t('recorder.location-error'); + } finally { + button.disabled = false; + } + } + function addPositionField(position = {}, index = null) { const container = document.getElementById('editor-recorded-positions'); if (!container) return; @@ -114,13 +150,30 @@ globalThis.Wegwichtel ??= {}; row.appendChild(label); row.appendChild(input); fields[key] = input; } - manualFields.push({ index, original: position, ...fields }); + const field = { index, original: position, ...fields }; + manualFields.push(field); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'current-location-button'; + button.setAttribute('aria-label', ns.I18n.t('recorder.use-location')); + button.title = ns.I18n.t('recorder.use-location'); + const icon = document.createElement('img'); + icon.src = '/images/icons/current-location.svg'; + icon.alt = ''; + button.appendChild(icon); + const notice = document.createElement('p'); + notice.className = 'editor-muted'; + notice.setAttribute('aria-live', 'polite'); + button.addEventListener('click', () => { void fillCurrentLocation(field, button, notice); }); + row.appendChild(button); + row.appendChild(notice); container.appendChild(row); } function renderPoints(gpx) { const container = document.getElementById('editor-recorded-positions'); if (!container) return; + manualGeneration += 1; container.replaceChildren(); manualFields = []; ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField); @@ -146,6 +199,10 @@ globalThis.Wegwichtel ??= {}; function bind() { globalThis.addEventListener?.('pagehide', endEditing); + document.getElementById('route-recording-toggle')?.addEventListener('change', event => setEnabled(event.target.checked)); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && status === 'recording') void sample(); + }); const activate = (elementId, action) => { const element = document.getElementById(elementId); element?.addEventListener('pointerup', event => { diff --git a/public/js/texts-de.js b/public/js/texts-de.js index f492586..4e8566a 100644 --- a/public/js/texts-de.js +++ b/public/js/texts-de.js @@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {}; "drafts": "Meine Entwürfe", "no-draft": "Noch kein eigener Entwurf vorhanden.", "no-stations": "Noch keine Stationen angelegt.", + "public-unavailable": "Die veröffentlichten Strecken sind gerade nicht erreichbar. Deine Entwürfe auf diesem Gerät kannst du weiter bearbeiten.", "loading-routes": "Strecken werden geladen …", "submitted": "Erneut zur Prüfung senden", "rights-hint": "Wenn du Bilder oder Tonaufnahmen sendest, erlaubst du Wegwichtel, sie nach der Nutzungsvereinbarung zu verwenden. Sende nur Aufnahmen, für die du diese Erlaubnis geben darfst. Kinder brauchen die Zustimmung ihrer Sorgeberechtigten.", @@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {}; } }, "recorder": { + "toggle": "Meinen Weg aufnehmen", + "toggle-help": "Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.", + "use-location": "Meinen Standort einsetzen", + "locating": "Dein Standort wird gesucht …", + "location-set": "Dein Standort ist eingetragen. Speichere jetzt deine Änderungen.", + "location-error": "Dein Standort konnte nicht gefunden werden. Erlaube den Zugriff auf deinen Standort und versuche es noch einmal.", + "recording": "Dein Weg wird aufgenommen.", "paused": "Die Aufnahme macht eine Pause.", "pause": "Aufnahme pausieren", diff --git a/public/js/texts-en.js b/public/js/texts-en.js index 3e102b7..c92603d 100644 --- a/public/js/texts-en.js +++ b/public/js/texts-en.js @@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {}; "drafts": "My drafts", "no-draft": "There are no drafts yet.", "no-stations": "There are no stops yet.", + "public-unavailable": "Published trails are currently unavailable. You can still edit your drafts on this device.", "loading-routes": "Loading trails …", "submitted": "Send changes for review", "rights-hint": "By sending pictures or recordings, you allow Wegwichtel to use them under its terms of use. Only send recordings you are allowed to share this way. Children need their parent or guardian’s permission.", @@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {}; } }, "recorder": { + "toggle": "Record my path", + "toggle-help": "Your location is saved on this device every two minutes. Recording stops when you leave the editor.", + "use-location": "Use my current location", + "locating": "Finding your location …", + "location-set": "Your location has been entered. Save your changes now.", + "location-error": "Your location could not be found. Allow access to your location and try again.", + "recording": "Your trail is being recorded.", "paused": "Recording is paused.", "pause": "Pause recording", diff --git a/src/services/gpx.js b/src/services/gpx.js index 90d5cbf..71e0190 100644 --- a/src/services/gpx.js +++ b/src/services/gpx.js @@ -3,6 +3,8 @@ import { HttpError } from '../middleware/errors.js'; import { distanceMeters, routeMetrics } from './geo.js'; import { config } from '../config.js'; +const WAYPOINT_DERIVED = Symbol('waypointDerived'); + const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true }; const parser = new XMLParser(xmlOptions); const builder = new XMLBuilder({ ...xmlOptions, format: true }); @@ -20,6 +22,7 @@ function parseXml(xml) { function toRoutePoint(point, sequence) { return { sequence, + ...(point.extensions?.['wegwichtel:waypointRoutePoint'] === true ? { [WAYPOINT_DERIVED]: true } : {}), lat: Number.parseFloat(point['@_lat']), lon: Number.parseFloat(point['@_lon']), elevation: point.ele == null ? null : Number.parseFloat(point.ele), @@ -42,6 +45,49 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) { return result.map((point, sequence) => ({ ...point, sequence })); } +function sameLocation(left, right) { + return left.lat === right.lat && left.lon === right.lon; +} + +function insertionDistance(path, slot, waypoint) { + const before = path[slot - 1]; + const after = path[slot]; + return (before ? distanceMeters(before, waypoint) : 0) + + (after ? distanceMeters(waypoint, after) : 0) + - (before && after ? distanceMeters(before, after) : 0); +} + +function waypointSlot(path, waypoint, firstSlot) { + let slot = firstSlot; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = firstSlot; index <= path.length; index += 1) { + const addedDistance = insertionDistance(path, index, waypoint); + if (addedDistance < bestDistance) { + bestDistance = addedDistance; + slot = index; + } + } + return slot; +} + +// Keep the recorded path and station order. Among remaining gaps choose the +// insertion with the smallest extra walking distance; no road routing is used. +function mergeWaypoints(path, waypoints) { + const result = [...path]; + let firstSlot = 0; + for (const waypoint of waypoints) { + const existing = result.findIndex((point, index) => index >= firstSlot && sameLocation(point, waypoint)); + if (existing !== -1) { + firstSlot = existing; + continue; + } + const slot = waypointSlot(result, waypoint, firstSlot); + result.splice(slot, 0, { ...waypoint, [WAYPOINT_DERIVED]: true }); + firstSlot = slot + 1; + } + return result.map((point, sequence) => ({ ...point, sequence })); +} + export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) { const gpx = parseXml(xml)?.gpx; if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.'); @@ -49,16 +95,16 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = 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)); + const path = (replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded))) + .filter(point => !point[WAYPOINT_DERIVED]); + const points = mergeWaypoints(path, waypoints); if (!allowEmpty && !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 metricPoints = points.length ? points : waypoints; - const metrics = metricPoints.length ? routeMetrics(metricPoints) : { + const metrics = points.length ? routeMetrics(points) : { 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; @@ -66,11 +112,12 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = const { extensions, ...base } = gpx; // GPX 1.1 namespace identifier: this URI is written into XML, never fetched. // Its exact spelling is part of the GPX standard; HTTPS would be another namespace. - const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' }; + const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi', '@_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 } : {}) + ...(point.recordedAt ? { time: point.recordedAt } : {}), + ...(point[WAYPOINT_DERIVED] ? { extensions: { 'wegwichtel:waypointRoutePoint': true } } : {}) })) }; if (extensions) normalized.extensions = extensions; return { name, points, metrics, normalizedXml: '\n' + builder.build({ gpx: normalized }) }; diff --git a/src/services/routes-service.js b/src/services/routes-service.js index 70b74d7..2333021 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -246,7 +246,10 @@ export function appendRoute(db, id, gpxFile) { const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8')); const current = readRouteGpx(existing).points; - const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence })); + // An appended route contributes its complete path, including its stations. + // Those points are now part of the path independently of this route's POIs. + const appendedPoints = parsed.points.map(({ lat, lon, elevation, recordedAt }) => ({ lat, lon, elevation, recordedAt })); + const allPoints = current.concat(appendedPoints).map((point, sequence) => ({ ...point, sequence })); const target = resolveStoredFile(existing.gpx_path); const original = fs.readFileSync(target, 'utf8'); const normalized = parseGpx(original, allPoints); @@ -290,8 +293,9 @@ 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))); - updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics); + const normalized = parseGpx(gpxWithWaypoints(xml, listPois(db, routeId)), null, { allowEmpty: true }); + fs.writeFileSync(filename, normalized.normalizedXml); + updateBounds(db, routeId, normalized.metrics); } function poiCoordinates(fields, existing = {}) { diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js index 1e8aee8..7b51197 100644 --- a/test/app-route-control-runtime.test.js +++ b/test/app-route-control-runtime.test.js @@ -277,6 +277,8 @@ async function loadApp({ language = 'de-DE' } = {}) { stop() {} }, LocalRoutes: { + async getAppPoi(_id, poiId) { return structuredClone(localRoute?.pois.find(poi => Number(poi.id) === Number(poiId)) || null); }, + releasePoiMedia() {}, getAppRoute(id) { if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden'); return structuredClone(localRoute); @@ -640,3 +642,43 @@ test('invalid API responses reject initialization and route selection errors res assert.deepEqual(errors, ['Strecke entfernt']); assert.equal(button.properties.get('disabled'), false); }); + + +test('local station playback loads only the selected station and ignores media arriving after leaving', async () => { + const setup = await loadApp(); + const { context, harness } = setup; + const ns = context.Wegwichtel; + await ns.App.initialize({ markStep() {} }); + const button = new FakeNode('local-select'); + button.dataValues.set('route-source', 'local'); + button.dataValues.set('route-id', 'local-1'); + const requests = []; + const released = []; + ns.LocalRoutes.getAppPoi = (routeId, poiId) => new Promise(resolve => requests.push({ routeId, poiId, resolve })); + ns.LocalRoutes.releasePoiMedia = poi => released.push(poi); + const calls = observeSensors(setup); + await harness.activate('[data-route-select]', button); + assert.equal(requests.length, 0); + const poiButton = new FakeNode('station'); + poiButton.dataValues.set('poi-id', 10); + // Use the first actual local station ID rather than assuming server IDs. + const local = ns.LocalRoutes.getAppRoute('local-1'); + poiButton.dataValues.set('poi-id', local.pois[0].id); + await harness.activate('[data-poi-select]', poiButton); + assert.equal(requests.length, 1); + assert.equal(requests[0].routeId, 'local-1'); + assert.equal(requests[0].poiId, local.pois[0].id); + const firstLoaded = { images: [{ url: 'blob:shown' }], audioUrl: 'blob:shown-audio' }; + requests[0].resolve(firstLoaded); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(calls.audio.at(-1), 'blob:shown-audio'); + assert.equal(calls.slides.at(-1)[0].url, 'blob:shown'); + await harness.activate('[data-poi-select]', poiButton); + assert.equal(released[0], firstLoaded); + await harness.activate('#stop-route', new FakeNode('stop')); + const loaded = { images: [{ url: 'blob:late' }], audioUrl: 'blob:late-audio' }; + requests[1].resolve(loaded); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(released[1], loaded); + assert.equal(calls.audio.includes('blob:late-audio'), false); +}); diff --git a/test/backend-services.test.js b/test/backend-services.test.js index d7a30a9..47e3be5 100644 --- a/test/backend-services.test.js +++ b/test/backend-services.test.js @@ -149,7 +149,13 @@ test('route, POI, media and storage services cover successful and rejected lifec const indoorStation = routes.createPoi(db, indoor.id, { title: 'Ohne GPS', lat: null, lon: null }); assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null); routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 }); + assert.equal(routes.getRoute(db, indoor.id).pointCount, 1); assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 50); + routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 51, lon: 11 }); + const movedStationRoute = routes.getRoute(db, indoor.id); + assert.equal(movedStationRoute.pointCount, 1); + assert.equal(movedStationRoute.points[0].lat, 51); + assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 51); routes.deletePoi(db, indoor.id, indoorStation.id); assert.equal(routes.getRoute(db, indoor.id).pointCount, 0); assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null); diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js index f02d83f..49b2e8a 100644 --- a/test/editor-ui.test.js +++ b/test/editor-ui.test.js @@ -302,6 +302,13 @@ function buildEditorContext() { type: file.type || 'application/octet-stream' }), get: id => packages.get(String(id)) || null, + getPoiWithMedia: async (id, poiId) => { + const value = packages.get(String(id))?.pois.find(poi => String(poi.id) === String(poiId)); + if (!value) return null; + return { ...value, audio: value.audio ? { ...value.audio, url: 'blob:test-audio' } : null, + images: (value.images || []).map(image => ({ ...image, url: 'blob:test-image' })) }; + }, + releasePoiMedia() {}, getWithMedia: async id => { const value = packages.get(String(id)); if (!value) return null; @@ -659,6 +666,7 @@ test('editing an activated server route uses PUT and never writes activation sta await context.Wegwichtel.EditorUi.open(); const routeList = elements.get('editor-route-list'); + await tick(); const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server'); const serverActions = serverItem.children[1]; assert.equal(serverActions.children.length, 1); @@ -737,3 +745,94 @@ test('local media picker keeps File System Access handles for later upload', asy assert.equal(imageStore.handle, handle); assert.equal(imageStore.file, imageFile); }); + + +test('local drafts can be opened and saved while the server request is still pending', async () => { + const { context, elements, packages } = buildEditorContext(); + context.fetch = () => new Promise(() => {}); + await loadI18n(context); + vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); + await context.Wegwichtel.EditorUi.open(); + const list = elements.get('editor-route-list'); + const item = list.children.find(child => child.dataset.routeId === 'local-existing'); + assert.ok(item); + list.dispatchEvent(pointerEvent(item.children[1].children[0])); + await tick(); + assert.equal(elements.get('editor-route-id').value, 'local-existing'); + elements.get('editor-route-name').value = 'Offline geändert'; + elements.get('editor-route-form').dispatchEvent(submitEvent()); + await tick(); await tick(); + assert.equal(packages.get('local-existing').route.name, 'Offline geändert'); + assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); +}); + +test('a failed public route request preserves local drafts and late replies use the latest local data', async () => { + const { context, elements, packages } = buildEditorContext(); + const requests = []; + context.fetch = () => new Promise((resolve, reject) => requests.push({ resolve, reject })); + await loadI18n(context); + vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); + await context.Wegwichtel.EditorUi.open(); + requests[0].reject(new Error('offline')); await tick(); + assert.match(elements.get('editor-status').textContent, /nicht erreichbar/); + assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing')); + await context.Wegwichtel.EditorUi.refresh(); + packages.delete('local-existing'); + requests[1].resolve(jsonResponse({ routes: [{ id: 7, name: 'Veröffentlicht' }] })); + await tick(); await tick(); + assert.equal(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'), false); + assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeSource === 'server')); +}); + + +test('opening and saving a draft does not load files; editing a station loads only that station', async () => { + const { context, elements, packages } = buildEditorContext(); + const local = packages.get('local-existing'); + local.pois = [0, 1].map(id => ({ id, title: `Station ${id}`, sequence: id, images: [{ id: `image-${id}`, mediaKey: `key-${id}` }] })); + const loads = []; + context.Wegwichtel.LocalRoutes.getWithMedia = () => { throw new Error('Full route hydration must not run'); }; + const original = context.Wegwichtel.LocalRoutes.getPoiWithMedia; + context.Wegwichtel.LocalRoutes.getPoiWithMedia = (...args) => { loads.push(args); return original(...args); }; + await loadI18n(context); + vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); + await context.Wegwichtel.EditorUi.open(); + const list = elements.get('editor-route-list'); + const item = list.children.find(child => child.dataset.routeId === 'local-existing'); + list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick(); + assert.equal(loads.length, 0); + elements.get('editor-route-name').value = 'Nur Name geändert'; + elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); + assert.equal(loads.length, 0); + const poiList = elements.get('editor-poi-list'); + const poiItem = poiList.children.find(child => child.dataset.poiId === '1'); + poiList.dispatchEvent(pointerEvent(poiItem.children[1].children[2])); await tick(); + assert.equal(loads.length, 1); + assert.equal(loads[0][1], 1); + assert.equal(elements.get('editor-poi-media-preview').children.length, 1); + elements.get('editor-poi-title').value = 'Titel geändert'; + elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick(); + assert.equal(loads.length, 1); + assert.equal(packages.get('local-existing').pois[1].title, 'Titel geändert'); +}); + +test('late station previews are released after the editor closes without replacing unsaved input', async () => { + const { context, elements, packages } = buildEditorContext(); + packages.get('local-existing').pois = [{ id: 0, title: 'Station', images: [] }]; + let finish; + const released = []; + context.Wegwichtel.LocalRoutes.getPoiWithMedia = () => new Promise(resolve => { finish = resolve; }); + context.Wegwichtel.LocalRoutes.releasePoiMedia = poi => { if(poi) released.push(poi); }; + await loadI18n(context); + vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); + await context.Wegwichtel.EditorUi.open(); + const list = elements.get('editor-route-list'); + const item = list.children.find(child => child.dataset.routeId === 'local-existing'); + list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick(); + const poiList = elements.get('editor-poi-list'); + poiList.dispatchEvent(pointerEvent(poiList.children[0].children[1].children[2])); await tick(); + elements.get('editor-poi-title').value = 'Noch nicht gespeichert'; + context.Wegwichtel.EditorUi.close(); + finish({ id: 0, title: 'Alter Titel', images: [{ url: 'blob:late' }] }); await tick(); + assert.equal(released.length, 1); + assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert'); +}); diff --git a/test/gpx.test.js b/test/gpx.test.js index dc6df72..b601045 100644 --- a/test/gpx.test.js +++ b/test/gpx.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseGpx } from '../src/services/gpx.js'; +import { parseGpx, gpxWithWaypoints } from '../src/services/gpx.js'; import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js'; test('parseGpx reads synthetic track data without repository example files', () => { @@ -11,3 +11,73 @@ test('parseGpx reads synthetic track data without repository example files', () assert.equal(parsed.points[0].lat, 52.5200); assert.equal(parsed.points[2].lon, 13.4090); }); + +const point = (tag, lat, lon, content = '') => `<${tag} lat="${lat}" lon="${lon}">${content}`; +const gpx = (waypoints, path = '', extra = '') => `${waypoints}${path}${extra}`; +const track = points => `${points}`; +const locations = points => points.map(({ lat, lon }) => [lat, lon]); + +test('waypoints alone become route points in station order and contribute to distance and bounds', () => { + const parsed = parseGpx(gpx(point('wpt', 50, 10) + point('wpt', 50.01, 10.02))); + assert.deepEqual(locations(parsed.points), [[50, 10], [50.01, 10.02]]); + assert.equal(parsed.metrics.pointCount, 2); + assert.ok(parsed.metrics.distanceM > 0); + assert.equal(parsed.metrics.maxLon, 10.02); + assert.match(parsed.normalizedXml, / { + const wp = point('wpt', 50, 10.00001) + point('wpt', 50.005, 10.015); + const recorded = point('trkpt', 50, 10) + point('trkpt', 50, 10.00002) + + point('trkpt', 50, 10.01) + point('trkpt', 50, 10.02); + const parsed = parseGpx(gpx(wp, track(recorded))); + assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.00001], [50, 10.01], [50.005, 10.015], [50, 10.02]]); + assert.equal(parsed.metrics.maxLat, 50.005); + assert.equal(parsed.points.length, 5); + assert.deepEqual(parsed.points.map(p => p.sequence), [0, 1, 2, 3, 4]); + assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points)); +}); + +test('duplicate station locations do not multiply route points and station order is retained on loops', () => { + const stations = point('wpt', 50, 10.02) + point('wpt', 50, 10.02) + point('wpt', 50, 10.01); + const recorded = [10, 10.01, 10.02, 10.01, 10].map(lon => point('trkpt', 50, lon)).join(''); + const parsed = parseGpx(gpx(stations, track(recorded))); + assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.01], [50, 10.02], [50, 10.01], [50, 10]]); + assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points)); +}); + +test('existing routes and replacement points incorporate stations, including stations before and after the path', () => { + const stations = [9.99, 10.005, 10.02].map(lon => point('wpt', 50, lon)).join(''); + const route = `${point('rtept', 50, 10)}${point('rtept', 50, 10.01)}`; + const xml = gpx(stations, route); + const parsed = parseGpx(xml); + assert.deepEqual(locations(parsed.points), [[50, 9.99], [50, 10], [50, 10.005], [50, 10.01], [50, 10.02]]); + assert.deepEqual(locations(parseGpx(xml, [{ lat: 50, lon: 10 }, { lat: 50, lon: 10.01 }]).points), locations(parsed.points)); +}); + +test('missing and invalid station coordinates are excluded while GPS gaps remain available for editing', () => { + const stations = 'Ohne Ort' + point('wpt', 91, 10) + point('wpt', 50, 181) + point('wpt', 50, 10); + const extra = 'Später'; + const parsed = parseGpx(gpx(stations, '', extra)); + assert.deepEqual(locations(parsed.points), [[50, 10]]); + assert.match(parsed.normalizedXml, /wegwichtel:gpsGap/); + assert.match(parsed.normalizedXml, /wegwichtel:pendingWaypoint/); + const empty = parseGpx(gpx('', '', extra)); + assert.equal(empty.metrics.pointCount, 0); + assert.equal(empty.metrics.minLat, null); +}); + + +test('moving or deleting a station replaces its generated route anchor without removing recorded points', () => { + const original = parseGpx(gpx(point('wpt', 50.01, 10.005), track(point('trkpt', 50, 10) + point('trkpt', 50, 10.01)))); + assert.match(original.normalizedXml, /wegwichtel:waypointRoutePoint/); + const updated = parseGpx(gpxWithWaypoints(original.normalizedXml, [{ id: 1, title: 'Neu', lat: 50.02, lon: 10.006 }])); + assert.equal(updated.points.length, 3); + assert.equal(updated.metrics.maxLat, 50.02); + assert.equal(updated.points.some(p => p.lat === 50.01), false); + const removed = parseGpx(gpxWithWaypoints(updated.normalizedXml, [])); + assert.deepEqual(locations(removed.points), [[50, 10], [50, 10.01]]); + assert.deepEqual(locations(parseGpx(updated.normalizedXml).points), locations(updated.points)); +}); diff --git a/test/legal-ui.test.js b/test/legal-ui.test.js index ba46023..0c5bca1 100644 --- a/test/legal-ui.test.js +++ b/test/legal-ui.test.js @@ -45,7 +45,8 @@ test('information button opens scrollable overlay pages for privacy, imprint, te assert.match(html, /data-info-target="technology"/); assert.match(html, /

Technisch verantwortlich<\/h3>\s*

Florian Zumpe
/); assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/); - assert.match(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]/); + assert.match(html, /E-Mail: info@wegwichtel\.de<\/a>/); + assert.doesNotMatch(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]|\[Kontakt für Rechtefragen ergänzen\]/); const storage = await read('public/js/privacy-storage.js'); assert.match(storage, /wegwichtel\.privacy\.v1/); assert.match(storage, /wegwichtel\.route-progress\.v1/); diff --git a/test/local-routes.test.js b/test/local-routes.test.js index b85b0a5..edf9aa1 100644 --- a/test/local-routes.test.js +++ b/test/local-routes.test.js @@ -730,3 +730,35 @@ test('creator identity never falls back to insecure randomness or replaces corru assert.throws(() => api.creatorId(), /Gerätekennung/); assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken'); }); + + +test('route selection reads metadata only and station media is loaded and released separately', async () => { + const { api, context } = await loadLocalRoutes(); + const reads = [0, 0]; + const revoked = []; + let urlNumber = 0; + context.URL.createObjectURL = () => `blob:lazy-${++urlNumber}`; + context.URL.revokeObjectURL = url => revoked.push(url); + const files = []; + for (let id = 0; id < 2; id++) { + const file = Object.assign(new Blob(['image']), { name: `${id}.jpg` }); + const handle = { async getFile() { reads[id]++; return file; } }; + files.push(await api.storeMediaFile('lazy', id, 'image', id, file, handle)); + } + api.save({ id: 'lazy', route: { name: 'Lazy' }, pois: files.map((image, id) => ({ id, images: [image] })) }); + await api.getAppRoute('lazy'); + api.get('lazy'); + assert.deepEqual(reads, [0, 0]); + const first = await api.getPoiWithMedia('lazy', 0, { requestPermission: true }); + assert.deepEqual(reads, [1, 0]); + assert.equal(first.images[0].url, 'blob:lazy-1'); + const second = await api.getAppPoi('lazy', 1); + assert.deepEqual(reads, [1, 1]); + assert.equal(second.images[0].url, 'blob:lazy-2'); + api.releasePoiMedia(first); api.releasePoiMedia(first); api.releasePoiMedia(second); + api.releasePoiMedia({ images: [{ url: 'https://example.org/image.jpg' }] }); + assert.deepEqual(revoked, ['blob:lazy-1', 'blob:lazy-2']); + assert.equal(await api.getPoiWithMedia('lazy', 9), null); + assert.equal(await api.getAppPoi('missing', 0), null); + assert.equal(api.get('lazy').pois[0].images[0].url, undefined); +}); diff --git a/test/route-recorder.test.js b/test/route-recorder.test.js index 332212d..45968ad 100644 --- a/test/route-recorder.test.js +++ b/test/route-recorder.test.js @@ -14,7 +14,7 @@ async function setup() { 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 ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position', 'route-recording-toggle']; const nodes = new Map(ids.map(id => [id, new Element()])); const document = new EventTarget(); document.readyState = 'complete'; @@ -23,7 +23,7 @@ async function setup() { 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; }, + setInterval(callback, delay) { assert.equal(delay, 120000); intervals.set(1, callback); return 1; }, clearInterval(id) { intervals.delete(id); }, Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) }, LocalRoutes: { @@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) { node.dispatchEvent(event); } -test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => { +test('recording persists every two minutes during an editing session, pauses and finishes', async () => { const { recorder, packages, intervals, nodes } = await setup(); recorder.start('draft'); await tick(); assert.equal(recorder.status, 'recording'); @@ -106,10 +106,14 @@ test('missing recorded locations can be filled manually while newer samples rema test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => { - const { recorder, intervals, packages } = await setup(); + const { recorder, intervals, packages, nodes } = await setup(); assert.equal(recorder.status, 'idle'); recorder.beginEditing('draft'); await tick(); + assert.equal(intervals.size, 0); + nodes.get('route-recording-toggle').checked = true; + nodes.get('route-recording-toggle').dispatchEvent(new Event('change')); + await tick(); assert.equal(intervals.size, 1); recorder.pause(); recorder.beginEditing('draft'); @@ -121,8 +125,68 @@ test('ending editing stops sampling, reopening permits recording and a fresh app assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); recorder.beginEditing('draft'); await tick(); - assert.equal(recorder.status, 'recording'); + assert.equal(recorder.status, 'idle'); + assert.equal(nodes.get('route-recording-toggle').checked, false); const fresh = await setup(); assert.equal(fresh.recorder.status, 'idle'); assert.equal(fresh.intervals.size, 0); }); + + +test('the switch can prepare a new draft and late GPS from an older session is ignored', async () => { + const { context, recorder, nodes, packages, intervals } = await setup(); + const answers = []; + context.Wegwichtel.Geo.current = () => new Promise(resolve => answers.push(resolve)); + const toggle = nodes.get('route-recording-toggle'); + toggle.checked = true; toggle.dispatchEvent(new Event('change')); + assert.equal(answers.length, 0); + recorder.beginEditing('draft'); + assert.equal(answers.length, 1); + toggle.checked = false; toggle.dispatchEvent(new Event('change')); + toggle.checked = true; toggle.dispatchEvent(new Event('change')); + assert.equal(answers.length, 2); + answers[1]({ lat: 52, lon: 12 }); await tick(); + answers[0]({ lat: 50, lon: 10 }); await tick(); + assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); + assert.equal(JSON.parse(packages.get('draft').gpx)[0].lat, 52); + context.Wegwichtel.Geo.current = async () => ({ lat: 53, lon: 13 }); + context.document.visibilityState = 'visible'; + context.document.dispatchEvent(new Event('visibilitychange')); await tick(); + assert.equal(JSON.parse(packages.get('draft').gpx).length, 2); + recorder.endEditing(); + assert.equal(intervals.size, 0); + assert.equal(toggle.checked, false); +}); + +test('each crosshair fills only its own point without starting recording or saving', async () => { + const { recorder, nodes, packages } = await setup(); + recorder.renderPoints('[{"lat":1,"lon":2},{"lat":3,"lon":4}]'); + const [first, second] = nodes.get('editor-recorded-positions').children; + second.children[5].dispatchEvent(new Event('click')); await tick(); + assert.equal(first.children[2].value, '1'); + assert.equal(second.children[2].value, '50'); + assert.equal(second.children[4].value, '10'); + assert.equal(recorder.status, 'idle'); + assert.equal(packages.get('draft').gpx, '[]'); + assert.match(second.children[6].textContent, /Speichere/); +}); + +test('manual location errors preserve coordinates and stale or duplicate requests cannot overwrite points', async () => { + const { context, recorder, nodes } = await setup(); + recorder.renderPoints('[{"lat":1,"lon":2}]'); + const row = nodes.get('editor-recorded-positions').children[0]; + context.Wegwichtel.Geo.current = async () => { throw new Error('denied'); }; + row.children[5].dispatchEvent(new Event('click')); await tick(); + assert.equal(row.children[2].value, '1'); + assert.match(row.children[6].textContent, /nicht gefunden/); + let finish; + let requests = 0; + context.Wegwichtel.Geo.current = () => { requests++; return new Promise(resolve => { finish = resolve; }); }; + row.children[5].dispatchEvent(new Event('click')); + row.children[5].dispatchEvent(new Event('click')); + assert.equal(requests, 1); + recorder.renderPoints('[]'); + finish({ lat: 51, lon: 11 }); await tick(); + assert.equal(row.children[2].value, '1'); + assert.equal(row.children[5].disabled, false); +});