Multiple improvements
This commit is contained in:
@@ -46,12 +46,17 @@ Die notwendigen Arbeiten stehen in
|
|||||||
Vor verbindlicher Verwendung sind insbesondere diese Platzhalter zu ersetzen:
|
Vor verbindlicher Verwendung sind insbesondere diese Platzhalter zu ersetzen:
|
||||||
|
|
||||||
- vollständiger Name und Anschrift des tatsächlichen Betreibers als Lizenznehmer;
|
- 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.
|
- 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
|
Außerdem müssen die Texte und das Zustimmungsverfahren zum tatsächlichen
|
||||||
Betriebsmodell der Schule passen und rechtlich geprüft werden. Die bereits
|
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
|
pauschale Rechtezuordnung zur Schule wurde durch die Trennung von Urheberschaft
|
||||||
und Nutzungsrechten ersetzt.
|
und Nutzungsrechten ersetzt.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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,
|
Die GPS-Aufzeichnung läuft nur innerhalb einer Bearbeitungssitzung. Schließen,
|
||||||
Abbrechen, Rückkehr zur Liste, Streckenwechsel und Verlassen der Seite beenden
|
Abbrechen, Rückkehr zur Liste, Streckenwechsel und Verlassen der Seite beenden
|
||||||
sie; ein Neustart übernimmt keinen aktiven Aufzeichnungszustand. Beim erneuten
|
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).
|
[GPX-Aufzeichnung.md](agents/GPX-Aufzeichnung.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.
|
||||||
@@ -7,7 +7,8 @@ Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung
|
|||||||
- vollständiger Name und Rechtsform der Betreiberin
|
- vollständiger Name und Rechtsform der Betreiberin
|
||||||
- vertretungsberechtigte Person
|
- vertretungsberechtigte Person
|
||||||
- ladungsfähige Anschrift
|
- ladungsfähige Anschrift
|
||||||
- E-Mail-Adresse und weiterer unmittelbarer Kontaktweg
|
- eingerichtete Kontaktadresse für Wegwichtel: info@wegwichtel.de
|
||||||
|
- weiterer unmittelbarer Kontaktweg
|
||||||
- inhaltlich verantwortliche Person
|
- inhaltlich verantwortliche Person
|
||||||
- gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben
|
- gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ Alle folgenden Befehle werden im Projektverzeichnis ausgeführt.
|
|||||||
|
|
||||||
## Architektur
|
## 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
|
nachträgliche Ergänzen fehlender Koordinaten sind in
|
||||||
[`GPX-Aufzeichnung.md`](agents/GPX-Aufzeichnung.md) beschrieben.
|
[`GPX-Aufzeichnung.md`](agents/GPX-Aufzeichnung.md) beschrieben.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
und Code-Kommentare. Code-Bezeichner, Dateiformate und Schnittstellen bleiben fachlich präzise.
|
||||||
- Prüfe neue Texte und angepasste Text-Erwartungen in den vorhandenen Tests.
|
- 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
|
## Texte und Übersetzungen
|
||||||
|
|
||||||
- Alle eigenen Bedienungstexte liegen zentral in der Klasse `WegwichtelTexts`
|
- 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.
|
- Deutsch und Englisch enthalten dieselben Schlüssel, Platzhalter und Pluralformen.
|
||||||
Texte in beiden Sprachen einfach und sinngemäß schreiben. Sprachwechsel aktualisieren feste Beschriftungen;
|
Texte in beiden Sprachen einfach und sinngemäß schreiben. Sprachwechsel aktualisieren feste Beschriftungen;
|
||||||
bereits erzeugte dynamische Ansichten bei Bedarf neu rendern.
|
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.
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
## Stationen und Weg getrennt speichern
|
## Stationen und Weg getrennt speichern
|
||||||
|
|
||||||
- Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`.
|
- Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`.
|
||||||
- Beim ersten Speichern eines neuen Entwurfs startet während der Bearbeitung eine Aufzeichnung:
|
- Der Schalter „Meinen Weg aufnehmen“ ist beim Start und beim Öffnen eines
|
||||||
sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage.
|
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
|
Schließen, Abbrechen, Rückkehr zur Streckenliste oder Wechsel der Strecke
|
||||||
beendet die Aufzeichnung. Nach einem Neustart ist sie inaktiv. Beim erneuten
|
beendet die Aufzeichnung und schaltet sie aus. Speichern innerhalb derselben
|
||||||
Öffnen eines lokalen Entwurfs zur Bearbeitung beginnt eine neue Sitzung.
|
Sitzung hebt Pause oder bewusstes Stoppen nicht auf.
|
||||||
Speichern innerhalb derselben Sitzung hebt Pause oder bewusstes Stoppen nicht auf.
|
|
||||||
- Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden.
|
- Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden.
|
||||||
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
|
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
|
||||||
- Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und,
|
- 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.
|
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
|
In der Streckenbearbeitung können unter „Orte auf dem Weg ergänzen“ fehlende
|
||||||
Positionen ergänzt, vorhandene geändert und weitere hinzugefügt werden.
|
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.
|
Breiten- und Längengrad müssen gemeinsam angegeben werden oder beide leer bleiben.
|
||||||
Stationen haben eigene Felder unter „Ort ergänzen“.
|
Stationen haben eigene Felder unter „Ort ergänzen“.
|
||||||
|
|
||||||
@@ -56,18 +59,34 @@ Coverage-Lauf geprüft.
|
|||||||
## Normalisierung auf dem Server
|
## Normalisierung auf dem Server
|
||||||
|
|
||||||
Beim Einreichen oder Ersetzen einer GPX-Datei normalisiert der Node-Dienst den
|
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
|
Aufeinanderfolgende Trackpunkte innerhalb des eingestellten Abstands werden
|
||||||
zusammengefasst. Der erste Punkt bleibt der Vertreter; der nächste wird erst
|
zusammengefasst. Der erste Punkt bleibt der Vertreter; der nächste wird erst
|
||||||
übernommen, wenn er weiter entfernt liegt. So werden kleine Schwankungen
|
übernommen, wenn er weiter entfernt liegt. So werden kleine Schwankungen
|
||||||
während einer Pause nicht fortlaufend zur Weglänge addiert.
|
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.
|
`GPX_CLUSTER_METERS` in `.env` steuert den Abstand, standardmäßig 20 Meter.
|
||||||
Das Verfahren ist eine geometrische Vereinfachung ohne externen Routingdienst.
|
Das Verfahren ist eine geometrische Vereinfachung ohne externen Routingdienst.
|
||||||
Es berechnet keine Wege auf einer Straßenkarte. GPS-Lücken werden bei der
|
Es berechnet keine Wege auf einer Straßenkarte. GPS-Lücken werden bei der
|
||||||
Routenberechnung übersprungen; zwischen gültigen Nachbarpunkten entsteht eine
|
Routenberechnung übersprungen; zwischen gültigen Nachbarpunkten entsteht eine
|
||||||
direkte Verbindung. Die Lücken bleiben für spätere Bearbeitung als Erweiterungen
|
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.
|
bei 0 Metern und 0 Punkten; Stationen bleiben trotzdem gespeichert.
|
||||||
|
|
||||||
Die gespeicherte Serverdatei enthält den normalisierten Weg. Änderungen an
|
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;
|
- zum nächsten Routenpunkt, ohne Entfernungsangabe;
|
||||||
- zur nächsten noch offenen Station mit Koordinaten, mit Metern.
|
- 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
|
Stationen ohne Koordinaten lösen keine Standort-Erkennung aus. Während einer
|
||||||
Pause werden die Pfeile verborgen.
|
Pause werden die Pfeile verborgen.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ Ausgangspunkt: [Recherche und umgesetzte Hinweise](../Bilder-und-Ton-Nutzungsrec
|
|||||||
Die statischen Rechtstexte und X-Copyright sind eingebunden; ein belegbares
|
Die statischen Rechtstexte und X-Copyright sind eingebunden; ein belegbares
|
||||||
Zustimmungs- und Namensnennungssystem ist noch nicht umgesetzt.
|
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
|
## 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.
|
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
|
2. Die relevante Vereinbarung vor dem Einsenden zugänglich machen und eine
|
||||||
nicht vorausgewählte, ausdrückliche Zustimmung einholen. Eine bloße
|
nicht vorausgewählte, ausdrückliche Zustimmung einholen. Eine bloße
|
||||||
|
|||||||
@@ -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.
|
||||||
+21
-5
@@ -465,12 +465,18 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid rgba(49, 93, 58, .18);
|
border: 1px solid rgba(49, 93, 58, .18);
|
||||||
border-radius: .9rem;
|
border-radius: .9rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-route-item > :first-child,
|
||||||
|
.editor-poi-item > :first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-route-item strong,
|
.editor-route-item strong,
|
||||||
.editor-poi-item strong {
|
.editor-poi-item strong {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -517,16 +523,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-icon-row {
|
.editor-icon-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 2.75rem;
|
||||||
gap: .35rem;
|
gap: .35rem;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-icon-button {
|
.editor-icon-button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
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 {
|
.editor-icon-danger {
|
||||||
@@ -577,3 +590,6 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-location-button { min-width: 44px; min-height: 44px; }
|
||||||
|
.current-location-button img { width: 24px; height: 24px; vertical-align: middle; }
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#244c34" stroke-width="2"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 1v5m0 12v5M1 12h5m12 0h5"/></svg>
|
||||||
|
After Width: | Height: | Size: 215 B |
+14
-4
@@ -319,6 +319,7 @@
|
|||||||
<p><span>Datenschutzangaben der Schule:</span><br>
|
<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>
|
<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>
|
<p>Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
|
||||||
|
<p>Für Fragen zu Wegwichtel, Datenschutzanfragen, Widerruf und Entfernung von Aufnahmen erreichst du die Betreiber unter <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a>.</p>
|
||||||
<h3>2. Wenn du Wegwichtel öffnest</h3>
|
<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>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>
|
<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>
|
||||||
@@ -348,7 +349,7 @@
|
|||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p><strong>[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.]</strong></p>
|
<p><strong>[Vor Veröffentlichung ergänzen: Rechtsgrundlagen für Einsendung, Prüfung, Veröffentlichung und Rechtenachweise; Empfänger und Hostinganbieter; Lösch- und Sicherungsfristen.]</strong></p>
|
||||||
<h3>9. Deine Rechte</h3>
|
<h3>9. 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>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>
|
<p><span>Zuständige Datenschutzaufsicht:</span><br>
|
||||||
@@ -361,6 +362,7 @@
|
|||||||
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span></p>
|
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span></p>
|
||||||
<h3>Kontakt</h3>
|
<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>
|
<p><span>Telefon: (036943) 63722</span><br><span>Telefax: (036943) 63843</span><br><span>E-Mail: info@grundschule-herpf.de</span></p>
|
||||||
|
<p>Kontakt für Wegwichtel: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></p>
|
||||||
<h3>Leitung der Schule</h3>
|
<h3>Leitung der Schule</h3>
|
||||||
<p>Frau Wichert, Schulleiterin</p>
|
<p>Frau Wichert, Schulleiterin</p>
|
||||||
<h3>Schulträger</h3>
|
<h3>Schulträger</h3>
|
||||||
@@ -369,7 +371,7 @@
|
|||||||
<h3>Inhaltlich verantwortlich</h3>
|
<h3>Inhaltlich verantwortlich</h3>
|
||||||
<p>Frau Wichert</p>
|
<p>Frau Wichert</p>
|
||||||
<h3>Technisch verantwortlich</h3>
|
<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>
|
<p>Florian Zumpe<br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></span></p>
|
||||||
<h3>Weitere Pflichtangaben</h3>
|
<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>
|
<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>
|
<h3>Haftung für Inhalte und Verweise</h3>
|
||||||
@@ -380,7 +382,7 @@
|
|||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="info-page" data-info-page="terms" lang="de" hidden>
|
<article class="info-page" data-info-page="terms" lang="de" hidden>
|
||||||
<p class="legal-warning"><strong>Entwurf:</strong> Diese Vereinbarung muss vor ihrer verbindlichen Verwendung rechtlich geprüft werden. Betreiber, Kontakt und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.</p>
|
<p class="legal-warning"><strong>Entwurf:</strong> Diese Vereinbarung muss vor ihrer verbindlichen Verwendung rechtlich geprüft werden. Betreiber und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.</p>
|
||||||
<p>Stand: 4. Oktober 2026</p>
|
<p>Stand: 4. Oktober 2026</p>
|
||||||
<h3>1. Für wen diese Vereinbarung gilt</h3>
|
<h3>1. Für wen diese Vereinbarung gilt</h3>
|
||||||
<p>Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber <strong>[vollständiger Name und Anschrift des tatsächlichen Betreibers ergänzen]</strong> eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.</p>
|
<p>Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber <strong>[vollständiger Name und Anschrift des tatsächlichen Betreibers ergänzen]</strong> eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.</p>
|
||||||
@@ -395,7 +397,7 @@
|
|||||||
<p>Du darfst nur Aufnahmen senden, die du selbst erstellt hast oder für die du alle erforderlichen Erlaubnisse besitzt, um diese Nutzungsrechte einzuräumen. Bei Tonaufnahmen sind auch die Rechte an Musik, gesprochenen Texten, Darbietungen und der Aufnahme selbst zu beachten. Die Erlaubnis zum privaten Anhören oder zum eigenen Gebrauch reicht für eine Veröffentlichung in Wegwichtel nicht aus.</p>
|
<p>Du darfst nur Aufnahmen senden, die du selbst erstellt hast oder für die du alle erforderlichen Erlaubnisse besitzt, um diese Nutzungsrechte einzuräumen. Bei Tonaufnahmen sind auch die Rechte an Musik, gesprochenen Texten, Darbietungen und der Aufnahme selbst zu beachten. Die Erlaubnis zum privaten Anhören oder zum eigenen Gebrauch reicht für eine Veröffentlichung in Wegwichtel nicht aus.</p>
|
||||||
<p>Auch die Rechte erkennbarer oder hörbarer Personen müssen beachtet werden. Eine Nutzungserlaubnis des Fotografen ersetzt nicht die erforderliche Einwilligung der abgebildeten Person. Kinder brauchen für die Rechteeinräumung die erforderliche Zustimmung ihrer gesetzlichen Vertreter. Sorgeberechtigte dürfen Rechte anderer Urheber nicht ohne deren Erlaubnis einräumen.</p>
|
<p>Auch die Rechte erkennbarer oder hörbarer Personen müssen beachtet werden. Eine Nutzungserlaubnis des Fotografen ersetzt nicht die erforderliche Einwilligung der abgebildeten Person. Kinder brauchen für die Rechteeinräumung die erforderliche Zustimmung ihrer gesetzlichen Vertreter. Sorgeberechtigte dürfen Rechte anderer Urheber nicht ohne deren Erlaubnis einräumen.</p>
|
||||||
<h3>5. Prüfung, Entfernung und Beschwerden</h3>
|
<h3>5. Prüfung, Entfernung und Beschwerden</h3>
|
||||||
<p>Die Betreiber sind nicht verpflichtet, eingesandte Aufnahmen zu veröffentlichen. Wenn Rechte unklar sind oder eine berechtigte Beanstandung vorliegt, dürfen sie die Veröffentlichung aussetzen oder die Aufnahme entfernen. Fragen zu Urheberschaft, Nutzungsrechten, Namensnennung oder Entfernung richtest du an <strong>[Kontakt für Rechtefragen ergänzen]</strong>. Datenschutzrechtliche Einwilligungen und Widerrufe werden getrennt von den urheberrechtlichen Nutzungsrechten geprüft.</p>
|
<p>Die Betreiber sind nicht verpflichtet, eingesandte Aufnahmen zu veröffentlichen. Wenn Rechte unklar sind oder eine berechtigte Beanstandung vorliegt, dürfen sie die Veröffentlichung aussetzen oder die Aufnahme entfernen. Fragen zu Urheberschaft, Nutzungsrechten, Namensnennung oder Entfernung richtest du an <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a>. Datenschutzrechtliche Einwilligungen und Widerrufe werden getrennt von den urheberrechtlichen Nutzungsrechten geprüft.</p>
|
||||||
<h3>6. Hinweis bei ausgelieferten Bildern</h3>
|
<h3>6. Hinweis bei ausgelieferten Bildern</h3>
|
||||||
<p>Der technische Rechtehinweis bei Bildern informiert über diese Nutzungsregeln. Er ersetzt weder die Urhebernennung bei der Aufnahme noch einen Nachweis über die tatsächlich erteilte Nutzungserlaubnis. Er verhindert auch nicht das Kopieren einer Aufnahme.</p>
|
<p>Der technische Rechtehinweis bei Bildern informiert über diese Nutzungsregeln. Er ersetzt weder die Urhebernennung bei der Aufnahme noch einen Nachweis über die tatsächlich erteilte Nutzungserlaubnis. Er verhindert auch nicht das Kopieren einer Aufnahme.</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -485,6 +487,14 @@
|
|||||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section id="editor-recording-options">
|
||||||
|
<label class="switch-row" for="route-recording-toggle">
|
||||||
|
<span data-i18n="recorder.toggle">Meinen Weg aufnehmen</span>
|
||||||
|
<input id="route-recording-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
</label>
|
||||||
|
<p class="editor-muted" data-i18n="recorder.toggle-help">Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.</p>
|
||||||
|
</section>
|
||||||
<details id="editor-path-positions">
|
<details id="editor-path-positions">
|
||||||
<summary data-i18n="recorder.edit-path">Orte auf dem Weg ergänzen</summary>
|
<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>
|
<p data-i18n="recorder.edit-help">Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.</p>
|
||||||
|
|||||||
+39
-1
@@ -22,6 +22,19 @@
|
|||||||
persistTimer: null
|
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) {
|
function escapeHtml(value) {
|
||||||
return $('<div>').text(value ?? '').html();
|
return $('<div>').text(value ?? '').html();
|
||||||
}
|
}
|
||||||
@@ -88,6 +101,7 @@
|
|||||||
const target = $(pageSelector);
|
const target = $(pageSelector);
|
||||||
if (!target.length) throw new Error(ns.I18n.t("app.page.missing"));
|
if (!target.length) throw new Error(ns.I18n.t("app.page.missing"));
|
||||||
|
|
||||||
|
if (pageSelector !== '#poi-page') releaseLocalPoiMedia();
|
||||||
const previous = state.activePage;
|
const previous = state.activePage;
|
||||||
$('.app-page').attr('hidden', true);
|
$('.app-page').attr('hidden', true);
|
||||||
target.removeAttr('hidden');
|
target.removeAttr('hidden');
|
||||||
@@ -287,6 +301,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function endRoute({ announce = true, returnToRoutes = false } = {}) {
|
function endRoute({ announce = true, returnToRoutes = false } = {}) {
|
||||||
|
releaseLocalPoiMedia();
|
||||||
const wasActive = state.routeState !== 'idle';
|
const wasActive = state.routeState !== 'idle';
|
||||||
state.routeState = 'idle';
|
state.routeState = 'idle';
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
@@ -330,6 +345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function suspendCurrentRoute() {
|
function suspendCurrentRoute() {
|
||||||
|
releaseLocalPoiMedia();
|
||||||
if (!state.route) return;
|
if (!state.route) return;
|
||||||
if (state.routeState === 'running') state.routeState = 'paused';
|
if (state.routeState === 'running') state.routeState = 'paused';
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
@@ -383,6 +399,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showPoi(poi) {
|
function showPoi(poi) {
|
||||||
|
releaseLocalPoiMedia();
|
||||||
state.activePoiId = Number(poi.id);
|
state.activePoiId = Number(poi.id);
|
||||||
state.viewed.add(state.activePoiId);
|
state.viewed.add(state.activePoiId);
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
@@ -392,6 +409,27 @@
|
|||||||
ns.Slideshow.show(poi.images);
|
ns.Slideshow.show(poi.images);
|
||||||
ns.AudioPlayer.load(poi.audioUrl);
|
ns.AudioPlayer.load(poi.audioUrl);
|
||||||
navigate('#poi-page');
|
navigate('#poi-page');
|
||||||
|
if (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) {
|
function nearestRoutePointIndex(position) {
|
||||||
@@ -680,7 +718,7 @@
|
|||||||
async function selectRoute(source, id) {
|
async function selectRoute(source, id) {
|
||||||
setRouteControlOpen(false);
|
setRouteControlOpen(false);
|
||||||
const route = source === 'local'
|
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 };
|
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||||
renderRoute(route);
|
renderRoute(route);
|
||||||
navigate('#route-page');
|
navigate('#route-page');
|
||||||
|
|||||||
+73
-24
@@ -202,7 +202,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return ns.I18n.t("editor.view-route");
|
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() {
|
function showListView() {
|
||||||
|
releasePoiPreview();
|
||||||
ns.RouteRecorder?.endEditing();
|
ns.RouteRecorder?.endEditing();
|
||||||
setDialogTitle('Streckenverwaltung');
|
setDialogTitle('Streckenverwaltung');
|
||||||
setHidden(byId(ids.routeListView), false);
|
setHidden(byId(ids.routeListView), false);
|
||||||
@@ -211,6 +221,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showRouteView() {
|
function showRouteView() {
|
||||||
|
releasePoiPreview();
|
||||||
setDialogTitle(routeDialogTitle());
|
setDialogTitle(routeDialogTitle());
|
||||||
setHidden(byId(ids.routeListView), true);
|
setHidden(byId(ids.routeListView), true);
|
||||||
setHidden(byId(ids.routeDetailView), false);
|
setHidden(byId(ids.routeDetailView), false);
|
||||||
@@ -411,8 +422,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
||||||
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
|
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.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() {
|
function renderPoiList() {
|
||||||
@@ -438,19 +449,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let routeListGeneration = 0;
|
||||||
|
|
||||||
async function loadRoutes() {
|
async function loadRoutes() {
|
||||||
setStatus(ns.I18n.t("editor.loading-routes"));
|
const requestGeneration = ++routeListGeneration;
|
||||||
const payload = await readJson('/routes?activatedOnly=true');
|
state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
|
||||||
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
|
|
||||||
let localRoutes = [];
|
|
||||||
try {
|
|
||||||
localRoutes = localRouteSummaries();
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error.message, true);
|
|
||||||
}
|
|
||||||
state.routes = [...serverRoutes, ...localRoutes];
|
|
||||||
renderRouteList();
|
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() {
|
async function openEditor() {
|
||||||
@@ -471,6 +493,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeEditor() {
|
function closeEditor() {
|
||||||
|
releasePoiPreview();
|
||||||
ns.RouteRecorder?.endEditing();
|
ns.RouteRecorder?.endEditing();
|
||||||
const dialog = byId(ids.dialog);
|
const dialog = byId(ids.dialog);
|
||||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||||
@@ -499,6 +522,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function fillRouteForm(route) {
|
function fillRouteForm(route) {
|
||||||
setHidden(byId('editor-path-positions'), state.currentSource !== 'local');
|
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));
|
if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name));
|
||||||
byId(ids.routeId).value = routeId(route);
|
byId(ids.routeId).value = routeId(route);
|
||||||
byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(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) {
|
async function loadRoute(route) {
|
||||||
const id = routeId(route);
|
const id = routeId(route);
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
|
if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) {
|
||||||
ns.RouteRecorder.endEditing();
|
ns.RouteRecorder?.endEditing();
|
||||||
}
|
}
|
||||||
setStatus(ns.I18n.t("editor.route.loading"));
|
setStatus(ns.I18n.t("editor.route.loading"));
|
||||||
state.currentSource = routeSource(route);
|
state.currentSource = routeSource(route);
|
||||||
|
|
||||||
if (state.currentSource === 'local') {
|
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"));
|
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
|
||||||
state.currentRoute = {
|
state.currentRoute = {
|
||||||
...routePackage.route,
|
...routePackage.route,
|
||||||
@@ -1048,6 +1072,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function newPoi() {
|
function newPoi() {
|
||||||
|
releasePoiPreview();
|
||||||
const position = currentPosition();
|
const position = currentPosition();
|
||||||
const lat = position?.lat ?? null;
|
const lat = position?.lat ?? null;
|
||||||
const lon = position?.lon ?? null;
|
const lon = position?.lon ?? null;
|
||||||
@@ -1065,11 +1090,26 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
showPoiView();
|
showPoiView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editPoi(poi) {
|
async function editPoi(poi) {
|
||||||
|
if (!poi) return;
|
||||||
|
releasePoiPreview();
|
||||||
state.currentPoi = poi;
|
state.currentPoi = poi;
|
||||||
fillPoiForm(poi);
|
fillPoiForm(poi);
|
||||||
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
|
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
|
||||||
showPoiView();
|
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) {
|
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||||
@@ -1122,13 +1162,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
route: state.currentRoute,
|
route: state.currentRoute,
|
||||||
pois: state.pois
|
pois: state.pois
|
||||||
});
|
});
|
||||||
const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
|
const previousPoi = state.currentPoi;
|
||||||
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
|
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||||
state.pois = hydrated.pois;
|
state.pois = saved.pois;
|
||||||
state.currentPoi = currentPoiId
|
state.currentPoi = currentPoiId
|
||||||
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
|
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
|
||||||
: null;
|
: null;
|
||||||
return hydrated;
|
if (state.currentPoi && previousPoi) {
|
||||||
|
state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images };
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLocalPoi(values) {
|
async function saveLocalPoi(values) {
|
||||||
@@ -1241,7 +1284,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
updateCurrentPoi(updated);
|
updateCurrentPoi(updated);
|
||||||
await persistLocalCurrentRoute();
|
await persistLocalCurrentRoute();
|
||||||
if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
|
if (removedMedia) {
|
||||||
|
ns.LocalRoutes.releasePoiMedia({ audio: removedMedia });
|
||||||
|
await ns.LocalRoutes.removeMedia(removedMedia);
|
||||||
|
}
|
||||||
state.selectedMediaKey = null;
|
state.selectedMediaKey = null;
|
||||||
renderPoiMediaPreview();
|
renderPoiMediaPreview();
|
||||||
updatePoiMediaStatus();
|
updatePoiMediaStatus();
|
||||||
@@ -1416,7 +1462,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
async function poiListAction(event) {
|
async function poiListAction(event) {
|
||||||
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
|
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
|
||||||
if (action === 'edit-poi') {
|
if (action === 'edit-poi') {
|
||||||
editPoi(poiForItem(event.target));
|
await editPoi(poiForItem(event.target));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'delete-poi') {
|
if (action === 'delete-poi') {
|
||||||
@@ -1442,12 +1488,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
for (const eventName of ['cancel', 'close']) {
|
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.button), () => guarded(openEditor));
|
||||||
activate(byId(ids.close), closeEditor);
|
activate(byId(ids.close), closeEditor);
|
||||||
activate(byId(ids.newRoute), newRoute);
|
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.routeUpload), () => guarded(uploadLocalRoute));
|
||||||
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
|
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
|
||||||
activate(byId(ids.addPoi), newPoi);
|
activate(byId(ids.addPoi), newPoi);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const MEDIA_STORE_NAME = 'media';
|
const MEDIA_STORE_NAME = 'media';
|
||||||
let mediaDatabasePromise = null;
|
let mediaDatabasePromise = null;
|
||||||
let poiCounter = 0;
|
let poiCounter = 0;
|
||||||
|
const objectUrls = new Set();
|
||||||
|
|
||||||
function storage() {
|
function storage() {
|
||||||
const target = globalThis.localStorage;
|
const target = globalThis.localStorage;
|
||||||
@@ -195,7 +196,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const blob = await mediaBlob(media, options);
|
const blob = await mediaBlob(media, options);
|
||||||
if (!blob) return { ...media, unavailable: true };
|
if (!blob) return { ...media, unavailable: true };
|
||||||
if (typeof globalThis.URL?.createObjectURL === 'function') {
|
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);
|
const fallback = await fileToDataUrl(blob);
|
||||||
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
|
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
|
||||||
@@ -204,16 +207,39 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withMedia(routePackage, options = {}) {
|
async function poiWithMedia(poi, options = {}) {
|
||||||
if (!routePackage) return null;
|
if (!poi) return null;
|
||||||
const pois = await Promise.all((routePackage.pois || []).map(async poi => ({
|
return {
|
||||||
...poi,
|
...poi,
|
||||||
audio: await mediaForUse(poi.audio, options),
|
audio: await mediaForUse(poi.audio, options),
|
||||||
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, 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 };
|
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) {
|
function mediaForStorage(media) {
|
||||||
if (!media) return null;
|
if (!media) return null;
|
||||||
const stored = { ...media };
|
const stored = { ...media };
|
||||||
@@ -709,8 +735,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return listPackages().map(toAppRoute);
|
return listPackages().map(toAppRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAppRoute(id, options = {}) {
|
async function getAppRoute(id) {
|
||||||
const routePackage = await withMedia(readPackage(id), options);
|
const routePackage = readPackage(id);
|
||||||
if (!routePackage) throw new Error(ns.I18n.t("local.error.route-missing"));
|
if (!routePackage) throw new Error(ns.I18n.t("local.error.route-missing"));
|
||||||
return toAppRoute(routePackage);
|
return toAppRoute(routePackage);
|
||||||
}
|
}
|
||||||
@@ -762,6 +788,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
get: readPackage,
|
get: readPackage,
|
||||||
getAppRoute,
|
getAppRoute,
|
||||||
getWithMedia,
|
getWithMedia,
|
||||||
|
getPoiWithMedia,
|
||||||
|
getAppPoi,
|
||||||
|
releasePoiMedia,
|
||||||
gpxForUpload,
|
gpxForUpload,
|
||||||
list: listPackages,
|
list: listPackages,
|
||||||
listAppRoutes,
|
listAppRoutes,
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
globalThis.Wegwichtel ??= {};
|
globalThis.Wegwichtel ??= {};
|
||||||
(function (ns) {
|
(function (ns) {
|
||||||
'use strict';
|
'use strict';
|
||||||
const INTERVAL_MS = 30000;
|
const INTERVAL_MS = 120000;
|
||||||
let id = null;
|
let id = null;
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
let status = 'idle';
|
let status = 'idle';
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let pending = false;
|
let pendingGeneration = null;
|
||||||
|
let enabled = false;
|
||||||
|
let manualGeneration = 0;
|
||||||
let message = '';
|
let message = '';
|
||||||
let manualFields = [];
|
let manualFields = [];
|
||||||
let lastPosition = null;
|
let lastPosition = null;
|
||||||
|
|
||||||
function render() {
|
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');
|
const control = document.getElementById('route-recording-control');
|
||||||
if (!control) return;
|
if (!control) return;
|
||||||
control.hidden = status === 'idle';
|
control.hidden = status === 'idle';
|
||||||
@@ -21,9 +25,9 @@ globalThis.Wegwichtel ??= {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sample() {
|
async function sample() {
|
||||||
if (pending || status !== 'recording') return;
|
if (pendingGeneration === generation || status !== 'recording') return;
|
||||||
pending = true;
|
|
||||||
const currentGeneration = generation;
|
const currentGeneration = generation;
|
||||||
|
pendingGeneration = currentGeneration;
|
||||||
try {
|
try {
|
||||||
let position;
|
let position;
|
||||||
try { position = await ns.Geo.current({ maximumAge: 0 }); }
|
try { position = await ns.Geo.current({ maximumAge: 0 }); }
|
||||||
@@ -40,7 +44,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
ns.StorageUsage?.reportError(error);
|
ns.StorageUsage?.reportError(error);
|
||||||
message = ns.I18n.t('recorder.save-error');
|
message = ns.I18n.t('recorder.save-error');
|
||||||
} finally {
|
} finally {
|
||||||
pending = false;
|
if (pendingGeneration === currentGeneration) pendingGeneration = null;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
|
enabled = false;
|
||||||
clearTimer();
|
clearTimer();
|
||||||
id = null;
|
id = null;
|
||||||
lastPosition = null;
|
lastPosition = null;
|
||||||
@@ -78,6 +83,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function start(routeId) {
|
function start(routeId) {
|
||||||
|
enabled = true;
|
||||||
lastPosition = null;
|
lastPosition = null;
|
||||||
id = String(routeId);
|
id = String(routeId);
|
||||||
editingId = id;
|
editingId = id;
|
||||||
@@ -85,16 +91,46 @@ globalThis.Wegwichtel ??= {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginEditing(routeId) {
|
function beginEditing(routeId) {
|
||||||
if (editingId === String(routeId)) return;
|
const nextId = String(routeId);
|
||||||
stop();
|
if (editingId === nextId) return;
|
||||||
start(routeId);
|
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() {
|
function endEditing() {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
|
manualGeneration += 1;
|
||||||
stop();
|
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) {
|
function addPositionField(position = {}, index = null) {
|
||||||
const container = document.getElementById('editor-recorded-positions');
|
const container = document.getElementById('editor-recorded-positions');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -114,13 +150,30 @@ globalThis.Wegwichtel ??= {};
|
|||||||
row.appendChild(label); row.appendChild(input);
|
row.appendChild(label); row.appendChild(input);
|
||||||
fields[key] = 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);
|
container.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPoints(gpx) {
|
function renderPoints(gpx) {
|
||||||
const container = document.getElementById('editor-recorded-positions');
|
const container = document.getElementById('editor-recorded-positions');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
manualGeneration += 1;
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
manualFields = [];
|
manualFields = [];
|
||||||
ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField);
|
ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField);
|
||||||
@@ -146,6 +199,10 @@ globalThis.Wegwichtel ??= {};
|
|||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
globalThis.addEventListener?.('pagehide', endEditing);
|
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 activate = (elementId, action) => {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
element?.addEventListener('pointerup', event => {
|
element?.addEventListener('pointerup', event => {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
"drafts": "Meine Entwürfe",
|
"drafts": "Meine Entwürfe",
|
||||||
"no-draft": "Noch kein eigener Entwurf vorhanden.",
|
"no-draft": "Noch kein eigener Entwurf vorhanden.",
|
||||||
"no-stations": "Noch keine Stationen angelegt.",
|
"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 …",
|
"loading-routes": "Strecken werden geladen …",
|
||||||
"submitted": "Erneut zur Prüfung senden",
|
"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.",
|
"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": {
|
"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.",
|
"recording": "Dein Weg wird aufgenommen.",
|
||||||
"paused": "Die Aufnahme macht eine Pause.",
|
"paused": "Die Aufnahme macht eine Pause.",
|
||||||
"pause": "Aufnahme pausieren",
|
"pause": "Aufnahme pausieren",
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
"drafts": "My drafts",
|
"drafts": "My drafts",
|
||||||
"no-draft": "There are no drafts yet.",
|
"no-draft": "There are no drafts yet.",
|
||||||
"no-stations": "There are no stops 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 …",
|
"loading-routes": "Loading trails …",
|
||||||
"submitted": "Send changes for review",
|
"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.",
|
"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": {
|
"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.",
|
"recording": "Your trail is being recorded.",
|
||||||
"paused": "Recording is paused.",
|
"paused": "Recording is paused.",
|
||||||
"pause": "Pause recording",
|
"pause": "Pause recording",
|
||||||
|
|||||||
+53
-6
@@ -3,6 +3,8 @@ import { HttpError } from '../middleware/errors.js';
|
|||||||
import { distanceMeters, routeMetrics } from './geo.js';
|
import { distanceMeters, routeMetrics } from './geo.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const WAYPOINT_DERIVED = Symbol('waypointDerived');
|
||||||
|
|
||||||
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
|
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
|
||||||
const parser = new XMLParser(xmlOptions);
|
const parser = new XMLParser(xmlOptions);
|
||||||
const builder = new XMLBuilder({ ...xmlOptions, format: true });
|
const builder = new XMLBuilder({ ...xmlOptions, format: true });
|
||||||
@@ -20,6 +22,7 @@ function parseXml(xml) {
|
|||||||
function toRoutePoint(point, sequence) {
|
function toRoutePoint(point, sequence) {
|
||||||
return {
|
return {
|
||||||
sequence,
|
sequence,
|
||||||
|
...(point.extensions?.['wegwichtel:waypointRoutePoint'] === true ? { [WAYPOINT_DERIVED]: true } : {}),
|
||||||
lat: Number.parseFloat(point['@_lat']),
|
lat: Number.parseFloat(point['@_lat']),
|
||||||
lon: Number.parseFloat(point['@_lon']),
|
lon: Number.parseFloat(point['@_lon']),
|
||||||
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
|
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 }));
|
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 } = {}) {
|
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
|
||||||
const gpx = parseXml(xml)?.gpx;
|
const gpx = parseXml(xml)?.gpx;
|
||||||
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
|
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 routes = asList(gpx.rte);
|
||||||
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
|
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
|
||||||
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
|
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 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.');
|
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 = points.length ? routeMetrics(points) : {
|
||||||
const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
|
|
||||||
startLat: null, startLon: null, centerLat: null, centerLon: null,
|
startLat: null, startLon: null, centerLat: null, centerLon: null,
|
||||||
minLat: null, minLon: null, maxLat: null, maxLon: null,
|
minLat: null, minLon: null, maxLat: null, maxLon: null,
|
||||||
distanceM: 0, elevationGainM: 0, pointCount: 0
|
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;
|
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
|
||||||
delete gpx.trk;
|
delete gpx.trk;
|
||||||
delete gpx.rte;
|
delete gpx.rte;
|
||||||
@@ -66,11 +112,12 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
|
|||||||
const { extensions, ...base } = gpx;
|
const { extensions, ...base } = gpx;
|
||||||
// GPX 1.1 namespace identifier: this URI is written into XML, never fetched.
|
// 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.
|
// 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 => ({
|
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
|
||||||
'@_lat': point.lat, '@_lon': point.lon,
|
'@_lat': point.lat, '@_lon': point.lon,
|
||||||
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
|
...(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;
|
if (extensions) normalized.extensions = extensions;
|
||||||
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
|
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
|
||||||
|
|||||||
@@ -246,7 +246,10 @@ export function appendRoute(db, id, gpxFile) {
|
|||||||
|
|
||||||
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||||
const current = readRouteGpx(existing).points;
|
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 target = resolveStoredFile(existing.gpx_path);
|
||||||
const original = fs.readFileSync(target, 'utf8');
|
const original = fs.readFileSync(target, 'utf8');
|
||||||
const normalized = parseGpx(original, allPoints);
|
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 row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
|
||||||
const filename = resolveStoredFile(row.gpx_path);
|
const filename = resolveStoredFile(row.gpx_path);
|
||||||
const xml = fs.readFileSync(filename, 'utf8');
|
const xml = fs.readFileSync(filename, 'utf8');
|
||||||
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
|
const normalized = parseGpx(gpxWithWaypoints(xml, listPois(db, routeId)), null, { allowEmpty: true });
|
||||||
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
|
fs.writeFileSync(filename, normalized.normalizedXml);
|
||||||
|
updateBounds(db, routeId, normalized.metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
function poiCoordinates(fields, existing = {}) {
|
function poiCoordinates(fields, existing = {}) {
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ async function loadApp({ language = 'de-DE' } = {}) {
|
|||||||
stop() {}
|
stop() {}
|
||||||
},
|
},
|
||||||
LocalRoutes: {
|
LocalRoutes: {
|
||||||
|
async getAppPoi(_id, poiId) { return structuredClone(localRoute?.pois.find(poi => Number(poi.id) === Number(poiId)) || null); },
|
||||||
|
releasePoiMedia() {},
|
||||||
getAppRoute(id) {
|
getAppRoute(id) {
|
||||||
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
|
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
|
||||||
return structuredClone(localRoute);
|
return structuredClone(localRoute);
|
||||||
@@ -640,3 +642,43 @@ test('invalid API responses reject initialization and route selection errors res
|
|||||||
assert.deepEqual(errors, ['Strecke entfernt']);
|
assert.deepEqual(errors, ['Strecke entfernt']);
|
||||||
assert.equal(button.properties.get('disabled'), false);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
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);
|
assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null);
|
||||||
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 });
|
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);
|
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);
|
routes.deletePoi(db, indoor.id, indoorStation.id);
|
||||||
assert.equal(routes.getRoute(db, indoor.id).pointCount, 0);
|
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);
|
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null);
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ function buildEditorContext() {
|
|||||||
type: file.type || 'application/octet-stream'
|
type: file.type || 'application/octet-stream'
|
||||||
}),
|
}),
|
||||||
get: id => packages.get(String(id)) || null,
|
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 => {
|
getWithMedia: async id => {
|
||||||
const value = packages.get(String(id));
|
const value = packages.get(String(id));
|
||||||
if (!value) return null;
|
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();
|
await context.Wegwichtel.EditorUi.open();
|
||||||
|
|
||||||
const routeList = elements.get('editor-route-list');
|
const routeList = elements.get('editor-route-list');
|
||||||
|
await tick();
|
||||||
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
||||||
const serverActions = serverItem.children[1];
|
const serverActions = serverItem.children[1];
|
||||||
assert.equal(serverActions.children.length, 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.handle, handle);
|
||||||
assert.equal(imageStore.file, imageFile);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
+71
-1
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
|
||||||
|
|
||||||
test('parseGpx reads synthetic track data without repository example files', () => {
|
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[0].lat, 52.5200);
|
||||||
assert.equal(parsed.points[2].lon, 13.4090);
|
assert.equal(parsed.points[2].lon, 13.4090);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const point = (tag, lat, lon, content = '') => `<${tag} lat="${lat}" lon="${lon}">${content}</${tag}>`;
|
||||||
|
const gpx = (waypoints, path = '', extra = '') => `<gpx version="1.1" creator="Test" xmlns:wegwichtel="urn:wegwichtel:poi">${waypoints}${path}${extra}</gpx>`;
|
||||||
|
const track = points => `<trk><trkseg>${points}</trkseg></trk>`;
|
||||||
|
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, /<wpt\b/);
|
||||||
|
assert.match(parsed.normalizedXml, /<rtept\b/);
|
||||||
|
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track points and stations both shape the path without dropping nearby station anchors', () => {
|
||||||
|
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 = `<rte>${point('rtept', 50, 10)}${point('rtept', 50, 10.01)}</rte>`;
|
||||||
|
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 = '<wpt><name>Ohne Ort</name></wpt>' + point('wpt', 91, 10) + point('wpt', 50, 181) + point('wpt', 50, 10);
|
||||||
|
const extra = '<extensions><wegwichtel:gpsGap time="2026-10-04T10:00:00Z"/><wegwichtel:pendingWaypoint><name>Später</name></wegwichtel:pendingWaypoint></extensions>';
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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, /data-info-target="technology"/);
|
||||||
assert.match(html, /<h3>Technisch verantwortlich<\/h3>\s*<p>Florian Zumpe<br>/);
|
assert.match(html, /<h3>Technisch verantwortlich<\/h3>\s*<p>Florian Zumpe<br>/);
|
||||||
assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/);
|
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: <a href="mailto:info@wegwichtel\.de">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');
|
const storage = await read('public/js/privacy-storage.js');
|
||||||
assert.match(storage, /wegwichtel\.privacy\.v1/);
|
assert.match(storage, /wegwichtel\.privacy\.v1/);
|
||||||
assert.match(storage, /wegwichtel\.route-progress\.v1/);
|
assert.match(storage, /wegwichtel\.route-progress\.v1/);
|
||||||
|
|||||||
@@ -730,3 +730,35 @@ test('creator identity never falls back to insecure randomness or replaces corru
|
|||||||
assert.throws(() => api.creatorId(), /Gerätekennung/);
|
assert.throws(() => api.creatorId(), /Gerätekennung/);
|
||||||
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async function setup() {
|
|||||||
appendChild(child) { this.children.push(child); }
|
appendChild(child) { this.children.push(child); }
|
||||||
replaceChildren() { this.children = []; }
|
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 nodes = new Map(ids.map(id => [id, new Element()]));
|
||||||
const document = new EventTarget();
|
const document = new EventTarget();
|
||||||
document.readyState = 'complete';
|
document.readyState = 'complete';
|
||||||
@@ -23,7 +23,7 @@ async function setup() {
|
|||||||
const intervals = new Map();
|
const intervals = new Map();
|
||||||
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
|
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
|
||||||
const context = { document, Date, console,
|
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); },
|
clearInterval(id) { intervals.delete(id); },
|
||||||
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
|
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
|
||||||
LocalRoutes: {
|
LocalRoutes: {
|
||||||
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
|
|||||||
node.dispatchEvent(event);
|
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();
|
const { recorder, packages, intervals, nodes } = await setup();
|
||||||
recorder.start('draft'); await tick();
|
recorder.start('draft'); await tick();
|
||||||
assert.equal(recorder.status, 'recording');
|
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 () => {
|
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');
|
assert.equal(recorder.status, 'idle');
|
||||||
recorder.beginEditing('draft');
|
recorder.beginEditing('draft');
|
||||||
await tick();
|
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);
|
assert.equal(intervals.size, 1);
|
||||||
recorder.pause();
|
recorder.pause();
|
||||||
recorder.beginEditing('draft');
|
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);
|
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
|
||||||
recorder.beginEditing('draft');
|
recorder.beginEditing('draft');
|
||||||
await tick();
|
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();
|
const fresh = await setup();
|
||||||
assert.equal(fresh.recorder.status, 'idle');
|
assert.equal(fresh.recorder.status, 'idle');
|
||||||
assert.equal(fresh.intervals.size, 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user