update editor and add tests
This commit is contained in:
+206
-31
@@ -1,27 +1,178 @@
|
||||
.editor-entry-panel {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid rgba(49, 93, 58, .22);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 .5rem 1.4rem rgba(20, 40, 24, .08);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 1.4rem;
|
||||
padding: 1rem;
|
||||
.route-control {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top) + .45rem);
|
||||
right: calc(max(.65rem, env(safe-area-inset-right)) + var(--icon-button-size) + .5rem);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.editor-entry-panel h2,
|
||||
.editor-entry-panel p {
|
||||
.route-control-button,
|
||||
.route-control-button.ui-button {
|
||||
display: grid;
|
||||
width: min(13rem, calc(100vw - var(--icon-button-size) - 5.2rem));
|
||||
min-width: 0;
|
||||
min-height: var(--icon-button-size);
|
||||
box-sizing: border-box;
|
||||
gap: .08rem;
|
||||
margin: 0;
|
||||
padding: .35rem .75rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, .9);
|
||||
border-radius: 999px;
|
||||
background: #24462b;
|
||||
color: #fff;
|
||||
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .28);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.editor-entry-panel p {
|
||||
color: #4e604f;
|
||||
margin-top: .25rem;
|
||||
.route-control-button strong,
|
||||
.route-control-button small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-control-button strong {
|
||||
font-size: .9rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.route-control-button small {
|
||||
color: rgba(255, 255, 255, .86);
|
||||
font-size: .68rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.route-control-button:focus-visible,
|
||||
.route-control-button.ui-button:focus-visible {
|
||||
outline: .2rem solid #fff;
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.route-control-dropdown {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-button-size) + .45rem);
|
||||
right: 0;
|
||||
width: min(24rem, calc(100vw - 1.3rem));
|
||||
max-height: min(70dvh, 36rem);
|
||||
box-sizing: border-box;
|
||||
padding: .8rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid rgba(49, 93, 58, .28);
|
||||
border-radius: 1rem;
|
||||
background: #f8f5eb;
|
||||
color: #233326;
|
||||
box-shadow: 0 .8rem 2rem rgba(20, 32, 22, .28);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.route-control-dropdown section + section {
|
||||
margin-top: .85rem;
|
||||
padding-top: .75rem;
|
||||
border-top: 1px solid rgba(49, 93, 58, .18);
|
||||
}
|
||||
|
||||
.route-control-dropdown h2 {
|
||||
margin: 0 0 .45rem;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.route-control-list {
|
||||
display: grid;
|
||||
gap: .4rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.route-control-list li > span {
|
||||
display: block;
|
||||
padding: .6rem .7rem;
|
||||
border-radius: .65rem;
|
||||
background: #fff;
|
||||
color: #607160;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.route-control-list button,
|
||||
.route-control-list button.ui-button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 2.8rem;
|
||||
box-sizing: border-box;
|
||||
gap: .12rem;
|
||||
margin: 0;
|
||||
padding: .55rem .7rem;
|
||||
border: 1px solid rgba(49, 93, 58, .25);
|
||||
border-radius: .7rem;
|
||||
background: #fff;
|
||||
color: #233326;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.route-control-list button strong,
|
||||
.route-control-list button small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-control-list button small {
|
||||
color: #607160;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.route-control-list button[aria-current="true"] {
|
||||
border-color: #315d3a;
|
||||
background: #e7f1e9;
|
||||
box-shadow: inset .25rem 0 0 #315d3a;
|
||||
}
|
||||
|
||||
.route-control-list button:focus-visible,
|
||||
.route-control-list button.ui-button:focus-visible {
|
||||
outline: .2rem solid #1e6fba;
|
||||
outline-offset: .1rem;
|
||||
}
|
||||
|
||||
.route-control-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: .5rem;
|
||||
margin-top: .85rem;
|
||||
padding-top: .75rem;
|
||||
border-top: 1px solid rgba(49, 93, 58, .18);
|
||||
}
|
||||
|
||||
.editor-button,
|
||||
.editor-primary-button,
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
border-radius: .75rem;
|
||||
font: inherit;
|
||||
min-height: 2.4rem;
|
||||
padding: .55rem .85rem;
|
||||
}
|
||||
|
||||
.editor-button,
|
||||
.editor-primary-button {
|
||||
background: #315d3a;
|
||||
border: 1px solid #315d3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editor-button {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-round-button {
|
||||
align-items: center;
|
||||
background: #315d3a;
|
||||
@@ -128,6 +279,18 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-route-group {
|
||||
display: grid;
|
||||
gap: .45rem;
|
||||
margin: .2rem 0;
|
||||
}
|
||||
|
||||
.editor-route-group > h4 {
|
||||
margin: .45rem 0 0;
|
||||
color: #315d3a;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.editor-poi-list {
|
||||
counter-reset: editor-poi;
|
||||
}
|
||||
@@ -183,21 +346,6 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editor-primary-button,
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
border-radius: .75rem;
|
||||
font: inherit;
|
||||
min-height: 2.4rem;
|
||||
padding: .55rem .85rem;
|
||||
}
|
||||
|
||||
.editor-primary-button {
|
||||
background: #315d3a;
|
||||
border: 1px solid #315d3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
background: #fff;
|
||||
@@ -228,6 +376,33 @@
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.route-control {
|
||||
right: calc(max(.5rem, env(safe-area-inset-right)) + var(--icon-button-size) + .35rem);
|
||||
}
|
||||
|
||||
.route-control-button,
|
||||
.route-control-button.ui-button {
|
||||
width: min(10.5rem, calc(100vw - var(--icon-button-size) - 4.7rem));
|
||||
padding-right: .55rem;
|
||||
padding-left: .55rem;
|
||||
}
|
||||
|
||||
.route-control-dropdown {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top) + var(--icon-button-size) + .9rem);
|
||||
right: max(.65rem, env(safe-area-inset-right));
|
||||
left: max(.65rem, env(safe-area-inset-left));
|
||||
width: auto;
|
||||
max-height: calc(100dvh - env(safe-area-inset-top) - 5.4rem);
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 42rem) {
|
||||
.editor-coordinate-grid {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
|
||||
+48
-45
@@ -26,7 +26,6 @@
|
||||
<span aria-hidden="true">🧭</span>
|
||||
<h1>Wegwichtel</h1>
|
||||
<p>Der Wegwichtel bereitet Routen und Medien vor.</p>
|
||||
|
||||
<div class="privacy-summary">
|
||||
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
||||
<label class="switch-row switch-disabled">
|
||||
@@ -47,7 +46,6 @@
|
||||
</label>
|
||||
</div>
|
||||
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
||||
|
||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||
<ol id="bootstrap-steps">
|
||||
<li data-step="jquery">jQuery laden</li>
|
||||
@@ -64,11 +62,30 @@
|
||||
</section>
|
||||
</div>
|
||||
<main id="app-shell" hidden>
|
||||
<div class="route-control">
|
||||
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown">
|
||||
<strong id="route-control-title">Route wählen</strong>
|
||||
<small id="route-control-next">Aktive und lokale Strecken</small>
|
||||
</button>
|
||||
<nav id="route-control-dropdown" class="route-control-dropdown" aria-label="Streckenauswahl und Streckenverwaltung" hidden>
|
||||
<section aria-labelledby="route-control-server-title">
|
||||
<h2 id="route-control-server-title">Aktive Strecken</h2>
|
||||
<ul id="route-control-server-list" class="route-control-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
<section aria-labelledby="route-control-local-title">
|
||||
<h2 id="route-control-local-title">Lokale Strecken</h2>
|
||||
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
<div class="route-control-actions">
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckenverwaltung öffnen" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button>
|
||||
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="routes-page" class="app-page">
|
||||
<header>
|
||||
<h1>Wegwichtel</h1>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<p id="location-hint" class="notice">Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.</p>
|
||||
|
||||
@@ -81,7 +98,6 @@
|
||||
<section>
|
||||
<h2>Alle Routen</h2>
|
||||
<label for="route-search">Routen durchsuchen</label>
|
||||
|
||||
<div class="search-row">
|
||||
<input
|
||||
id="route-search"
|
||||
@@ -96,15 +112,12 @@
|
||||
<li><p>Routen werden geladen …</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="editor-entry-panel" aria-labelledby="editor-entry-title">
|
||||
<div>
|
||||
<h2 id="editor-entry-title">Strecken verwalten</h2>
|
||||
<p>Strecken, Stationen, Bilder und Audiodateien bearbeiten.</p>
|
||||
</div>
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckeneditor öffnen" title="Streckeneditor öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
<section aria-labelledby="local-route-title">
|
||||
<h2 id="local-route-title">Lokale Strecken</h2>
|
||||
<p>Diese Entwürfe bestehen aus GPX- und JSON-Daten und sind ausschließlich in diesem Browser gespeichert.</p>
|
||||
<ul id="local-route-list" class="route-list" aria-live="polite">
|
||||
<li><p>Lokale Strecken werden geladen …</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -115,7 +128,6 @@
|
||||
</button>
|
||||
<h1 id="route-title">Route</h1>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<p id="route-description"></p>
|
||||
<dl>
|
||||
@@ -140,7 +152,6 @@
|
||||
<ul id="poi-list" class="route-list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="poi-page" class="app-page" hidden>
|
||||
<header class="has-back-button">
|
||||
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route">
|
||||
@@ -225,11 +236,14 @@
|
||||
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
<h3>5. Cookies und Tracking</h3>
|
||||
<h3>5. Lokale Streckenentwürfe</h3>
|
||||
<p>Neu angelegte Strecken werden zunächst ausschließlich im Local Storage dieses Browsers gespeichert. Die GPX-Daten enthalten den Streckenverlauf und die POI-Koordinaten. Ergänzende JSON-Daten ordnen den POIs Texte, Audiodateien und Bilder zu. Medien werden dafür als lokale Browserdaten gespeichert.</p>
|
||||
<p>Lokale Streckenentwürfe sind bewusst von der freiwilligen Fortschrittsspeicherung getrennt. Sie werden nicht durch die 24-Stunden-Bereinigung entfernt und beim Ausschalten der Fortschrittsspeicherung nicht gelöscht. Sie verbleiben im Browser, bis sie ausdrücklich gelöscht werden oder die Browserdaten entfernt werden. Eine Übertragung an den Server findet derzeit nicht statt.</p>
|
||||
<h3>6. Cookies und Tracking</h3>
|
||||
<p>Die Anwendung setzt derzeit keine Cookies. Sie verwendet keine Reichweitenmessung, keine personalisierte Werbung und keine Trackingdienste. Eine technisch notwendige Local-Storage-Einstellung merkt sich lediglich, ob die freiwillige Speicherung des Streckenfortschritts aktiviert wurde.</p>
|
||||
<h3>6. Inhalte von Schülerinnen und Schülern</h3>
|
||||
<h3>7. Inhalte von Schülerinnen und Schülern</h3>
|
||||
<p>Fotos, Texte und Sprachaufnahmen dürfen nur veröffentlicht werden, wenn die erforderlichen Rechte und Einwilligungen vorliegen. Besonders bei erkennbaren Kindern sind die Vorgaben der Schule und die Einwilligungen der Sorgeberechtigten zu beachten.</p>
|
||||
<h3>7. Rechte betroffener Personen</h3>
|
||||
<h3>8. Rechte betroffener Personen</h3>
|
||||
<p>Betroffene Personen können je nach rechtlicher Voraussetzung Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch verlangen. Erteilte Einwilligungen können für die Zukunft widerrufen werden. Außerdem besteht das Recht, sich bei einer Datenschutzaufsichtsbehörde über die Verarbeitung personenbezogener Daten zu beschweren.</p>
|
||||
<p>
|
||||
Zuständige Datenschutzaufsicht:<br>
|
||||
@@ -250,7 +264,6 @@
|
||||
Zum Eichig 3<br>
|
||||
98617 Meiningen / OT Herpf
|
||||
</p>
|
||||
|
||||
<h3>Kontakt</h3>
|
||||
<p>
|
||||
Telefon: (036943) 63722<br>
|
||||
@@ -268,7 +281,6 @@
|
||||
</p>
|
||||
<h3>Inhaltlich verantwortlich</h3>
|
||||
<p>Frau Wichert</p>
|
||||
|
||||
<h3>Weitere Pflichtangaben</h3>
|
||||
<p>Je nach konkretem Betrieb der Wegwichtel-Instanz können zusätzliche Angaben erforderlich sein, zum Beispiel zur technischen Betreiberin, zum Hosting oder zu ergänzenden Zuständigkeiten. Nicht benötigte Angaben sind zu entfernen.</p>
|
||||
<h3>Haftung für Inhalte und Verweise</h3>
|
||||
@@ -289,6 +301,7 @@
|
||||
</label>
|
||||
<h3>Local Storage</h3>
|
||||
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
||||
<p>Lokale Streckenentwürfe werden getrennt davon unter <code>wegwichtel.local-routes.v1.*</code> gespeichert. Pro Entwurf werden GPX- und JSON-Daten vorgehalten; Bilder und Audiodateien liegen als Bestandteil der lokalen JSON-Daten im Browser. Diese Entwürfe werden nicht nach 24 Stunden bereinigt und sind nicht vom Schalter für den Streckenfortschritt abhängig. Durch Mediendateien kann das Speicherlimit des Browsers vergleichsweise schnell erreicht werden.</p>
|
||||
<label class="switch-row compact-switch">
|
||||
<span>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
@@ -301,7 +314,7 @@
|
||||
<ul>
|
||||
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
||||
<li><strong>jQuery UI 1.14.2:</strong> Schaltflächen und Suchvorschläge.</li>
|
||||
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz und lokaler Fortschritt.</li>
|
||||
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz, lokaler Fortschritt und lokale Streckenentwürfe.</li>
|
||||
</ul>
|
||||
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p>
|
||||
<h3>Browser-Schnittstellen</h3>
|
||||
@@ -310,42 +323,38 @@
|
||||
<li>Device Orientation API für den Richtungspfeil,</li>
|
||||
<li>Vibration API für Hinweise bei erreichten Stationen,</li>
|
||||
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
||||
<li>FileReader und XML-Schnittstellen zum lokalen Verarbeiten von GPX-, Bild- und Audiodateien,</li>
|
||||
<li>History API für die Navigation zwischen den Ansichten.</li>
|
||||
</ul>
|
||||
<h3>Technische Grenzen</h3>
|
||||
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder Local Storage sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen.</p>
|
||||
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder Local Storage sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen. Für lokale Streckenentwürfe gilt zusätzlich das vom Browser vorgegebene Local-Storage-Limit.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
|
||||
<dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title">
|
||||
<div class="editor-dialog-shell">
|
||||
<header class="editor-dialog-header">
|
||||
<h2 id="editor-dialog-title">Streckeneditor</h2>
|
||||
<button id="editor-close" type="button" aria-label="Streckeneditor schließen" title="Schließen">×</button>
|
||||
<h2 id="editor-dialog-title">Streckenverwaltung</h2>
|
||||
<button id="editor-close" type="button" aria-label="Streckenverwaltung schließen" title="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<output id="editor-status" class="editor-status" aria-live="polite" hidden></output>
|
||||
|
||||
<section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title">
|
||||
<div class="editor-toolbar">
|
||||
<h3 id="editor-route-list-title">Vorhandene Strecken</h3>
|
||||
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue Strecke anlegen" title="Neue Strecke anlegen">
|
||||
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue lokale Strecke anlegen" title="Neue lokale Strecke anlegen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="editor-muted">Veröffentlichte Serverstrecken und ausschließlich in diesem Browser gespeicherte Entwürfe werden getrennt dargestellt.</p>
|
||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
|
||||
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
<button id="editor-route-back" class="editor-secondary-button" type="button">Zurück</button>
|
||||
<h3 id="editor-route-detail-title">Streckendetails</h3>
|
||||
</div>
|
||||
|
||||
<form id="editor-route-form" class="editor-form">
|
||||
<input id="editor-route-id" type="hidden">
|
||||
<label for="editor-route-name">Name der Strecke</label>
|
||||
@@ -353,24 +362,21 @@
|
||||
|
||||
<label for="editor-route-description">Beschreibung</label>
|
||||
<textarea id="editor-route-description" rows="4"></textarea>
|
||||
|
||||
<label for="editor-route-gpx">GPX-Datei optional ersetzen oder ergänzen</label>
|
||||
<label for="editor-route-gpx">GPX-Datei importieren oder ersetzen</label>
|
||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||
|
||||
<p id="editor-route-activated" class="editor-muted">Neue Strecken bleiben unveröffentlicht, bis sie manuell in der Datenbank aktiviert werden.</p>
|
||||
<p id="editor-route-activated" class="editor-muted">Neue Strecken werden als lokaler GPX-/JSON-Entwurf gespeichert und nicht an den Server übertragen.</p>
|
||||
|
||||
<button id="editor-route-save" class="editor-primary-button" type="submit">Strecke speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="editor-toolbar editor-poi-toolbar">
|
||||
<h3>Stationen</h3>
|
||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="POI hinzufügen" title="POI hinzufügen">
|
||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="GPX-POI hinzufügen" title="GPX-POI hinzufügen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
<ol id="editor-poi-list" class="editor-poi-list"></ol>
|
||||
</section>
|
||||
|
||||
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
|
||||
@@ -379,7 +385,6 @@
|
||||
|
||||
<form id="editor-poi-form" class="editor-form">
|
||||
<input id="editor-poi-id" type="hidden">
|
||||
|
||||
<label for="editor-poi-title">Titel</label>
|
||||
<input id="editor-poi-title" type="text" autocomplete="off" required>
|
||||
|
||||
@@ -388,19 +393,17 @@
|
||||
|
||||
<div class="editor-coordinate-grid">
|
||||
<label for="editor-poi-lat">Breite</label>
|
||||
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001">
|
||||
|
||||
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001" required>
|
||||
<label for="editor-poi-lon">Länge</label>
|
||||
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001">
|
||||
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001" required>
|
||||
|
||||
<label for="editor-poi-radius">Auslöseradius in Metern</label>
|
||||
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
|
||||
</div>
|
||||
|
||||
<label for="editor-poi-audio">Audiodatei optional hochladen</label>
|
||||
<label for="editor-poi-audio">Audiodatei verknüpfen</label>
|
||||
<input id="editor-poi-audio" type="file" accept="audio/*">
|
||||
|
||||
<label for="editor-poi-images">Bilder optional hochladen</label>
|
||||
<label for="editor-poi-images">Bilder verknüpfen</label>
|
||||
<input id="editor-poi-images" type="file" accept="image/*" multiple>
|
||||
|
||||
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button>
|
||||
@@ -408,7 +411,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="js/local-routes.js?v=0.12.14"></script>
|
||||
<script src="js/editor-ui.js?v=0.12.14"></script>
|
||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||
|
||||
+13
-1
@@ -1,11 +1,23 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
function request(path) {
|
||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||
}
|
||||
|
||||
function routeQuery(position) {
|
||||
const params = new URLSearchParams({ activatedOnly: 'true' });
|
||||
if (position) {
|
||||
params.set('lat', String(position.lat));
|
||||
params.set('lon', String(position.lon));
|
||||
params.set('radiusKm', String(ns.Config.routeRadiusKm));
|
||||
}
|
||||
return `/routes?${params.toString()}`;
|
||||
}
|
||||
|
||||
ns.Api = {
|
||||
health: () => request('/health'),
|
||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||
routes: position => request(routeQuery(position)),
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||
};
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
+215
-69
@@ -3,6 +3,8 @@
|
||||
|
||||
const state = {
|
||||
routes: [],
|
||||
localRoutes: [],
|
||||
localRoutesError: null,
|
||||
route: null,
|
||||
position: null,
|
||||
triggered: new Set(),
|
||||
@@ -20,7 +22,6 @@
|
||||
return $('<div>').text(value ?? '').html();
|
||||
}
|
||||
|
||||
|
||||
function persistRouteProgress() {
|
||||
if (!state.route) return;
|
||||
if (state.persistTimer != null) {
|
||||
@@ -54,7 +55,6 @@
|
||||
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
|
||||
? 'paused'
|
||||
: 'idle';
|
||||
|
||||
if (state.routeState === 'paused') {
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
@@ -89,7 +89,6 @@
|
||||
target.removeAttr('hidden');
|
||||
state.activePage = pageSelector;
|
||||
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
||||
|
||||
if (options.updateHistory === false) return;
|
||||
const historyState = { page: pageSelector, previous };
|
||||
if (options.replace) history.replaceState(historyState, '', pageSelector);
|
||||
@@ -112,31 +111,99 @@
|
||||
].filter(Boolean).join(' ')).includes(searchTerm);
|
||||
}
|
||||
|
||||
function renderRouteItems(listSelector, routes, emptyText) {
|
||||
function routeButton(route, source) {
|
||||
const proximity = route.proximityM == null
|
||||
? ''
|
||||
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||
const sourceLabel = source === 'local' ? '<small>Lokaler Entwurf</small>' : '';
|
||||
return `
|
||||
<li>
|
||||
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}">
|
||||
<strong>${escapeHtml(route.name)}</strong>
|
||||
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||
${sourceLabel}
|
||||
${proximity}
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRouteItems(listSelector, routes, emptyText, source = 'server') {
|
||||
const list = $(listSelector).empty();
|
||||
const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
|
||||
if (!items.length) {
|
||||
list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
|
||||
return;
|
||||
}
|
||||
items.forEach(route => list.append(routeButton(route, source)));
|
||||
}
|
||||
|
||||
items.forEach(route => {
|
||||
const proximity = route.proximityM == null
|
||||
? ''
|
||||
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||
function nextPoiMetric() {
|
||||
const pois = state.route?.pois || [];
|
||||
const pending = pois
|
||||
.filter(poi => !state.triggered.has(poi.id))
|
||||
.slice()
|
||||
.sort((left, right) => Number(left.sequence ?? 0) - Number(right.sequence ?? 0));
|
||||
if (!pending.length) return null;
|
||||
if (!state.position) return { poi: pending[0], distance: null };
|
||||
return nearestPendingPoi(poiMetrics(state.position));
|
||||
}
|
||||
|
||||
function updateRouteControlSummary() {
|
||||
const title = $('#route-control-title');
|
||||
const next = $('#route-control-next');
|
||||
if (!state.route) {
|
||||
title.text('Route wählen');
|
||||
next.text('Aktive und lokale Strecken');
|
||||
return;
|
||||
}
|
||||
|
||||
title.text(state.route.name || 'Route');
|
||||
const metric = nextPoiMetric();
|
||||
if (!metric) {
|
||||
next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
|
||||
return;
|
||||
}
|
||||
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
|
||||
next.text(`Nächster POI: ${metric.poi.title}${distance}`);
|
||||
}
|
||||
|
||||
function renderRouteControlList(selector, routes, source, emptyText) {
|
||||
const list = $(selector).empty();
|
||||
if (!routes.length) {
|
||||
list.append(`<li><span>${escapeHtml(emptyText)}</span></li>`);
|
||||
return;
|
||||
}
|
||||
routes.forEach(route => {
|
||||
const current = state.route && String(state.route.id) === String(route.id)
|
||||
&& (state.route.source || 'server') === source;
|
||||
list.append(`
|
||||
<li>
|
||||
<button type="button" data-route-id="${route.id}">
|
||||
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}>
|
||||
<strong>${escapeHtml(route.name)}</strong>
|
||||
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||
${proximity}
|
||||
<small>${source === 'local' ? 'Lokaler Entwurf' : 'Veröffentlicht'}</small>
|
||||
</button>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRouteControl() {
|
||||
updateRouteControlSummary();
|
||||
renderRouteControlList(
|
||||
'#route-control-server-list',
|
||||
state.routes,
|
||||
'server',
|
||||
'Keine veröffentlichte Strecke vorhanden.'
|
||||
);
|
||||
renderRouteControlList(
|
||||
'#route-control-local-list',
|
||||
state.localRoutes,
|
||||
'local',
|
||||
state.localRoutesError || 'Noch keine lokale Strecke vorhanden.'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
|
||||
const radiusM = ns.Config.routeRadiusKm * 1000;
|
||||
@@ -152,6 +219,9 @@
|
||||
.filter(route => routeMatchesSearch(route, searchTerm))
|
||||
.slice()
|
||||
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
const localRoutes = state.localRoutes
|
||||
.slice()
|
||||
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
|
||||
const nearbyEmptyText = state.position
|
||||
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
|
||||
@@ -161,20 +231,26 @@
|
||||
renderRouteItems(
|
||||
'#all-route-list',
|
||||
allRoutes,
|
||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
|
||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine veröffentlichten Routen vorhanden.'
|
||||
);
|
||||
renderRouteItems(
|
||||
'#local-route-list',
|
||||
localRoutes,
|
||||
state.localRoutesError || 'Noch keine lokalen Strecken gespeichert.',
|
||||
'local'
|
||||
);
|
||||
|
||||
$('#all-routes-summary').text(
|
||||
searchTerm
|
||||
? `${allRoutes.length} von ${routes.length} Routen gefunden.`
|
||||
: `${routes.length} Routen verfügbar.`
|
||||
? `${allRoutes.length} von ${routes.length} veröffentlichten Routen gefunden.`
|
||||
: `${routes.length} veröffentlichte Routen verfügbar.`
|
||||
);
|
||||
renderRouteControl();
|
||||
}
|
||||
|
||||
function initializeRouteSearch() {
|
||||
const input = $('#route-search');
|
||||
if (!input.length || typeof input.autocomplete !== 'function') return;
|
||||
|
||||
input.autocomplete({
|
||||
minLength: 0,
|
||||
source(request, response) {
|
||||
@@ -215,7 +291,6 @@
|
||||
const running = state.routeState === 'running';
|
||||
const paused = state.routeState === 'paused';
|
||||
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
|
||||
|
||||
$('#start-route')
|
||||
.prop('disabled', running)
|
||||
.attr({ 'aria-label': startLabel, title: startLabel });
|
||||
@@ -226,7 +301,6 @@
|
||||
function setActivePoi(poiId) {
|
||||
$('#poi-list [data-poi-id]').removeAttr('aria-current');
|
||||
if (poiId == null) return;
|
||||
|
||||
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
|
||||
}
|
||||
|
||||
@@ -243,7 +317,6 @@
|
||||
ns.AudioPlayer.unload();
|
||||
setActivePoi(null);
|
||||
hideNavigation();
|
||||
|
||||
if (announce && wasActive && !returnToRoutes) {
|
||||
$('#tracking-status').prop('hidden', false).text('Route beendet.');
|
||||
} else {
|
||||
@@ -251,16 +324,18 @@
|
||||
}
|
||||
|
||||
updateRouteControls();
|
||||
|
||||
if (returnToRoutes) {
|
||||
state.route = null;
|
||||
updateRouteControlSummary();
|
||||
renderRouteControl();
|
||||
navigate('#routes-page', { replace: true });
|
||||
} else {
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function pauseRoute() {
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
state.routeState = 'paused';
|
||||
persistRouteProgress();
|
||||
stopRouteSensors();
|
||||
@@ -270,32 +345,37 @@
|
||||
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
|
||||
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
|
||||
updateRouteControls();
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
|
||||
function renderRoute(route) {
|
||||
if (state.routeState !== 'idle') endRoute({ announce: false });
|
||||
|
||||
state.route = route;
|
||||
function suspendCurrentRoute() {
|
||||
if (!state.route) return;
|
||||
if (state.routeState === 'running') state.routeState = 'paused';
|
||||
persistRouteProgress();
|
||||
stopRouteSensors();
|
||||
ns.AudioPlayer.unload();
|
||||
state.activePoiId = null;
|
||||
restoreRouteProgress(route);
|
||||
setActivePoi(null);
|
||||
hideNavigation();
|
||||
}
|
||||
|
||||
function renderRouteView(route) {
|
||||
$('#route-title').text(route.name);
|
||||
$('#route-description').text(route.description || '');
|
||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
||||
$('#route-elevation').text(`${Math.round(route.elevationGainM || 0)} m`);
|
||||
$('#route-poi-count').text(route.pois.length);
|
||||
updateRouteControls();
|
||||
|
||||
const list = $('#poi-list').empty();
|
||||
if (!route.pois.length) {
|
||||
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||
updateRouteControlSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
route.pois.forEach(poi => {
|
||||
list.append(`
|
||||
<li>
|
||||
<button type="button" data-poi-id="${poi.id}">
|
||||
<button type="button" data-poi-select data-poi-id="${poi.id}">
|
||||
<strong>${escapeHtml(poi.title)}</strong>
|
||||
<span>${escapeHtml(poi.description || '')}</span>
|
||||
<span class="poi-proximity" aria-label="Entfernung wird ermittelt">
|
||||
@@ -308,8 +388,18 @@
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
|
||||
updatePoiIndicators(state.position);
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
|
||||
function renderRoute(route) {
|
||||
if (state.route) suspendCurrentRoute();
|
||||
state.route = route;
|
||||
state.activePoiId = null;
|
||||
restoreRouteProgress(route);
|
||||
setActivePoi(null);
|
||||
renderRouteView(route);
|
||||
renderRouteControl();
|
||||
}
|
||||
|
||||
function showPoi(poi) {
|
||||
@@ -334,7 +424,6 @@
|
||||
start = Math.max(0, state.routeProgressIndex - 3);
|
||||
end = Math.min(points.length, state.routeProgressIndex + 201);
|
||||
}
|
||||
|
||||
let nearestIndex = start;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = start; index < end; index += 1) {
|
||||
@@ -344,7 +433,6 @@
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
@@ -360,7 +448,6 @@
|
||||
function poiMetrics(position) {
|
||||
if (!position || !state.route?.pois?.length) return [];
|
||||
const heading = effectiveHeading(position);
|
||||
|
||||
return state.route.pois.map(poi => {
|
||||
const distance = ns.Distance.meters(position, poi);
|
||||
const bearing = ns.Distance.bearing(position, poi);
|
||||
@@ -377,7 +464,6 @@
|
||||
|
||||
function updatePoiIndicators(position) {
|
||||
const metrics = poiMetrics(position);
|
||||
|
||||
metrics.forEach(metric => {
|
||||
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
|
||||
const distance = Math.round(metric.distance);
|
||||
@@ -387,7 +473,6 @@
|
||||
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
indicator.find('small').text(`${distance} m`);
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
@@ -403,20 +488,17 @@
|
||||
const previousProgressIndex = state.routeProgressIndex;
|
||||
const points = state.route?.points || [];
|
||||
if (!points.length) return null;
|
||||
|
||||
const nearestIndex = nearestRoutePointIndex(position);
|
||||
if (nearestIndex == null) return null;
|
||||
|
||||
state.routeProgressIndex = state.routeProgressIndex == null
|
||||
? nearestIndex
|
||||
: Math.max(state.routeProgressIndex, nearestIndex);
|
||||
|
||||
const lastIndex = points.length - 1;
|
||||
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
|
||||
let target = points[targetIndex];
|
||||
let distance = ns.Distance.meters(position, target);
|
||||
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
|
||||
|
||||
while (targetIndex < lastIndex && distance <= reachedRadius) {
|
||||
state.routeProgressIndex = targetIndex;
|
||||
targetIndex += 1;
|
||||
@@ -429,7 +511,6 @@
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: true, target, distance: 0 };
|
||||
}
|
||||
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: false, target, distance };
|
||||
}
|
||||
@@ -437,7 +518,6 @@
|
||||
function showPoiNavigation(position, metric) {
|
||||
const heading = effectiveHeading(position);
|
||||
const distance = Math.round(metric.distance);
|
||||
|
||||
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null
|
||||
@@ -464,14 +544,12 @@
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
||||
return;
|
||||
}
|
||||
|
||||
const bearing = ns.Distance.bearing(position, progress.target);
|
||||
const heading = effectiveHeading(position);
|
||||
const rotation = heading == null
|
||||
? bearing
|
||||
: ns.Distance.normalizeDegrees(bearing - heading);
|
||||
const distance = Math.round(progress.distance);
|
||||
|
||||
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
|
||||
@@ -485,11 +563,9 @@
|
||||
|
||||
function updateNavigation(position) {
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
const progress = updateRouteProgress(position);
|
||||
const metrics = updatePoiIndicators(position);
|
||||
const nextPoi = nearestPendingPoi(metrics);
|
||||
|
||||
if (nextPoi) showPoiNavigation(position, nextPoi);
|
||||
else showRoutePointNavigation(position, progress);
|
||||
}
|
||||
@@ -501,37 +577,36 @@
|
||||
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
|
||||
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
|
||||
}
|
||||
|
||||
if (state.position) updateNavigation(state.position);
|
||||
}
|
||||
|
||||
function evaluatePosition(position) {
|
||||
state.position = position;
|
||||
updateRouteProximity(position);
|
||||
renderRouteControl();
|
||||
if (!state.route) return;
|
||||
|
||||
const metrics = updatePoiIndicators(position);
|
||||
updateRouteControlSummary();
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
const nearest = nearestPendingPoi(metrics);
|
||||
updateNavigation(position);
|
||||
|
||||
$('#tracking-status').text(
|
||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
||||
);
|
||||
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
persistRouteProgress();
|
||||
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
||||
showPoi(nearest.poi);
|
||||
updateNavigation(position);
|
||||
updateRouteControlSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRoute() {
|
||||
if (!state.route || state.routeState === 'running') return;
|
||||
|
||||
const resumed = state.routeState === 'paused';
|
||||
if (!resumed) {
|
||||
state.routeProgressIndex = null;
|
||||
@@ -542,11 +617,11 @@
|
||||
state.deviceHeading = null;
|
||||
persistRouteProgress();
|
||||
updateRouteControls();
|
||||
updateRouteControlSummary();
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
|
||||
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
|
||||
|
||||
ns.Geo.start(
|
||||
evaluatePosition,
|
||||
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
|
||||
@@ -557,24 +632,35 @@
|
||||
ns.Orientation.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orientationAvailable) {
|
||||
$('#navigation-mode').text('Norden oben · Navigation');
|
||||
}
|
||||
if (!orientationAvailable) $('#navigation-mode').text('Norden oben · Navigation');
|
||||
}
|
||||
|
||||
function routesFromResponse(response) {
|
||||
if (!Array.isArray(response?.routes)) {
|
||||
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
|
||||
}
|
||||
return response.routes.filter(route => route?.id != null && route?.name != null);
|
||||
return response.routes
|
||||
.filter(route => route?.id != null && route?.name != null)
|
||||
.map(route => ({ ...route, source: 'server', local: false }));
|
||||
}
|
||||
|
||||
function loadLocalRoutes() {
|
||||
try {
|
||||
state.localRoutes = ns.LocalRoutes?.listAppRoutes?.() || [];
|
||||
state.localRoutesError = null;
|
||||
} catch (error) {
|
||||
state.localRoutes = [];
|
||||
state.localRoutesError = error.message || 'Lokale Strecken konnten nicht gelesen werden.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateRouteProximity(position) {
|
||||
state.routes = state.routes.map(route => ({
|
||||
const withProximity = route => ({
|
||||
...route,
|
||||
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
|
||||
}));
|
||||
});
|
||||
state.routes = state.routes.map(withProximity);
|
||||
state.localRoutes = state.localRoutes.map(withProximity);
|
||||
}
|
||||
|
||||
async function initializeLocation(context) {
|
||||
@@ -597,7 +683,38 @@
|
||||
}
|
||||
|
||||
function restoreRouteLists() {
|
||||
if (state.routes.length) renderRoutes();
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
renderRoutes();
|
||||
}
|
||||
|
||||
function setRouteControlOpen(open) {
|
||||
$('#route-control-dropdown').prop('hidden', !open);
|
||||
$('#route-control-button').attr('aria-expanded', String(open));
|
||||
}
|
||||
|
||||
async function selectRoute(source, id) {
|
||||
setRouteControlOpen(false);
|
||||
const route = source === 'local'
|
||||
? ns.LocalRoutes.getAppRoute(id)
|
||||
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
}
|
||||
|
||||
function refreshCurrentLocalRoute(id) {
|
||||
if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||
persistRouteProgress();
|
||||
try {
|
||||
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||
renderRouteView(state.route);
|
||||
renderRouteControl();
|
||||
} catch {
|
||||
state.route = null;
|
||||
state.routeState = 'idle';
|
||||
hideNavigation();
|
||||
navigate('#routes-page', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
@@ -607,13 +724,11 @@
|
||||
$('#route-search').val('').trigger('input').trigger('focus');
|
||||
});
|
||||
|
||||
activate('[data-route-id]', async function () {
|
||||
activate('[data-route-select]', async function () {
|
||||
const button = $(this);
|
||||
button.prop('disabled', true);
|
||||
try {
|
||||
const route = await ns.Api.route(button.data('route-id'));
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
|
||||
} catch (error) {
|
||||
alert(error.responseJSON?.message || error.statusText || error.message);
|
||||
} finally {
|
||||
@@ -621,7 +736,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
activate('[data-poi-id]', function () {
|
||||
activate('[data-poi-select]', function () {
|
||||
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
||||
if (poi) showPoi(poi);
|
||||
});
|
||||
@@ -632,14 +747,33 @@
|
||||
else navigate(target, { replace: true });
|
||||
});
|
||||
|
||||
activate('#route-control-button', function () {
|
||||
setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
|
||||
});
|
||||
|
||||
activate('#route-control-new', function () {
|
||||
setRouteControlOpen(false);
|
||||
ns.EditorUi?.openNewLocalRoute?.();
|
||||
});
|
||||
|
||||
activate('#editor-button', function () {
|
||||
setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
$(document).on('pointerup', event => {
|
||||
if ($('#route-control-dropdown').prop('hidden')) return;
|
||||
if ($(event.target).closest('.route-control').length) return;
|
||||
setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
$(document).on('keydown', event => {
|
||||
if (event.key === 'Escape') setRouteControlOpen(false);
|
||||
});
|
||||
|
||||
activate('#start-route', async function () {
|
||||
await startRoute();
|
||||
});
|
||||
|
||||
activate('#pause-route', function () {
|
||||
pauseRoute();
|
||||
});
|
||||
|
||||
activate('#pause-route', pauseRoute);
|
||||
activate('#stop-route', function () {
|
||||
endRoute({ returnToRoutes: true });
|
||||
});
|
||||
@@ -654,12 +788,19 @@
|
||||
if (event.detail?.enabled) persistRouteProgress();
|
||||
});
|
||||
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => {
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
refreshCurrentLocalRoute(event.detail?.id);
|
||||
renderRoutes();
|
||||
ns.EditorUi?.refresh?.();
|
||||
});
|
||||
|
||||
globalThis.addEventListener('pagehide', persistRouteProgress);
|
||||
globalThis.addEventListener('popstate', event => {
|
||||
const page = event.state?.page || '#routes-page';
|
||||
navigate(page, { updateHistory: false, instant: true });
|
||||
});
|
||||
|
||||
globalThis.addEventListener('pageshow', restoreRouteLists);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') restoreRouteLists();
|
||||
@@ -669,6 +810,9 @@
|
||||
|
||||
ns.App = {
|
||||
navigate,
|
||||
currentPosition() {
|
||||
return state.position ? { ...state.position } : null;
|
||||
},
|
||||
|
||||
async initialize(context) {
|
||||
bindEvents();
|
||||
@@ -677,12 +821,14 @@
|
||||
|
||||
const response = await ns.Api.routes(null);
|
||||
state.routes = routesFromResponse(response);
|
||||
loadLocalRoutes();
|
||||
context.markStep('routes', 'done');
|
||||
|
||||
renderRoutes();
|
||||
initializeRouteSearch();
|
||||
updateRouteControls();
|
||||
hideNavigation();
|
||||
setRouteControlOpen(false);
|
||||
|
||||
await initializeLocation(context);
|
||||
}
|
||||
|
||||
+271
-161
@@ -6,10 +6,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const ids = Object.freeze({
|
||||
addPoi: 'editor-add-poi',
|
||||
button: 'editor-button',
|
||||
coordinateGrid: 'editor-coordinate-grid',
|
||||
close: 'editor-close',
|
||||
dialog: 'editor-dialog',
|
||||
entryTitle: 'editor-entry-title',
|
||||
newRoute: 'editor-new-route',
|
||||
poiAudio: 'editor-poi-audio',
|
||||
poiCancel: 'editor-poi-cancel',
|
||||
@@ -23,53 +21,33 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
poiList: 'editor-poi-list',
|
||||
poiLon: 'editor-poi-lon',
|
||||
poiRadius: 'editor-poi-radius',
|
||||
poiSave: 'editor-poi-save',
|
||||
poiTitle: 'editor-poi-title',
|
||||
routeActivated: 'editor-route-activated',
|
||||
routeBack: 'editor-route-back',
|
||||
routeDescription: 'editor-route-description',
|
||||
routeDetailTitle: 'editor-route-detail-title',
|
||||
routeDetailView: 'editor-route-detail-view',
|
||||
routeForm: 'editor-route-form',
|
||||
routeGpx: 'editor-route-gpx',
|
||||
routeId: 'editor-route-id',
|
||||
routeList: 'editor-route-list',
|
||||
routeListTitle: 'editor-route-list-title',
|
||||
routeListView: 'editor-route-list-view',
|
||||
routeName: 'editor-route-name',
|
||||
routeSave: 'editor-route-save',
|
||||
status: 'editor-status',
|
||||
title: 'editor-dialog-title'
|
||||
status: 'editor-status'
|
||||
});
|
||||
|
||||
|
||||
const classes = Object.freeze({
|
||||
button: 'editor-button',
|
||||
dialog: 'editor-dialog',
|
||||
dialogHeader: 'editor-dialog-header',
|
||||
dialogShell: 'editor-dialog-shell',
|
||||
entryPanel: 'editor-entry-panel',
|
||||
form: 'editor-form',
|
||||
iconButton: 'editor-icon-button',
|
||||
iconRow: 'editor-icon-row',
|
||||
muted: 'editor-muted',
|
||||
poiItem: 'editor-poi-item',
|
||||
poiList: 'editor-poi-list',
|
||||
poiToolbar: 'editor-poi-toolbar',
|
||||
primaryButton: 'editor-primary-button',
|
||||
routeItem: 'editor-route-item',
|
||||
routeList: 'editor-route-list',
|
||||
roundButton: 'editor-round-button',
|
||||
secondaryButton: 'editor-secondary-button',
|
||||
status: 'editor-status',
|
||||
toolbar: 'editor-toolbar',
|
||||
toolbarBack: 'editor-toolbar-back',
|
||||
view: 'editor-view'
|
||||
routeGroup: 'editor-route-group',
|
||||
routeItem: 'editor-route-item'
|
||||
});
|
||||
|
||||
const state = {
|
||||
currentPoi: null,
|
||||
currentRoute: null,
|
||||
currentSource: null,
|
||||
pois: [],
|
||||
routes: []
|
||||
};
|
||||
@@ -194,6 +172,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
const raw = await response.text();
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'same-origin',
|
||||
@@ -225,22 +219,6 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
const raw = await response.text();
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function routeArray(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.routes)) return payload.routes;
|
||||
@@ -273,55 +251,71 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return button;
|
||||
}
|
||||
|
||||
function routeSource(route) {
|
||||
return route?._source || route?.source || 'server';
|
||||
}
|
||||
|
||||
function routeItem(route) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
item.dataset.routeId = routeId(route);
|
||||
item.dataset.routeSource = routeSource(route);
|
||||
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', routeName(route));
|
||||
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
||||
appendText(label, 'small', routeSource(route) === 'local' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke');
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function routeGroup(title) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeGroup;
|
||||
item.textContent = title;
|
||||
return item;
|
||||
}
|
||||
|
||||
function poiItem(poi, index) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.poiItem;
|
||||
item.dataset.poiId = poiId(poi);
|
||||
|
||||
const title = poiTitle(poi);
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', title);
|
||||
appendText(label, 'small', `Position ${index + 1}`);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
||||
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
|
||||
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderRouteGroup(list, title, routes, emptyText) {
|
||||
list.appendChild(routeGroup(title));
|
||||
if (!routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
appendText(item, 'span', emptyText);
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
routes.forEach(route => list.appendChild(routeItem(route)));
|
||||
}
|
||||
|
||||
function renderRouteList() {
|
||||
const list = byId(ids.routeList);
|
||||
if (!list) return;
|
||||
list.textContent = '';
|
||||
if (!state.routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.routeItem;
|
||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
state.routes.forEach(route => list.appendChild(routeItem(route)));
|
||||
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
||||
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
|
||||
renderRouteGroup(list, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.');
|
||||
renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.');
|
||||
}
|
||||
|
||||
function renderPoiList() {
|
||||
@@ -338,12 +332,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
||||
}
|
||||
|
||||
function localRouteSummaries() {
|
||||
if (!ns.LocalRoutes) return [];
|
||||
return ns.LocalRoutes.list().map(routePackage => ({
|
||||
...routePackage.route,
|
||||
id: routePackage.id,
|
||||
_source: 'local'
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
setStatus('Strecken werden geladen …');
|
||||
const payload = await readJson('/routes');
|
||||
state.routes = routeArray(payload);
|
||||
const payload = await readJson('/routes?activatedOnly=true');
|
||||
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
|
||||
let localRoutes = [];
|
||||
try {
|
||||
localRoutes = localRouteSummaries();
|
||||
} catch (error) {
|
||||
setStatus(error.message, true);
|
||||
}
|
||||
state.routes = [...serverRoutes, ...localRoutes];
|
||||
renderRouteList();
|
||||
setStatus('');
|
||||
if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
|
||||
}
|
||||
|
||||
async function openEditor() {
|
||||
@@ -358,6 +368,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await guarded(loadRoutes);
|
||||
}
|
||||
|
||||
async function openNewLocalRoute() {
|
||||
await openEditor();
|
||||
newRoute();
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
@@ -377,11 +392,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return {
|
||||
description: byId(ids.poiDescription)?.value.trim() || '',
|
||||
lat: numberOrNull(byId(ids.poiLat)?.value),
|
||||
latitude: numberOrNull(byId(ids.poiLat)?.value),
|
||||
lon: numberOrNull(byId(ids.poiLon)?.value),
|
||||
longitude: numberOrNull(byId(ids.poiLon)?.value),
|
||||
radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||
sortOrder: state.currentPoi?.sortOrder ?? state.pois.length,
|
||||
triggerRadiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||
sequence: state.currentPoi?.sequence ?? state.pois.length,
|
||||
title: byId(ids.poiTitle)?.value.trim() || ''
|
||||
};
|
||||
}
|
||||
@@ -391,9 +404,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||
byId(ids.routeDescription).value = text(route?.description);
|
||||
byId(ids.routeGpx).value = '';
|
||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||
? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
|
||||
: 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
|
||||
if (state.currentSource === 'local') {
|
||||
byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
|
||||
} else {
|
||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
|
||||
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
|
||||
}
|
||||
}
|
||||
|
||||
function fillPoiForm(poi) {
|
||||
@@ -402,7 +419,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||
byId(ids.poiRadius).value = text(poi?.radiusM ?? poi?.radius ?? 25);
|
||||
byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
|
||||
byId(ids.poiAudio).value = '';
|
||||
byId(ids.poiImages).value = '';
|
||||
}
|
||||
@@ -411,23 +428,47 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const id = routeId(route);
|
||||
if (!id) return;
|
||||
setStatus('Streckendetails werden geladen …');
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = detail;
|
||||
state.pois = poiArray(detail);
|
||||
fillRouteForm(detail);
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const routePackage = ns.LocalRoutes.get(id);
|
||||
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
|
||||
state.currentRoute = {
|
||||
...routePackage.route,
|
||||
id: routePackage.id,
|
||||
gpx: routePackage.gpx,
|
||||
_source: 'local'
|
||||
};
|
||||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = { ...detail, _source: 'server' };
|
||||
state.pois = poiArray(detail);
|
||||
}
|
||||
|
||||
state.currentPoi = null;
|
||||
fillRouteForm(state.currentRoute);
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
state.currentRoute = null;
|
||||
state.currentSource = 'local';
|
||||
state.currentRoute = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'),
|
||||
_source: 'local'
|
||||
};
|
||||
state.currentPoi = null;
|
||||
state.pois = [];
|
||||
fillRouteForm({});
|
||||
fillRouteForm(state.currentRoute);
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.');
|
||||
}
|
||||
|
||||
function formDataFromRoute(values) {
|
||||
@@ -439,52 +480,137 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveLocalRoute(values) {
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
let gpx = state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
|
||||
if (file) {
|
||||
gpx = await ns.LocalRoutes.readFileAsText(file);
|
||||
const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
|
||||
if (!state.pois.length && importedPois.length) state.pois = importedPois;
|
||||
}
|
||||
|
||||
const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
|
||||
const saved = ns.LocalRoutes.save({
|
||||
id,
|
||||
gpx,
|
||||
route: {
|
||||
...(state.currentRoute || {}),
|
||||
id,
|
||||
name: values.name,
|
||||
description: values.description
|
||||
},
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.currentSource = 'local';
|
||||
state.pois = saved.pois;
|
||||
setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
|
||||
await loadRoutes();
|
||||
await loadRoute({ id: saved.id, _source: 'local' });
|
||||
}
|
||||
|
||||
async function saveServerRoute(values) {
|
||||
const id = routeId(state.currentRoute);
|
||||
if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.');
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
const path = `/routes/${encodeURIComponent(id)}`;
|
||||
const payload = file
|
||||
? await sendForm(path, 'PUT', formDataFromRoute(values))
|
||||
: await sendJson(path, 'PUT', values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = { ...saved, _source: 'server' };
|
||||
setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
|
||||
await loadRoutes();
|
||||
await loadRoute({ id, _source: 'server' });
|
||||
}
|
||||
|
||||
async function saveRoute() {
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
||||
const id = byId(ids.routeId).value;
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
|
||||
setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
|
||||
await loadRoutes();
|
||||
if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
|
||||
if (state.currentSource === 'server') await saveServerRoute(values);
|
||||
else await saveLocalRoute(values);
|
||||
}
|
||||
|
||||
function currentPosition() {
|
||||
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
|
||||
}
|
||||
|
||||
function newPoi() {
|
||||
state.currentPoi = null;
|
||||
fillPoiForm({ radiusM: 25 });
|
||||
const position = currentPosition();
|
||||
fillPoiForm({
|
||||
lat: position?.lat ?? '',
|
||||
lon: position?.lon ?? '',
|
||||
triggerRadiusM: 25
|
||||
});
|
||||
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
function editPoi(poi) {
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
byId(ids.poiFormTitle).textContent = 'Station bearbeiten';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const route = state.currentRoute;
|
||||
const currentRouteId = routeId(route);
|
||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||
const audioFile = byId(ids.poiAudio)?.files?.[0];
|
||||
const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
|
||||
const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
|
||||
const images = [...(existingPoi?.images || [])];
|
||||
for (const imageFile of imageFiles) {
|
||||
const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
|
||||
images.push({
|
||||
...image,
|
||||
id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
|
||||
caption: ''
|
||||
});
|
||||
}
|
||||
return { audio, images };
|
||||
}
|
||||
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
const method = currentPoiId ? 'PUT' : 'POST';
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
async function persistLocalCurrentRoute() {
|
||||
const id = routeId(state.currentRoute);
|
||||
if (!id) throw new Error('Speichere zuerst die Strecke.');
|
||||
const saved = ns.LocalRoutes.save({
|
||||
id,
|
||||
gpx: state.currentRoute.gpx,
|
||||
route: state.currentRoute,
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.pois = saved.pois;
|
||||
return saved;
|
||||
}
|
||||
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Station gespeichert.');
|
||||
await loadRoute(route);
|
||||
async function saveLocalPoi(values) {
|
||||
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||
throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
|
||||
}
|
||||
const currentId = Number(byId(ids.poiId).value);
|
||||
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
|
||||
const media = await mediaFromLocalForm(existing, identifier);
|
||||
const savedPoi = {
|
||||
...(existing || {}),
|
||||
...values,
|
||||
...media,
|
||||
id: identifier,
|
||||
sequence: existing?.sequence ?? state.pois.length
|
||||
};
|
||||
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
|
||||
if (index >= 0) state.pois[index] = savedPoi;
|
||||
else state.pois.push(savedPoi);
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('GPX-POI und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||||
}
|
||||
|
||||
async function uploadOne(path, file) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return sendForm(path, 'POST', data);
|
||||
}
|
||||
|
||||
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
||||
@@ -496,30 +622,57 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOne(path, file) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return sendForm(path, 'POST', data);
|
||||
async function saveServerPoi(values) {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
const method = currentPoiId ? 'PUT' : 'POST';
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Station gespeichert.');
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||
else await saveServerPoi(values);
|
||||
}
|
||||
|
||||
function reorderPois(index, offset) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= state.pois.length) return false;
|
||||
const reordered = [...state.pois];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
state.pois = reordered.map((poi, sequence) => ({ ...poi, sequence }));
|
||||
renderPoiList();
|
||||
return true;
|
||||
}
|
||||
|
||||
function swapPois(index, offset) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= state.pois.length) return;
|
||||
const reordered = [...state.pois];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
state.pois = reordered;
|
||||
renderPoiList();
|
||||
if (!reorderPois(index, offset)) return;
|
||||
guarded(savePoiOrder);
|
||||
}
|
||||
|
||||
async function savePoiOrder() {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) return;
|
||||
if (state.currentSource === 'local') {
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Lokale POI-Reihenfolge gespeichert.');
|
||||
return;
|
||||
}
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
const currentPoiId = poiId(poi);
|
||||
if (!currentPoiId) return null;
|
||||
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||
return sendJson(path, 'PUT', { ...poi, position: index, sortOrder: index });
|
||||
return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
|
||||
}).filter(Boolean);
|
||||
await Promise.all(jobs);
|
||||
setStatus('Reihenfolge gespeichert.');
|
||||
@@ -527,7 +680,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function routeForItem(target) {
|
||||
const item = target.closest('[data-route-id]');
|
||||
return state.routes.find(route => routeId(route) === item?.dataset.routeId);
|
||||
const id = item?.dataset.routeId;
|
||||
const source = item?.dataset.routeSource;
|
||||
return state.routes.find(route => routeId(route) === id && routeSource(route) === source);
|
||||
}
|
||||
|
||||
function poiForItem(target) {
|
||||
@@ -580,58 +735,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||
}
|
||||
|
||||
|
||||
function classNames() {
|
||||
return Object.values(classes);
|
||||
}
|
||||
|
||||
function testHooks() {
|
||||
return Object.freeze({
|
||||
apiUrl,
|
||||
classNames,
|
||||
closeEditor,
|
||||
fillPoiForm,
|
||||
fillRouteForm,
|
||||
handleResponse,
|
||||
isActivation,
|
||||
loadRoute,
|
||||
loadRoutes,
|
||||
newPoi,
|
||||
newRoute,
|
||||
poiArray,
|
||||
poiFromForm,
|
||||
poiItem,
|
||||
parseJson,
|
||||
readJson,
|
||||
renderPoiList,
|
||||
renderRouteList,
|
||||
routeArray,
|
||||
routeFromForm,
|
||||
routeItem,
|
||||
savePoi,
|
||||
sendForm,
|
||||
sendJson,
|
||||
setDialogExpanded,
|
||||
setDisabled,
|
||||
setStatus,
|
||||
savePoiOrder,
|
||||
saveRoute,
|
||||
state,
|
||||
swapPois,
|
||||
uploadPoiMedia
|
||||
});
|
||||
}
|
||||
|
||||
function editorApi() {
|
||||
const api = {
|
||||
return Object.freeze({
|
||||
close: closeEditor,
|
||||
classNames,
|
||||
open: openEditor,
|
||||
openNewLocalRoute,
|
||||
refresh: loadRoutes
|
||||
};
|
||||
|
||||
if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks();
|
||||
return Object.freeze(api);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_PREFIX = 'wegwichtel.local-routes.v1';
|
||||
const INDEX_KEY = `${STORAGE_PREFIX}.index`;
|
||||
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||
let poiCounter = 0;
|
||||
|
||||
function storage() {
|
||||
const target = globalThis.localStorage;
|
||||
if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.');
|
||||
return target;
|
||||
}
|
||||
|
||||
function routeKey(id, suffix) {
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
|
||||
function readIndex() {
|
||||
const raw = storage().getItem(INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(ids) {
|
||||
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
|
||||
}
|
||||
|
||||
function createRouteId() {
|
||||
if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`;
|
||||
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function createPoiId() {
|
||||
poiCounter = (poiCounter + 1) % 1000;
|
||||
return (Date.now() * 1000) + poiCounter;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function emptyGpx(name = 'Neue Strecke') {
|
||||
const safeName = escapeXml(name);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
||||
+ `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n`
|
||||
+ ` <metadata><name>${safeName}</name></metadata>\n`
|
||||
+ ` <trk><name>${safeName}</name><trkseg></trkseg></trk>\n`
|
||||
+ '</gpx>';
|
||||
}
|
||||
|
||||
function parseXml(gpx) {
|
||||
const parser = new DOMParser();
|
||||
const document = parser.parseFromString(String(gpx || ''), 'application/xml');
|
||||
if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
|
||||
throw new Error('Die GPX-Datei ist nicht gültig.');
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function directChildrenByLocalName(parent, localName) {
|
||||
return Array.from(parent?.children || []).filter(node => node.localName === localName);
|
||||
}
|
||||
|
||||
function firstDescendantByLocalName(parent, localName) {
|
||||
return Array.from(parent?.getElementsByTagName('*') || []).find(node => node.localName === localName) || null;
|
||||
}
|
||||
|
||||
function textOfChild(parent, localName) {
|
||||
return directChildrenByLocalName(parent, localName)[0]?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
function numberAttribute(node, name) {
|
||||
const value = Number.parseFloat(node?.getAttribute(name));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function waypointId(node) {
|
||||
const extension = firstDescendantByLocalName(node, 'poiId');
|
||||
const parsed = Number(extension?.textContent);
|
||||
return Number.isSafeInteger(parsed) ? parsed : createPoiId();
|
||||
}
|
||||
|
||||
function parseWaypoints(gpx) {
|
||||
const document = parseXml(gpx);
|
||||
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
|
||||
id: waypointId(node),
|
||||
title: textOfChild(node, 'name') || `Station ${index + 1}`,
|
||||
description: textOfChild(node, 'desc'),
|
||||
lat: numberAttribute(node, 'lat'),
|
||||
lon: numberAttribute(node, 'lon'),
|
||||
triggerRadiusM: 25,
|
||||
sequence: index,
|
||||
audio: null,
|
||||
images: []
|
||||
})).filter(poi => poi.lat != null && poi.lon != null);
|
||||
}
|
||||
|
||||
function createElement(document, localName, text = null) {
|
||||
const element = document.createElementNS(GPX_NAMESPACE, localName);
|
||||
if (text != null) element.textContent = String(text);
|
||||
return element;
|
||||
}
|
||||
|
||||
function updateMetadataName(document, name) {
|
||||
const root = document.documentElement;
|
||||
let metadata = directChildrenByLocalName(root, 'metadata')[0];
|
||||
if (!metadata) {
|
||||
metadata = createElement(document, 'metadata');
|
||||
root.insertBefore(metadata, root.firstChild);
|
||||
}
|
||||
let nameElement = directChildrenByLocalName(metadata, 'name')[0];
|
||||
if (!nameElement) {
|
||||
nameElement = createElement(document, 'name');
|
||||
metadata.appendChild(nameElement);
|
||||
}
|
||||
nameElement.textContent = name;
|
||||
}
|
||||
|
||||
function syncGpx(gpx, route, pois) {
|
||||
const document = parseXml(gpx || emptyGpx(route?.name));
|
||||
const root = document.documentElement;
|
||||
updateMetadataName(document, route?.name || 'Neue Strecke');
|
||||
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
|
||||
|
||||
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
orderedPois.forEach((poi, index) => {
|
||||
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
||||
throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`);
|
||||
}
|
||||
const waypoint = createElement(document, 'wpt');
|
||||
waypoint.setAttribute('lat', String(Number(poi.lat)));
|
||||
waypoint.setAttribute('lon', String(Number(poi.lon)));
|
||||
waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`));
|
||||
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
|
||||
const extensions = createElement(document, 'extensions');
|
||||
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
||||
poiId.textContent = String(poi.id);
|
||||
extensions.appendChild(poiId);
|
||||
waypoint.appendChild(extensions);
|
||||
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
|
||||
|| directChildrenByLocalName(root, 'trk')[0]
|
||||
|| null;
|
||||
root.insertBefore(waypoint, routeOrTrack);
|
||||
});
|
||||
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
|
||||
function readPackage(id) {
|
||||
const target = storage();
|
||||
const gpx = target.getItem(routeKey(id, 'gpx'));
|
||||
const rawJson = target.getItem(routeKey(id, 'json'));
|
||||
if (!gpx || !rawJson) return null;
|
||||
try {
|
||||
const metadata = JSON.parse(rawJson);
|
||||
return {
|
||||
id: String(id),
|
||||
gpx,
|
||||
route: metadata.route || {},
|
||||
pois: Array.isArray(metadata.pois) ? metadata.pois : [],
|
||||
schemaVersion: metadata.schemaVersion || 1
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreValue(target, key, value) {
|
||||
if (value == null) target.removeItem(key);
|
||||
else target.setItem(key, value);
|
||||
}
|
||||
|
||||
function notify(id, action) {
|
||||
if (typeof document === 'undefined' || typeof CustomEvent === 'undefined') return;
|
||||
document.dispatchEvent(new CustomEvent('wegwichtel:local-routes-changed', {
|
||||
detail: { action, id: String(id) }
|
||||
}));
|
||||
}
|
||||
|
||||
function savePackage(routePackage) {
|
||||
const target = storage();
|
||||
const id = String(routePackage.id || routePackage.route?.id || createRouteId());
|
||||
const previousIndex = target.getItem(INDEX_KEY);
|
||||
const gpxKey = routeKey(id, 'gpx');
|
||||
const jsonKey = routeKey(id, 'json');
|
||||
const previousGpx = target.getItem(gpxKey);
|
||||
const previousJson = target.getItem(jsonKey);
|
||||
const now = new Date().toISOString();
|
||||
const inputRoute = routePackage.route || {};
|
||||
const route = {
|
||||
id,
|
||||
name: String(inputRoute.name || ''),
|
||||
description: String(inputRoute.description || ''),
|
||||
createdAt: inputRoute.createdAt || now,
|
||||
updatedAt: now
|
||||
};
|
||||
const pois = (routePackage.pois || []).map((poi, index) => ({
|
||||
...poi,
|
||||
id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
|
||||
sequence: index
|
||||
}));
|
||||
const gpx = syncGpx(routePackage.gpx || emptyGpx(route.name), route, pois);
|
||||
const metadata = JSON.stringify({ schemaVersion: 1, route, pois });
|
||||
|
||||
try {
|
||||
target.setItem(gpxKey, gpx);
|
||||
target.setItem(jsonKey, metadata);
|
||||
writeIndex([...readIndex(), id]);
|
||||
} catch (error) {
|
||||
try {
|
||||
restoreValue(target, gpxKey, previousGpx);
|
||||
restoreValue(target, jsonKey, previousJson);
|
||||
restoreValue(target, INDEX_KEY, previousIndex);
|
||||
} catch {
|
||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||
}
|
||||
const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||
throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
|
||||
}
|
||||
|
||||
notify(id, 'saved');
|
||||
return readPackage(id);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const target = storage();
|
||||
const routeId = String(id);
|
||||
target.removeItem(routeKey(routeId, 'gpx'));
|
||||
target.removeItem(routeKey(routeId, 'json'));
|
||||
writeIndex(readIndex().filter(item => item !== routeId));
|
||||
notify(routeId, 'removed');
|
||||
}
|
||||
|
||||
function listPackages() {
|
||||
return readIndex().map(readPackage).filter(Boolean);
|
||||
}
|
||||
|
||||
function routePoints(gpx) {
|
||||
const document = parseXml(gpx);
|
||||
const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
|
||||
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
|
||||
const selected = trackPoints.length ? trackPoints : routePoints;
|
||||
return selected.map(node => {
|
||||
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
|
||||
return {
|
||||
lat: numberAttribute(node, 'lat'),
|
||||
lon: numberAttribute(node, 'lon'),
|
||||
elevation: Number.isFinite(elevation) ? elevation : null,
|
||||
recordedAt: textOfChild(node, 'time') || null
|
||||
};
|
||||
}).filter(point => point.lat != null && point.lon != null);
|
||||
}
|
||||
|
||||
function radians(value) {
|
||||
return value * (Math.PI / 180);
|
||||
}
|
||||
|
||||
function meters(left, right) {
|
||||
const earthRadius = 6371008.8;
|
||||
const latDelta = radians(right.lat - left.lat);
|
||||
const lonDelta = radians(right.lon - left.lon);
|
||||
const leftLat = radians(left.lat);
|
||||
const rightLat = radians(right.lat);
|
||||
const value = Math.sin(latDelta / 2) ** 2
|
||||
+ Math.cos(leftLat) * Math.cos(rightLat) * (Math.sin(lonDelta / 2) ** 2);
|
||||
return earthRadius * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function metrics(points) {
|
||||
if (!points.length) {
|
||||
return {
|
||||
bounds: null,
|
||||
center: null,
|
||||
distanceM: 0,
|
||||
elevationGainM: 0,
|
||||
start: null
|
||||
};
|
||||
}
|
||||
let distanceM = 0;
|
||||
let elevationGainM = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
distanceM += meters(points[index - 1], points[index]);
|
||||
const previous = points[index - 1].elevation;
|
||||
const current = points[index].elevation;
|
||||
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) elevationGainM += current - previous;
|
||||
}
|
||||
const lats = points.map(point => point.lat);
|
||||
const lons = points.map(point => point.lon);
|
||||
const bounds = {
|
||||
minLat: Math.min(...lats),
|
||||
minLon: Math.min(...lons),
|
||||
maxLat: Math.max(...lats),
|
||||
maxLon: Math.max(...lons)
|
||||
};
|
||||
return {
|
||||
bounds,
|
||||
center: {
|
||||
lat: (bounds.minLat + bounds.maxLat) / 2,
|
||||
lon: (bounds.minLon + bounds.maxLon) / 2
|
||||
},
|
||||
distanceM,
|
||||
elevationGainM,
|
||||
start: { lat: points[0].lat, lon: points[0].lon }
|
||||
};
|
||||
}
|
||||
|
||||
function appPoi(poi) {
|
||||
return {
|
||||
id: Number(poi.id),
|
||||
title: poi.title || 'Unbenannte Station',
|
||||
description: poi.description || '',
|
||||
lat: Number(poi.lat),
|
||||
lon: Number(poi.lon),
|
||||
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
|
||||
sequence: Number(poi.sequence) || 0,
|
||||
audioUrl: poi.audio?.dataUrl || null,
|
||||
images: (poi.images || []).map((image, index) => ({
|
||||
id: image.id || `${poi.id}-${index}`,
|
||||
caption: image.caption || '',
|
||||
sequence: index,
|
||||
url: image.dataUrl
|
||||
})).filter(image => image.url)
|
||||
};
|
||||
}
|
||||
|
||||
function toAppRoute(routePackage) {
|
||||
const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
|
||||
const parsedPoints = routePoints(routePackage.gpx);
|
||||
const points = parsedPoints.length
|
||||
? parsedPoints
|
||||
: pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
|
||||
const routeMetrics = metrics(points);
|
||||
return {
|
||||
id: String(routePackage.id),
|
||||
source: 'local',
|
||||
local: true,
|
||||
name: routePackage.route?.name || 'Unbenannte Strecke',
|
||||
description: routePackage.route?.description || '',
|
||||
schoolName: 'Lokal gespeichert',
|
||||
status: 'local',
|
||||
activatedAt: null,
|
||||
points,
|
||||
pois,
|
||||
pointCount: points.length,
|
||||
gpxUrl: null,
|
||||
createdAt: routePackage.route?.createdAt || null,
|
||||
updatedAt: routePackage.route?.updatedAt || null,
|
||||
...routeMetrics
|
||||
};
|
||||
}
|
||||
|
||||
function listAppRoutes() {
|
||||
return listPackages().map(toAppRoute);
|
||||
}
|
||||
|
||||
function getAppRoute(id) {
|
||||
const routePackage = readPackage(id);
|
||||
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
|
||||
return toAppRoute(routePackage);
|
||||
}
|
||||
|
||||
function readFileAsText(file) {
|
||||
if (!file) return Promise.resolve('');
|
||||
if (typeof file.text === 'function') return file.text();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fileToDataUrl(file) {
|
||||
if (!file) return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve({
|
||||
dataUrl: String(reader.result || ''),
|
||||
name: file.name || 'Datei',
|
||||
size: Number(file.size) || 0,
|
||||
type: file.type || 'application/octet-stream'
|
||||
}), { once: true });
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
ns.LocalRoutes = Object.freeze({
|
||||
STORAGE_PREFIX,
|
||||
createPoiId,
|
||||
createRouteId,
|
||||
emptyGpx,
|
||||
fileToDataUrl,
|
||||
get: readPackage,
|
||||
getAppRoute,
|
||||
list: listPackages,
|
||||
listAppRoutes,
|
||||
parseWaypoints,
|
||||
readFileAsText,
|
||||
remove,
|
||||
save: savePackage,
|
||||
syncGpx,
|
||||
toAppRoute
|
||||
});
|
||||
}(globalThis.Wegwichtel));
|
||||
Reference in New Issue
Block a user