initial commit
This commit is contained in:
@@ -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));
|
||||
Reference in New Issue
Block a user