From 4dfa7da08bcf4b6f2aa1a4bb6232b2973b2282fe Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Tue, 16 Jun 2026 16:20:09 +0200 Subject: [PATCH] Add Navigation Arrow and simplify static page --- public/css/app.css | 271 +++++++++++++++++++++------------- public/css/bootstrap.css | 66 +++++++-- public/index.html | 101 ++++++++----- public/js/app.js | 221 ++++++++++++++++++++++----- public/js/bootstrap-loader.js | 12 +- public/js/distance.js | 27 +++- public/js/geolocation.js | 53 ++++++- public/js/orientation.js | 64 ++++++++ public/js/slideshow.js | 46 +++++- 9 files changed, 667 insertions(+), 194 deletions(-) create mode 100644 public/js/orientation.js diff --git a/public/css/app.css b/public/css/app.css index 479866c..87b965d 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -1,6 +1,6 @@ :root { --page-background: #eef4e9; - --surface: #ffffff; + --surface: #fff; --primary: #315d3a; --primary-dark: #24462b; --text: #1f3022; @@ -8,41 +8,57 @@ --notice: #fff8d6; --notice-border: #d4a72c; --focus: #1e6fba; + --footer-height: 5.25rem; } -html, body { +html, +body { min-height: 100%; background: var(--page-background); color: var(--text); + overscroll-behavior-y: contain; } body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + -webkit-tap-highlight-color: transparent; } -button, audio { +button, +input, +audio { font: inherit; } +button, +[data-page-target] { + touch-action: manipulation; +} + [hidden] { display: none !important; } -#app-shell { - min-height: 100vh; +#app-shell, +.app-page { + min-height: 100dvh; } .app-page { - min-height: 100vh; background: var(--page-background); } -.app-header { +#app-shell.has-navigation .app-page { + padding-bottom: calc(var(--footer-height) + env(safe-area-inset-bottom)); +} + +.app-page > header { position: sticky; top: 0; z-index: 20; display: grid; + grid-template-columns: minmax(0, 1fr); align-items: center; min-height: 3.5rem; padding: env(safe-area-inset-top) max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left)); @@ -51,53 +67,43 @@ button, audio { box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .2); } -.app-header h1 { - margin: 0; - padding: .85rem 0; - font-size: 1.25rem; - line-height: 1.2; - text-align: center; -} - -.app-header-with-back { - grid-template-columns: auto minmax(0, 1fr) auto; +.app-page > header:has(.back-button) { + grid-template-columns: 5.5rem minmax(0, 1fr) 5.5rem; gap: .5rem; } -.app-header-with-back::after { +.app-page > header:has(.back-button)::after { content: ""; - width: 5.5rem; } -.app-header .back-button.ui-button { +.app-page > header h1 { + min-width: 0; + margin: 0; + padding: .85rem 0; + overflow: hidden; + font-size: 1.25rem; + line-height: 1.2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-page > header .back-button.ui-button { width: 5.5rem; + min-height: 2.75rem; margin: 0; border-color: rgba(255, 255, 255, .45); background: rgba(255, 255, 255, .12); color: #fff; } -.app-header .back-button.ui-button:hover, -.app-header .back-button.ui-button:focus { - border-color: #fff; - background: rgba(255, 255, 255, .22); - color: #fff; -} - -.app-content { +.content { width: min(48rem, 100%); box-sizing: border-box; margin: 0 auto; padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); } -.app-footer { - padding: .85rem max(1rem, env(safe-area-inset-right)) calc(.85rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); - background: var(--primary); - color: #fff; - text-align: center; -} - .notice { margin: 0 0 1rem; padding: .75rem; @@ -106,25 +112,17 @@ button, audio { background: var(--notice); } -.route-section + .route-section { +#routes-page .content > section + section { margin-top: 2rem; padding-top: 1.25rem; border-top: 1px solid #c8d3ca; } -.section-heading-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(14rem, 22rem); - gap: 1rem; - align-items: end; +#routes-page h2 { margin-bottom: .75rem; } -.section-heading-row h2 { - margin: 0; -} - -.route-search-controls label { +#routes-page label { display: block; margin-bottom: .35rem; color: var(--muted); @@ -132,7 +130,7 @@ button, audio { font-weight: 600; } -.route-search-row { +.search-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .35rem; @@ -140,13 +138,14 @@ button, audio { #route-search { width: 100%; - min-height: 2.5rem; + min-height: 3rem; box-sizing: border-box; - padding: .5rem .65rem; + padding: .65rem .75rem; border: 1px solid #aebcaf; border-radius: .3rem; background: var(--surface); color: var(--text); + font-size: 1rem; } #route-search:focus { @@ -155,22 +154,23 @@ button, audio { } #clear-route-search.ui-button { + min-width: 3rem; + min-height: 3rem; margin: 0; } -.route-list-summary { - margin: 0 0 .75rem; +#all-routes-summary { + margin: .75rem 0; color: var(--muted); font-size: .9rem; } .ui-autocomplete { - max-height: 16rem; - overflow-y: auto; - overflow-x: hidden; + max-height: min(16rem, 45dvh); + overflow: auto; } -.card-list { +.route-list { display: grid; gap: .75rem; margin: 0; @@ -178,9 +178,10 @@ button, audio { list-style: none; } -.card-link { +.route-list button { display: grid; width: 100%; + min-height: 3.5rem; box-sizing: border-box; gap: .25rem; padding: 1rem; @@ -193,38 +194,33 @@ button, audio { cursor: pointer; } -.card-link:hover { - border-color: var(--primary); - box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13); +.route-list button > strong { + font-size: 1.05rem; } -.card-link:focus-visible { +.route-list button > span, +.route-list button > small { + color: var(--muted); +} + +.route-list button > small { + margin-top: .25rem; + font-weight: 600; +} + +.route-list button:focus-visible, +.ui-button:focus-visible { outline: .2rem solid var(--focus); outline-offset: .15rem; } -.card-link:disabled { +.route-list button:disabled { cursor: progress; opacity: .65; } -.card-title { - font-size: 1.05rem; - font-weight: 700; -} - -.card-description, -.route-distance { - color: var(--muted); -} - -.route-distance { - margin-top: .25rem; - font-size: .9rem; - font-weight: 600; -} - -.empty-list { +.route-list > li > p { + margin: 0; padding: 1rem; border-radius: .5rem; background: var(--surface); @@ -257,43 +253,39 @@ button, audio { #start-tracking.ui-button { width: 100%; + min-height: 3rem; margin: 0 0 1rem; border-color: var(--primary-dark); background: var(--primary); color: #fff; } -#start-tracking.ui-button:hover, -#start-tracking.ui-button:focus { - background: var(--primary-dark); - color: #fff; -} - -.slideshow { +#slideshow { overflow: hidden; border-radius: .75rem; background: #172019; color: #fff; + touch-action: pan-y; } -.slideshow figure { +#slideshow figure { margin: 0; } -.slideshow img { +#slideshow img { display: block; width: 100%; - height: min(55vh, 28rem); + height: min(55dvh, 28rem); object-fit: contain; background: #111; } -.slideshow figcaption { +#slideshow figcaption { min-height: 1.4rem; padding: .5rem .75rem; } -.slide-controls { +#slide-controls { display: grid; grid-template-columns: auto minmax(4rem, 1fr) auto; align-items: center; @@ -303,14 +295,19 @@ button, audio { text-align: center; } -.slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item { +#slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item { float: none; } +#slide-controls .ui-button { + min-width: 3rem; + min-height: 3rem; +} + #slide-position { display: grid; align-items: center; - min-height: 2.2rem; + min-height: 2.75rem; } #poi-audio { @@ -318,20 +315,94 @@ button, audio { margin-top: 1rem; } -.ui-button:focus-visible { - outline: .2rem solid var(--focus); - outline-offset: .12rem; +#navigation-footer { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 40; + display: grid; + grid-template-columns: 3.75rem minmax(0, 1fr); + align-items: center; + min-height: var(--footer-height); + box-sizing: border-box; + gap: .85rem; + padding: .6rem max(1rem, env(safe-area-inset-right)) calc(.6rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); + border-top: 1px solid rgba(255, 255, 255, .35); + background: rgba(36, 70, 43, .96); + color: #fff; + box-shadow: 0 -.25rem .8rem rgba(0, 0, 0, .2); + backdrop-filter: blur(.4rem); } -@media (max-width: 620px) { - .section-heading-row { - grid-template-columns: 1fr; - align-items: stretch; +#navigation-arrow { + width: 3.5rem; + height: 3.5rem; + fill: currentColor; + transform: rotate(0deg); + transform-origin: 50% 50%; + transition: transform 180ms linear; + will-change: transform; +} + +#navigation-footer p { + display: grid; + gap: .15rem; + margin: 0; +} + +#navigation-mode { + overflow: hidden; + font-size: .78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +#navigation-distance { + font-size: 1.5rem; + line-height: 1.1; +} + +@media (hover: hover) { + .route-list button:hover { + border-color: var(--primary); + box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13); + } + + .app-page > header .back-button.ui-button:hover, + .app-page > header .back-button.ui-button:focus, + #start-tracking.ui-button:hover, + #start-tracking.ui-button:focus { + background: var(--primary-dark); + color: #fff; } } -@media (max-width: 420px) { +@media (max-width: 32rem) { + .search-row { + grid-template-columns: minmax(0, 1fr) 3rem; + } + + .route-facts { + gap: .35rem; + } + + .route-facts div { + padding: .6rem .25rem; + } +} + +@media (max-width: 22rem) { .route-facts { grid-template-columns: 1fr; } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: .01ms !important; + } +} diff --git a/public/css/bootstrap.css b/public/css/bootstrap.css index 0580849..b7599b6 100644 --- a/public/css/bootstrap.css +++ b/public/css/bootstrap.css @@ -1,9 +1,57 @@ -html, body { min-height: 100%; margin: 0; font-family: system-ui, sans-serif; background: #eef4e9; color: #1f3022; } -#bootstrap-screen { position: fixed; inset: 0; display: grid; place-items: center; padding: 1rem; z-index: 9999; } -.bootstrap-card { width: min(30rem, 100%); background: white; border-radius: 1rem; padding: 1.5rem; box-shadow: 0 .5rem 2rem rgba(0,0,0,.15); text-align: center; } -.mascot { font-size: 4rem; } -progress { width: 100%; height: 1.2rem; } -#bootstrap-steps { text-align: left; line-height: 1.8; } -#bootstrap-steps li.done::marker { content: '✓ '; } -#bootstrap-steps li.failed { color: #8b1e1e; } -#bootstrap-error { background: #fee; color: #701010; padding: .75rem; border-radius: .5rem; } +html, +body { + min-height: 100%; + margin: 0; + background: #eef4e9; + color: #1f3022; + font-family: system-ui, sans-serif; +} + +#bootstrap-screen { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + box-sizing: border-box; + padding: 1rem; +} + +#bootstrap-screen > section { + width: min(30rem, 100%); + box-sizing: border-box; + padding: 1.5rem; + border-radius: 1rem; + background: #fff; + box-shadow: 0 .5rem 2rem rgba(0, 0, 0, .15); + text-align: center; +} + +#bootstrap-screen > section > span { + font-size: 4rem; +} + +#bootstrap-progress { + width: 100%; + height: 1.2rem; +} + +#bootstrap-steps { + line-height: 1.8; + text-align: left; +} + +#bootstrap-steps li.done::marker { + content: "✓ "; +} + +#bootstrap-steps li.failed { + color: #8b1e1e; +} + +#bootstrap-error { + padding: .75rem; + border-radius: .5rem; + background: #fee; + color: #701010; +} diff --git a/public/index.html b/public/index.html index 0c14e34..c1f6fa8 100644 --- a/public/index.html +++ b/public/index.html @@ -4,16 +4,18 @@ - Wegwichtel – Atlas + Wegwichtel
-
- +
+

Wegwichtel

-

Atlas bereitet Routen und Medien vor.

+

Der Wegwichtel bereitet Routen und Medien vor.

+ +
  1. jQuery laden
  2. jQuery UI laden
  3. @@ -22,80 +24,103 @@
  4. Routen laden
  5. Standort ermitteln
+ -
+
-
-
-

Wegwichtel Atlas

+
+
+

Wegwichtel

-
-
Standort wird ermittelt.
-
-

Routen in deiner Nähe

-
    +
    +

    Standort wird ermittelt.

    + +
    +

    Routen in deiner Nähe

    +
      -
      -
      -

      Alle Routen

      -
      - -
      - - -
      -
      +
      +

      Alle Routen

      + + +
      + +
      -

      -
        + +

        +
          -
          GPS-Lernwege
          -
          diff --git a/public/js/app.js b/public/js/app.js index 5067d17..cf1212c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -7,13 +7,31 @@ position: null, triggered: new Set(), tracking: false, - activePage: null + activePage: null, + routeProgressIndex: null, + deviceHeading: null }; function escapeHtml(value) { return $('
          ').text(value ?? '').html(); } + function activate(selector, handler) { + $(document) + .on('pointerup', selector, function (event) { + const pointer = event.originalEvent; + if (pointer && pointer.isPrimary === false) return; + if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return; + event.preventDefault(); + handler.call(this, event); + }) + .on('keydown', selector, function (event) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handler.call(this, event); + }); + } + function navigate(pageSelector, options = {}) { const target = $(pageSelector); if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`); @@ -49,19 +67,20 @@ function renderRouteItems(listSelector, routes, emptyText) { const list = $(listSelector).empty(); if (!routes.length) { - list.append(`
        • ${escapeHtml(emptyText)}
        • `); + list.append(`
        • ${escapeHtml(emptyText)}

        • `); return; } routes.forEach(route => { const proximity = route.proximityM == null ? '' - : `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`; + : `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`; + list.append(`
        • -
        • @@ -88,8 +107,11 @@ : 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.'; renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText); - renderRouteItems('#all-route-list', allRoutes, - searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'); + renderRouteItems( + '#all-route-list', + allRoutes, + searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.' + ); $('#all-routes-summary').text( searchTerm @@ -117,9 +139,43 @@ }); } + function hideNavigation() { + $('#navigation-footer').prop('hidden', true); + $('#app-shell').removeClass('has-navigation'); + } + + function showNavigation(message = 'Position wird ermittelt', distance = '–') { + $('#navigation-mode').text(message); + $('#navigation-distance').text(distance); + $('#navigation-footer').prop('hidden', false); + $('#app-shell').addClass('has-navigation'); + } + + function updateTrackingButton() { + const button = $('#start-tracking'); + button.button('option', { + label: state.tracking ? 'GPS-Begleitung stoppen' : 'GPS-Begleitung starten', + icon: state.tracking ? 'ui-icon-stop' : 'ui-icon-pin-s' + }); + } + + function stopTracking() { + state.tracking = false; + state.routeProgressIndex = null; + state.deviceHeading = null; + ns.Geo.stop(); + ns.Orientation.stop(); + $('#tracking-status').prop('hidden', true).text(''); + hideNavigation(); + updateTrackingButton(); + } + function renderRoute(route) { + if (state.tracking) stopTracking(); + state.route = route; state.triggered.clear(); + state.routeProgressIndex = null; $('#route-title').text(route.name); $('#route-description').text(route.description || ''); $('#route-distance').text(ns.Distance.format(route.distanceM)); @@ -128,16 +184,16 @@ const list = $('#poi-list').empty(); if (!route.pois.length) { - list.append('
        • Diese Route enthält noch keine Stationen.
        • '); + list.append('
        • Diese Route enthält noch keine Stationen.

        • '); return; } route.pois.forEach(poi => { list.append(`
        • -
        • `); @@ -153,10 +209,99 @@ if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {}); } + function nearestRoutePointIndex(position) { + const points = state.route?.points || []; + if (!points.length) return null; + + let start = 0; + let end = points.length; + if (state.routeProgressIndex != null) { + 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) { + const distance = ns.Distance.meters(position, points[index]); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + } + + return nearestIndex; + } + + function updateNavigation(position) { + const points = state.route?.points || []; + if (!state.tracking || !points.length) return; + + const nearestIndex = nearestRoutePointIndex(position); + if (nearestIndex == null) return; + + 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; + target = points[targetIndex]; + distance = ns.Distance.meters(position, target); + } + + if (targetIndex === lastIndex && distance <= reachedRadius) { + state.routeProgressIndex = lastIndex; + $('#navigation-arrow').css('transform', 'rotate(0deg)'); + showNavigation('Ziel der Route erreicht', '0 m'); + $('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht'); + return; + } + + const bearing = ns.Distance.bearing(position, target); + const movementHeading = Number.isFinite(position.heading) && Number(position.speed) > 0.5 + ? position.heading + : null; + const heading = state.deviceHeading ?? movementHeading; + const rotation = heading == null + ? bearing + : ns.Distance.normalizeDegrees(bearing - heading); + + $('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`); + showNavigation( + heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt', + `${Math.round(distance)} m` + ); + $('#navigation-footer').attr( + 'aria-label', + `Nächster Routenpunkt in ${Math.round(distance)} Metern` + ); + } + + function updateHeading(heading) { + if (state.deviceHeading == null) { + state.deviceHeading = heading; + } else { + 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; if (!state.route || !state.tracking) return; + updateNavigation(position); + let nearest = null; state.route.pois.forEach(poi => { if (state.triggered.has(poi.id)) return; @@ -165,8 +310,8 @@ }); $('#tracking-status').text( - `GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` + - (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '') + `GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` + + (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '') ); if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) { @@ -176,22 +321,33 @@ } } - function updateTrackingButton() { - const button = $('#start-tracking'); - button.button('option', { - label: state.tracking ? 'GPS-Begleitung stoppen' : 'GPS-Begleitung starten', - icon: state.tracking ? 'ui-icon-stop' : 'ui-icon-pin-s' - }); + async function startTracking() { + state.tracking = true; + state.routeProgressIndex = null; + state.deviceHeading = null; + updateTrackingButton(); + $('#tracking-status').prop('hidden', false).text('GPS wird gestartet.'); + showNavigation(); + + ns.Geo.start( + evaluatePosition, + error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`) + ); + + const orientationAvailable = await ns.Orientation.start(updateHeading); + if (!orientationAvailable && state.tracking) { + $('#navigation-mode').text('Norden oben · nächster Routenpunkt'); + } } function bindEvents() { $(document).on('input', '#route-search', renderRoutes); - $(document).on('click', '#clear-route-search', function () { + activate('#clear-route-search', function () { $('#route-search').val('').trigger('input').trigger('focus'); }); - $(document).on('click', '[data-route-id]', async function () { + activate('[data-route-id]', async function () { const button = $(this); button.prop('disabled', true); try { @@ -205,26 +361,20 @@ } }); - $(document).on('click', '[data-poi-id]', function () { + activate('[data-poi-id]', function () { const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id'))); if (poi) showPoi(poi, false); }); - $(document).on('click', '[data-page-target]', function () { + activate('[data-page-target]', function () { const target = $(this).data('page-target'); if (history.state?.previous === target) history.back(); else navigate(target, { replace: true }); }); - $(document).on('click', '#start-tracking', function () { - state.tracking = !state.tracking; - updateTrackingButton(); - $('#tracking-status').toggle(state.tracking); - if (state.tracking) { - ns.Geo.start(evaluatePosition, error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)); - } else { - ns.Geo.stop(); - } + activate('#start-tracking', async function () { + if (state.tracking) stopTracking(); + else await startTracking(); }); window.addEventListener('popstate', event => { @@ -249,8 +399,8 @@ const position = await ns.Geo.current(); state.position = position; $('#location-hint').text( - `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` + - `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.` + `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` + + `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.` ); state.routes = state.routes.map(route => ({ ...route, @@ -266,6 +416,7 @@ initializeRouteSearch(); renderRoutes(); updateTrackingButton(); + hideNavigation(); } }; }(window.Wegwichtel, window.jQuery)); diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js index 6d13d76..d794d63 100644 --- a/public/js/bootstrap-loader.js +++ b/public/js/bootstrap-loader.js @@ -71,7 +71,7 @@ initializeJqueryUi(); mark('jquery-ui', 'done'); - for (const module of ['config', 'api-client', 'distance', 'geolocation', 'slideshow', 'audio-player', 'app']) { + for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'app']) { await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel)); } mark('modules', 'done'); @@ -90,6 +90,14 @@ } } - retry.addEventListener('click', () => location.reload()); + function retryBoot(event) { + if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; + if (event.type === 'pointerup' && event.pointerType === 'mouse' && event.button !== 0) return; + event.preventDefault(); + location.reload(); + } + + retry.addEventListener('pointerup', retryBoot); + retry.addEventListener('keydown', retryBoot); boot(); }()); diff --git a/public/js/distance.js b/public/js/distance.js index d3f7249..0986261 100644 --- a/public/js/distance.js +++ b/public/js/distance.js @@ -1,12 +1,33 @@ (function (ns) { 'use strict'; + const radians = value => value * Math.PI / 180; + const degrees = value => value * 180 / Math.PI; + const normalizeDegrees = value => (value % 360 + 360) % 360; + ns.Distance = { meters(a, b) { - const dLat = radians(b.lat - a.lat); const dLon = radians(b.lon - a.lon); - const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2; + const dLat = radians(b.lat - a.lat); + const dLon = radians(b.lon - a.lon); + const h = Math.sin(dLat / 2) ** 2 + + Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2; return 2 * 6371000 * Math.asin(Math.sqrt(h)); }, - format(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; } + + bearing(a, b) { + const lat1 = radians(a.lat); + const lat2 = radians(b.lat); + const dLon = radians(b.lon - a.lon); + const y = Math.sin(dLon) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) + - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + return normalizeDegrees(degrees(Math.atan2(y, x))); + }, + + normalizeDegrees, + + format(meters) { + return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; + } }; }(window.Wegwichtel)); diff --git a/public/js/geolocation.js b/public/js/geolocation.js index 70cbe3c..0b0d097 100644 --- a/public/js/geolocation.js +++ b/public/js/geolocation.js @@ -1,18 +1,61 @@ (function (ns) { 'use strict'; + let watchId = null; - function normalize(position) { return { lat: position.coords.latitude, lon: position.coords.longitude, accuracy: position.coords.accuracy, timestamp: position.timestamp }; } + + function normalize(position) { + return { + lat: position.coords.latitude, + lon: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + heading: position.coords.heading, + speed: position.coords.speed, + timestamp: position.timestamp + }; + } + ns.Geo = { current() { return new Promise((resolve, reject) => { - if (!navigator.geolocation) return reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.')); - navigator.geolocation.getCurrentPosition(p => resolve(normalize(p)), reject, { enableHighAccuracy: true, timeout: ns.Config.geolocationTimeoutMs, maximumAge: 30000 }); + if (!navigator.geolocation) { + reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.')); + return; + } + + navigator.geolocation.getCurrentPosition( + position => resolve(normalize(position)), + reject, + { + enableHighAccuracy: true, + timeout: ns.Config.geolocationTimeoutMs, + maximumAge: 30000 + } + ); }); }, + start(callback, onError) { this.stop(); - watchId = navigator.geolocation.watchPosition(p => callback(normalize(p)), onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 5000 }); + if (!navigator.geolocation) { + onError?.(new Error('Dieses Gerät unterstützt keine Geolokalisierung.')); + return; + } + + watchId = navigator.geolocation.watchPosition( + position => callback(normalize(position)), + onError, + { + enableHighAccuracy: true, + timeout: 20000, + maximumAge: 5000 + } + ); }, - stop() { if (watchId != null) navigator.geolocation.clearWatch(watchId); watchId = null; } + + stop() { + if (watchId != null) navigator.geolocation.clearWatch(watchId); + watchId = null; + } }; }(window.Wegwichtel)); diff --git a/public/js/orientation.js b/public/js/orientation.js new file mode 100644 index 0000000..5ed9f1a --- /dev/null +++ b/public/js/orientation.js @@ -0,0 +1,64 @@ +(function (ns) { + 'use strict'; + + let listener = null; + const eventNames = ['deviceorientationabsolute', 'deviceorientation']; + let callback = null; + + function normalizeDegrees(value) { + return (value % 360 + 360) % 360; + } + + function readHeading(event) { + if (Number.isFinite(event.webkitCompassHeading)) { + return normalizeDegrees(event.webkitCompassHeading); + } + + if ((event.absolute || event.type === 'deviceorientationabsolute') && Number.isFinite(event.alpha)) { + return normalizeDegrees(360 - event.alpha); + } + + return null; + } + + async function requestPermission() { + const orientation = window.DeviceOrientationEvent; + if (!orientation) return false; + + if (typeof orientation.requestPermission === 'function') { + return (await orientation.requestPermission()) === 'granted'; + } + + return true; + } + + ns.Orientation = { + async start(onHeading) { + this.stop(); + callback = onHeading; + + let permitted = false; + try { + permitted = await requestPermission(); + } catch { + permitted = false; + } + + if (!permitted) return false; + + listener = event => { + const heading = readHeading(event); + if (heading != null) callback?.(heading); + }; + + eventNames.forEach(name => window.addEventListener(name, listener, true)); + return true; + }, + + stop() { + if (listener) eventNames.forEach(name => window.removeEventListener(name, listener, true)); + listener = null; + callback = null; + } + }; +}(window.Wegwichtel)); diff --git a/public/js/slideshow.js b/public/js/slideshow.js index 7960c2a..1245c32 100644 --- a/public/js/slideshow.js +++ b/public/js/slideshow.js @@ -3,6 +3,23 @@ let images = []; let index = 0; + let swipe = null; + + function activate(selector, handler) { + $(document) + .on('pointerup', selector, function (event) { + const pointer = event.originalEvent; + if (pointer && pointer.isPrimary === false) return; + if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return; + event.preventDefault(); + handler.call(this, event); + }) + .on('keydown', selector, function (event) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handler.call(this, event); + }); + } function refreshButton(selector, disabled) { const button = $(selector).prop('disabled', disabled); @@ -30,17 +47,42 @@ index = 0; render(); }, + next() { if (images.length) index = (index + 1) % images.length; render(); }, + previous() { if (images.length) index = (index - 1 + images.length) % images.length; render(); } }; + activate('#slide-next', () => ns.Slideshow.next()); + activate('#slide-prev', () => ns.Slideshow.previous()); + $(document) - .on('click', '#slide-next', () => ns.Slideshow.next()) - .on('click', '#slide-prev', () => ns.Slideshow.previous()); + .on('pointerdown', '#slideshow', function (event) { + if ($(event.target).closest('button').length) return; + const pointer = event.originalEvent; + if (!pointer?.isPrimary) return; + swipe = { id: pointer.pointerId, x: pointer.clientX, y: pointer.clientY }; + }) + .on('pointerup pointercancel', '#slideshow', function (event) { + const pointer = event.originalEvent; + if (!swipe || pointer?.pointerId !== swipe.id) return; + if (event.type === 'pointercancel') { + swipe = null; + return; + } + + const deltaX = pointer.clientX - swipe.x; + const deltaY = pointer.clientY - swipe.y; + swipe = null; + + if (Math.abs(deltaX) < 45 || Math.abs(deltaX) <= Math.abs(deltaY)) return; + if (deltaX < 0) ns.Slideshow.next(); + else ns.Slideshow.previous(); + }); }(window.Wegwichtel, window.jQuery));