initial commit

This commit is contained in:
Florian Zumpe
2026-06-16 10:31:41 +02:00
parent e607876d37
commit eb559b16fb
282 changed files with 3118 additions and 41422 deletions
+11
View File
@@ -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));
+198
View File
@@ -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));
+11
View File
@@ -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));
+94
View File
@@ -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();
}());
+2
View File
@@ -0,0 +1,2 @@
window.Wegwichtel = window.Wegwichtel || {};
window.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
+12
View File
@@ -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));
+18
View File
@@ -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));
+46
View File
@@ -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));