initial commit
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
:root {
|
||||
--page-background: #eef4e9;
|
||||
--surface: #ffffff;
|
||||
--primary: #315d3a;
|
||||
--primary-dark: #24462b;
|
||||
--text: #1f3022;
|
||||
--muted: #526258;
|
||||
--notice: #fff8d6;
|
||||
--notice-border: #d4a72c;
|
||||
--focus: #1e6fba;
|
||||
}
|
||||
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
background: var(--page-background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, audio {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
min-height: 100vh;
|
||||
background: var(--page-background);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
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));
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
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;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.app-header-with-back::after {
|
||||
content: "";
|
||||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.app-header .back-button.ui-button {
|
||||
width: 5.5rem;
|
||||
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 {
|
||||
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;
|
||||
border-left: .35rem solid var(--notice-border);
|
||||
border-radius: .25rem;
|
||||
background: var(--notice);
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: .25rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #c8d3ca;
|
||||
border-radius: .65rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 .12rem .35rem rgba(0, 0, 0, .08);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13);
|
||||
}
|
||||
|
||||
.card-link:focus-visible {
|
||||
outline: .2rem solid var(--focus);
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.card-link: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 {
|
||||
padding: 1rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.route-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: .5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.route-facts div {
|
||||
padding: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.route-facts dt {
|
||||
color: var(--muted);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.route-facts dd {
|
||||
margin: .25rem 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#start-tracking.ui-button {
|
||||
width: 100%;
|
||||
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 {
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
background: #172019;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slideshow figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slideshow img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(55vh, 28rem);
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.slideshow figcaption {
|
||||
min-height: 1.4rem;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
|
||||
.slide-controls {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(4rem, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item {
|
||||
float: none;
|
||||
}
|
||||
|
||||
#slide-position {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
min-height: 2.2rem;
|
||||
}
|
||||
|
||||
#poi-audio {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.ui-button:focus-visible {
|
||||
outline: .2rem solid var(--focus);
|
||||
outline-offset: .12rem;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.route-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
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; }
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#315d3a">
|
||||
<title>Wegwichtel – Atlas</title>
|
||||
<link rel="stylesheet" href="css/bootstrap.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="bootstrap-screen" role="status" aria-live="polite">
|
||||
<div class="bootstrap-card">
|
||||
<div class="mascot" aria-hidden="true">🧭</div>
|
||||
<h1>Wegwichtel</h1>
|
||||
<p>Atlas bereitet Routen und Medien vor.</p>
|
||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||
<ol id="bootstrap-steps">
|
||||
<li data-step="jquery">jQuery laden</li>
|
||||
<li data-step="jquery-ui">jQuery UI laden</li>
|
||||
<li data-step="modules">Anwendungsmodule laden</li>
|
||||
<li data-step="server">Server prüfen</li>
|
||||
<li data-step="routes">Routen laden</li>
|
||||
<li data-step="location">Standort ermitteln</li>
|
||||
</ol>
|
||||
<p id="bootstrap-error" hidden></p>
|
||||
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="app-shell" hidden>
|
||||
<section class="app-page" id="routes-page" aria-labelledby="routes-heading">
|
||||
<header class="app-header">
|
||||
<h1 id="routes-heading">Wegwichtel <small>Atlas</small></h1>
|
||||
</header>
|
||||
<div class="app-content">
|
||||
<div id="location-hint" class="notice">Standort wird ermittelt.</div>
|
||||
<h2>Routen in deiner Nähe</h2>
|
||||
<ul id="route-list" class="card-list" aria-live="polite"></ul>
|
||||
</div>
|
||||
<footer class="app-footer">GPS-Lernwege</footer>
|
||||
</section>
|
||||
|
||||
<section class="app-page" id="route-page" aria-labelledby="route-title" hidden>
|
||||
<header class="app-header app-header-with-back">
|
||||
<button class="back-button" type="button" data-page-target="#routes-page">Zurück</button>
|
||||
<h1 id="route-title">Route</h1>
|
||||
</header>
|
||||
<div class="app-content">
|
||||
<p id="route-description"></p>
|
||||
<dl class="route-facts">
|
||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||
</dl>
|
||||
<button id="start-tracking" type="button">GPS-Begleitung starten</button>
|
||||
<div id="tracking-status" class="notice" hidden></div>
|
||||
<h2>Stationen</h2>
|
||||
<ul id="poi-list" class="card-list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-page" id="poi-page" aria-labelledby="poi-title" hidden>
|
||||
<header class="app-header app-header-with-back">
|
||||
<button class="back-button" type="button" data-page-target="#route-page">Zurück</button>
|
||||
<h1 id="poi-title">Station</h1>
|
||||
</header>
|
||||
<div class="app-content">
|
||||
<div id="slideshow" class="slideshow" aria-live="polite">
|
||||
<figure><img id="slide-image" alt=""><figcaption id="slide-caption"></figcaption></figure>
|
||||
<div id="slide-controls" class="slide-controls">
|
||||
<button id="slide-prev" type="button">Vorheriges Bild</button>
|
||||
<span id="slide-position" aria-live="polite">0 / 0</span>
|
||||
<button id="slide-next" type="button">Nächstes Bild</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="poi-description"></p>
|
||||
<audio id="poi-audio" controls preload="metadata"></audio>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="js/bootstrap-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
function request(path) {
|
||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||
}
|
||||
ns.Api = {
|
||||
health: () => request('/health'),
|
||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
@@ -0,0 +1,198 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
const state = {
|
||||
routes: [],
|
||||
route: null,
|
||||
position: null,
|
||||
triggered: new Set(),
|
||||
tracking: false,
|
||||
activePage: null
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return $('<div>').text(value ?? '').html();
|
||||
}
|
||||
|
||||
function navigate(pageSelector, options = {}) {
|
||||
const target = $(pageSelector);
|
||||
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
|
||||
|
||||
const previous = state.activePage;
|
||||
$('.app-page').attr('hidden', true).removeClass('is-active');
|
||||
target.removeAttr('hidden').addClass('is-active');
|
||||
state.activePage = pageSelector;
|
||||
window.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);
|
||||
else if (previous !== pageSelector) history.pushState(historyState, '', pageSelector);
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
const list = $('#route-list').empty();
|
||||
if (!state.routes.length) {
|
||||
list.append('<li class="empty-list">Keine Route in der Nähe gefunden.</li>');
|
||||
return;
|
||||
}
|
||||
|
||||
state.routes.forEach(route => {
|
||||
const proximity = route.proximityM == null
|
||||
? ''
|
||||
: `<span class="route-distance">${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</span>`;
|
||||
list.append(`
|
||||
<li>
|
||||
<button class="card-link" type="button" data-route-id="${route.id}">
|
||||
<span class="card-title">${escapeHtml(route.name)}</span>
|
||||
<span class="card-description">${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||
${proximity}
|
||||
</button>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoute(route) {
|
||||
state.route = route;
|
||||
state.triggered.clear();
|
||||
$('#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-poi-count').text(route.pois.length);
|
||||
|
||||
const list = $('#poi-list').empty();
|
||||
if (!route.pois.length) {
|
||||
list.append('<li class="empty-list">Diese Route enthält noch keine Stationen.</li>');
|
||||
return;
|
||||
}
|
||||
|
||||
route.pois.forEach(poi => {
|
||||
list.append(`
|
||||
<li>
|
||||
<button class="card-link" type="button" data-poi-id="${poi.id}">
|
||||
<span class="card-title">${escapeHtml(poi.title)}</span>
|
||||
<span class="card-description">${escapeHtml(poi.description || '')}</span>
|
||||
</button>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function showPoi(poi, automatic) {
|
||||
$('#poi-title').text(poi.title);
|
||||
$('#poi-description').text(poi.description || '');
|
||||
ns.Slideshow.show(poi.images);
|
||||
ns.AudioPlayer.load(poi.audioUrl);
|
||||
navigate('#poi-page');
|
||||
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
|
||||
}
|
||||
|
||||
function evaluatePosition(position) {
|
||||
state.position = position;
|
||||
if (!state.route || !state.tracking) return;
|
||||
|
||||
let nearest = null;
|
||||
state.route.pois.forEach(poi => {
|
||||
if (state.triggered.has(poi.id)) return;
|
||||
const distance = ns.Distance.meters(position, poi);
|
||||
if (!nearest || distance < nearest.distance) nearest = { poi, distance };
|
||||
});
|
||||
|
||||
$('#tracking-status').text(
|
||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` +
|
||||
(nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
|
||||
);
|
||||
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
|
||||
showPoi(nearest.poi, true);
|
||||
}
|
||||
}
|
||||
|
||||
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 bindEvents() {
|
||||
$(document).on('click', '[data-route-id]', 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');
|
||||
} catch (error) {
|
||||
alert(error.responseJSON?.message || error.statusText || error.message);
|
||||
} finally {
|
||||
button.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '[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 () {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', event => {
|
||||
const page = event.state?.page || '#routes-page';
|
||||
navigate(page, { updateHistory: false, instant: true });
|
||||
});
|
||||
}
|
||||
|
||||
ns.App = {
|
||||
navigate,
|
||||
|
||||
async initialize(context) {
|
||||
bindEvents();
|
||||
await ns.Api.health();
|
||||
context.markStep('server', 'done');
|
||||
|
||||
const response = await ns.Api.routes(null);
|
||||
state.routes = response.routes;
|
||||
context.markStep('routes', 'done');
|
||||
|
||||
try {
|
||||
const position = await ns.Geo.current();
|
||||
state.position = position;
|
||||
$('#location-hint').text(`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m).`);
|
||||
state.routes = state.routes
|
||||
.map(route => ({
|
||||
...route,
|
||||
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
|
||||
}))
|
||||
.filter(route => route.proximityM == null || route.proximityM <= ns.Config.routeRadiusKm * 1000)
|
||||
.sort((a, b) => (a.proximityM ?? Number.MAX_VALUE) - (b.proximityM ?? Number.MAX_VALUE));
|
||||
} catch (error) {
|
||||
$('#location-hint').text(`Standort nicht verfügbar: ${error.message}. Es werden alle Routen angezeigt.`);
|
||||
}
|
||||
|
||||
context.markStep('location', 'done');
|
||||
renderRoutes();
|
||||
updateTrackingButton();
|
||||
}
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
@@ -0,0 +1,11 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
ns.AudioPlayer = {
|
||||
load(url) {
|
||||
const audio = $('#poi-audio');
|
||||
audio[0].pause(); audio.attr('src', url || '').toggle(Boolean(url));
|
||||
if (url) audio[0].load();
|
||||
},
|
||||
play() { const audio = $('#poi-audio')[0]; if (audio.src) return audio.play(); }
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const versions = Object.freeze({ jquery: '4.0.0', jqueryUi: '1.14.2' });
|
||||
const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location'];
|
||||
const progress = document.getElementById('bootstrap-progress');
|
||||
const errorBox = document.getElementById('bootstrap-error');
|
||||
const retry = document.getElementById('bootstrap-retry');
|
||||
|
||||
function mark(step, state) {
|
||||
const item = document.querySelector(`[data-step="${step}"]`);
|
||||
if (!item) return;
|
||||
item.classList.remove('done', 'failed');
|
||||
item.classList.add(state);
|
||||
progress.value = document.querySelectorAll('#bootstrap-steps .done').length;
|
||||
}
|
||||
|
||||
function loadScript(src, test) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = false;
|
||||
script.onload = () => {
|
||||
if (test && !test()) reject(new Error(`Selbsttest fehlgeschlagen: ${src}`));
|
||||
else resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error(`Datei konnte nicht geladen werden: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStyle(href) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.onload = resolve;
|
||||
link.onerror = () => reject(new Error(`Stylesheet konnte nicht geladen werden: ${href}`));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
function initializeJqueryUi() {
|
||||
const $ = window.jQuery;
|
||||
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
|
||||
$('.back-button').button({ icon: 'ui-icon-caret-1-w' });
|
||||
$('#start-tracking').button({ icon: 'ui-icon-pin-s' });
|
||||
$('#slide-prev').button({ icon: 'ui-icon-caret-1-w', showLabel: false });
|
||||
$('#slide-next').button({ icon: 'ui-icon-caret-1-e', showLabel: false });
|
||||
$('#slide-controls').controlgroup();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
errorBox.hidden = true;
|
||||
retry.hidden = true;
|
||||
|
||||
try {
|
||||
await loadScript(
|
||||
`vendor/jquery/jquery-${versions.jquery}.min.js`,
|
||||
() => window.jQuery?.fn?.jquery === versions.jquery
|
||||
);
|
||||
mark('jquery', 'done');
|
||||
|
||||
await loadStyle(`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.css`);
|
||||
await loadStyle('css/app.css');
|
||||
await loadScript(
|
||||
`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.js`,
|
||||
() => window.jQuery?.ui?.version === versions.jqueryUi
|
||||
);
|
||||
initializeJqueryUi();
|
||||
mark('jquery-ui', 'done');
|
||||
|
||||
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'slideshow', 'audio-player', 'app']) {
|
||||
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
|
||||
}
|
||||
mark('modules', 'done');
|
||||
|
||||
await window.Wegwichtel.App.initialize({ markStep: mark });
|
||||
document.getElementById('bootstrap-screen').hidden = true;
|
||||
document.getElementById('app-shell').hidden = false;
|
||||
window.Wegwichtel.App.navigate('#routes-page', { replace: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
|
||||
if (current) mark(current, 'failed');
|
||||
errorBox.textContent = error.message;
|
||||
errorBox.hidden = false;
|
||||
retry.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
retry.addEventListener('click', () => location.reload());
|
||||
boot();
|
||||
}());
|
||||
@@ -0,0 +1,2 @@
|
||||
window.Wegwichtel = window.Wegwichtel || {};
|
||||
window.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
|
||||
@@ -0,0 +1,12 @@
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
const radians = value => value * Math.PI / 180;
|
||||
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;
|
||||
return 2 * 6371000 * Math.asin(Math.sqrt(h));
|
||||
},
|
||||
format(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; }
|
||||
};
|
||||
}(window.Wegwichtel));
|
||||
@@ -0,0 +1,18 @@
|
||||
(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 }; }
|
||||
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 });
|
||||
});
|
||||
},
|
||||
start(callback, onError) {
|
||||
this.stop();
|
||||
watchId = navigator.geolocation.watchPosition(p => callback(normalize(p)), onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 5000 });
|
||||
},
|
||||
stop() { if (watchId != null) navigator.geolocation.clearWatch(watchId); watchId = null; }
|
||||
};
|
||||
}(window.Wegwichtel));
|
||||
@@ -0,0 +1,46 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
let images = [];
|
||||
let index = 0;
|
||||
|
||||
function refreshButton(selector, disabled) {
|
||||
const button = $(selector).prop('disabled', disabled);
|
||||
if (button.hasClass('ui-button')) button.button('refresh');
|
||||
}
|
||||
|
||||
function render() {
|
||||
const hasImages = images.length > 0;
|
||||
$('#slideshow').toggle(hasImages);
|
||||
if (!hasImages) return;
|
||||
|
||||
const image = images[index];
|
||||
$('#slide-image')
|
||||
.attr('src', image.url)
|
||||
.attr('alt', image.caption || 'Bild zur Station');
|
||||
$('#slide-caption').text(image.caption || '');
|
||||
$('#slide-position').text(`${index + 1} / ${images.length}`);
|
||||
refreshButton('#slide-prev', images.length < 2);
|
||||
refreshButton('#slide-next', images.length < 2);
|
||||
}
|
||||
|
||||
ns.Slideshow = {
|
||||
show(items) {
|
||||
images = items || [];
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
$(document)
|
||||
.on('click', '#slide-next', () => ns.Slideshow.next())
|
||||
.on('click', '#slide-prev', () => ns.Slideshow.previous());
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
# Lokale Client-Abhängigkeiten
|
||||
|
||||
Die Browseranwendung verwendet ausschließlich die hier abgelegten Dateien:
|
||||
|
||||
- `jquery/jquery-4.0.0.min.js`
|
||||
- `jquery/LICENSE.txt`
|
||||
- `jquery-ui/jquery-ui-1.14.2.min.js`
|
||||
- `jquery-ui/jquery-ui-1.14.2.min.css`
|
||||
- `jquery-ui/images/*.png`
|
||||
- `jquery-ui/LICENSE.txt`
|
||||
|
||||
Die Dateien stammen aus den offiziellen npm-Paketen `jquery@4.0.0` und `jquery-ui@1.14.2`. `npm install` führt `scripts/vendor-sync.js` aus und kopiert die Distributionsdateien, das Base-Theme, dessen Bildressourcen und die Lizenztexte erneut aus `node_modules` in dieses Verzeichnis.
|
||||
|
||||
Die Dateien sind bereits im Projektarchiv enthalten. Der Browser benötigt deshalb weder ein CDN noch eine Netzwerkverbindung zum Laden der UI-Bibliotheken.
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
|
||||
|
||||
This software consists of voluntary contributions made by many
|
||||
individuals. For exact contribution history, see the revision history
|
||||
available at https://github.com/jquery/jquery-ui
|
||||
|
||||
The following license applies to all parts of this software except as
|
||||
documented below:
|
||||
|
||||
====
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
====
|
||||
|
||||
Copyright and related rights for sample code are waived via CC0. Sample
|
||||
code is defined as all source code contained within the demos directory.
|
||||
|
||||
CC0: http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
====
|
||||
|
||||
All files located in the node_modules and external directories are
|
||||
externally maintained libraries used by this software which have their
|
||||
own licenses; we recommend you read them, as their terms may differ from
|
||||
the terms above.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user