reworked app screens and added vibration

This commit is contained in:
Florian Zumpe
2026-06-16 17:30:55 +02:00
parent 40f230864b
commit 44b1700c48
13 changed files with 418 additions and 53 deletions
+37
View File
@@ -219,6 +219,43 @@ button,
opacity: .65;
}
#poi-list button {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
#poi-list button > strong,
#poi-list button > span:not(.poi-proximity) {
grid-column: 1;
}
.poi-proximity {
display: grid;
grid-column: 2;
grid-row: 1 / span 2;
place-items: center;
min-width: 3.75rem;
gap: .15rem;
padding-left: .5rem;
}
.poi-proximity svg {
width: 1.65rem;
height: 1.65rem;
fill: var(--primary-dark);
transform: rotate(0deg);
transform-origin: 50% 50%;
transition: transform 180ms linear;
will-change: transform;
}
.poi-proximity small {
color: var(--text);
font-size: .8rem;
font-weight: 700;
white-space: nowrap;
}
#poi-list button[aria-current="step"] {
border-color: var(--primary);
background: #e7f1e9;
+2 -2
View File
@@ -118,7 +118,7 @@
</div>
<p id="poi-description"></p>
<audio id="poi-audio" controls preload="metadata" hidden></audio>
<audio id="poi-audio" controls preload="auto" hidden></audio>
</div>
</section>
@@ -127,7 +127,7 @@
<path d="M32 4 53 54 32 44 11 54Z"/>
</svg>
<p>
<span id="navigation-mode">Richtung zum nächsten Routenpunkt</span>
<span id="navigation-mode">Richtung zum nächsten POI</span>
<strong id="navigation-distance">–</strong>
</p>
</footer>
+125 -27
View File
@@ -155,6 +155,7 @@
function stopRouteSensors() {
ns.Geo.stop();
ns.Orientation.stop();
ns.Vibration.stop();
state.deviceHeading = null;
}
@@ -242,20 +243,27 @@
<button type="button" data-poi-id="${poi.id}">
<strong>${escapeHtml(poi.title)}</strong>
<span>${escapeHtml(poi.description || '')}</span>
<span class="poi-proximity" aria-label="Entfernung wird ermittelt">
<svg viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M16 2 27 28 16 23 5 28Z"/>
</svg>
<small>–</small>
</span>
</button>
</li>
`);
});
updatePoiIndicators(state.position);
}
function showPoi(poi, automatic) {
function showPoi(poi) {
setActivePoi(poi.id);
$('#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 nearestRoutePointIndex(position) {
@@ -282,12 +290,63 @@
return nearestIndex;
}
function updateNavigation(position) {
function effectiveHeading(position) {
const movementHeading = position
&& Number.isFinite(position.heading)
&& Number(position.speed) > 0.5
? position.heading
: null;
return state.deviceHeading ?? movementHeading;
}
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);
return {
poi,
distance,
bearing,
rotation: heading == null
? bearing
: ns.Distance.normalizeDegrees(bearing - heading)
};
});
}
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);
indicator
.attr('aria-label', `${distance} Meter entfernt`)
.attr('title', `${distance} m entfernt`);
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
indicator.find('small').text(`${distance} m`);
});
return metrics;
}
function nearestPendingPoi(metrics) {
return metrics
.filter(metric => !state.triggered.has(metric.poi.id))
.reduce((nearest, metric) => (
!nearest || metric.distance < nearest.distance ? metric : nearest
), null);
}
function updateRouteProgress(position) {
const points = state.route?.points || [];
if (state.routeState !== 'running' || !points.length) return;
if (!points.length) return null;
const nearestIndex = nearestRoutePointIndex(position);
if (nearestIndex == null) return;
if (nearestIndex == null) return null;
state.routeProgressIndex = state.routeProgressIndex == null
? nearestIndex
@@ -308,32 +367,72 @@
if (targetIndex === lastIndex && distance <= reachedRadius) {
state.routeProgressIndex = lastIndex;
return { reached: true, target, distance: 0 };
}
return { reached: false, target, distance };
}
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
? `Norden oben · nächster POI: ${metric.poi.title}`
: `Nächster POI: ${metric.poi.title}`,
`${distance} m`
);
$('#navigation-footer').attr(
'aria-label',
`Nächster POI ${metric.poi.title} in ${distance} Metern`
);
}
function showRoutePointNavigation(position, progress) {
if (!progress) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Ziel der Route erreicht', '0 m');
$('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht');
showNavigation('Keine Navigationspunkte vorhanden', '–');
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;
if (progress.reached) {
$('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
$('#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',
`${Math.round(distance)} m`
`${distance} m`
);
$('#navigation-footer').attr(
'aria-label',
`Nächster Routenpunkt in ${Math.round(distance)} Metern`
`Nächster Routenpunkt in ${distance} Metern`
);
}
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);
}
function updateHeading(heading) {
if (state.deviceHeading == null) {
state.deviceHeading = heading;
@@ -347,26 +446,25 @@
function evaluatePosition(position) {
state.position = position;
if (!state.route || state.routeState !== 'running') return;
if (!state.route) return;
const metrics = updatePoiIndicators(position);
if (state.routeState !== 'running') return;
let nearest = nearestPendingPoi(metrics);
updateNavigation(position);
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` : '')
+ (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);
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
showPoi(nearest.poi, true);
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
showPoi(nearest.poi);
nearest = nearestPendingPoi(metrics);
updateNavigation(position);
}
}
@@ -399,7 +497,7 @@
}
if (!orientationAvailable) {
$('#navigation-mode').text('Norden oben · nächster Routenpunkt');
$('#navigation-mode').text('Norden oben · Navigation');
}
}
@@ -426,7 +524,7 @@
activate('[data-poi-id]', function () {
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
if (poi) showPoi(poi, false);
if (poi) showPoi(poi);
});
activate('[data-page-target]', function () {
+1 -1
View File
@@ -70,7 +70,7 @@
initializeJqueryUi();
mark('jquery-ui', 'done');
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'app']) {
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'vibration', 'app']) {
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
}
mark('modules', 'done');
+53
View File
@@ -0,0 +1,53 @@
(function (ns) {
'use strict';
const Patterns = Object.freeze({
MICRO: 50,
MINI: 100,
SHORT: 200,
LONG: 2000,
ALARM: Object.freeze([100, 50, 100]),
POI: Object.freeze([2000, 200, 2000]),
ACTIVE_POI: Object.freeze([500, 300, 500])
});
function supported() {
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
}
function normalize(pattern) {
const selected = pattern ?? Patterns.LONG;
if (Array.isArray(selected)) {
const values = selected.map(Number);
if (!values.length || values.some(value => !Number.isFinite(value) || value < 0)) {
throw new TypeError('Das Vibrationsmuster muss aus nichtnegativen Millisekunden bestehen.');
}
return values;
}
const duration = Number(selected);
if (!Number.isFinite(duration) || duration < 0) {
throw new TypeError('Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.');
}
return duration;
}
function start(pattern = Patterns.LONG) {
const normalized = normalize(pattern);
if (!supported()) return false;
return navigator.vibrate(normalized);
}
function stop() {
if (!supported()) return false;
return navigator.vibrate(0);
}
ns.Vibration = Object.freeze({
Patterns,
isSupported: supported,
start,
stop
});
}(window.Wegwichtel));