Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-08-31 11:05:24 +02:00
1 parent e53cd580c5
commit de8640d801
7 files changed
+1065 -94

No files matched your search

+3 -1
View File
@@ -98,7 +98,7 @@ const API = (() => {
* `from`/`to` brauchen nur noch { lat, lon, name } - egal ob Haltestelle,
* Adresse, POI oder GPS-Standort, alle liefern das bereits mit.
*/
getJourneys({ from, to, when, arrival, polylines, results = 5 }) {
getJourneys({ from, to, when, arrival, polylines, results = 5, transferSlack, wheelchair }) {
return request(
`/otp/journeys?${qs({
fromLat: from.lat,
@@ -109,6 +109,8 @@ const API = (() => {
arrival: arrival ? 'true' : undefined,
polylines: polylines ? 'true' : undefined,
results,
transferSlack,
wheelchair: wheelchair ? 'true' : undefined,
})}`,
);
},
+347 -63
View File
@@ -3,6 +3,8 @@ const App = (() => {
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
const TRANSFER_SLACK_KEY = 'oepnv-navi:db-transfer-slack';
const WHEELCHAIR_KEY = 'oepnv-navi:db-wheelchair';
const FAVORITES_POLL_MS = 30000;
let currentStop = null;
@@ -41,7 +43,13 @@ const App = (() => {
if (!station || !station.name) return;
const items = loadRecentSearches().filter((item) => item.id !== station.id);
items.unshift({ id: station.id, name: station.name, source: station.source });
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 6)));
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 8)));
renderRecentSearches();
}
function removeRecentSearch(id) {
const items = loadRecentSearches().filter((item) => item.id !== id);
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items));
renderRecentSearches();
}
@@ -55,11 +63,28 @@ const App = (() => {
return;
}
items.forEach((item) => {
const button = document.createElement('button');
button.className = 'recent-item';
button.innerHTML = `<span>↺</span><strong>${escapeHtml(item.name)}</strong>`;
button.addEventListener('click', () => selectStation(item));
container.appendChild(button);
const wrap = document.createElement('div');
wrap.className = 'recent-item';
const select = document.createElement('button');
select.type = 'button';
select.className = 'recent-item__select';
select.innerHTML = `<span>↺</span><strong>${escapeHtml(item.name)}</strong>`;
select.addEventListener('click', () => selectStation(item));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'recent-item__remove';
remove.setAttribute('aria-label', `${item.name} aus Verlauf entfernen`);
remove.textContent = '×';
remove.addEventListener('click', (e) => {
e.stopPropagation();
removeRecentSearch(item.id);
});
wrap.appendChild(select);
wrap.appendChild(remove);
container.appendChild(wrap);
});
}
@@ -67,6 +92,36 @@ const App = (() => {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[char]));
}
// VBB-Störungstexte (HAFAS-remarks) enthalten gelegentlich einen eingebetteten
// "[MEHR/MORE]"-Link zur offiziellen Störungsmeldung, z.B.:
// Bauarbeiten ... <a href="https://www.bvg.de/...">[MEHR/MORE]</a>
// escapeHtml() allein macht daraus nur sichtbaren Text statt eines klickbaren
// Links. Diese Funktion erlaubt gezielt NUR <a href="http(s)://...">Label</a> -
// alles andere (auch verschachtelte Tags im Label) wird escaped, damit aus den
// Störungsmeldungen kein beliebiges HTML/Script eingeschleust werden kann.
function linkifyRemarkText(raw) {
if (!raw) return '';
const anchorRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = anchorRegex.exec(raw)) !== null) {
result += escapeHtml(raw.slice(lastIndex, match.index));
const href = match[1];
const label = match[2].replace(/<[^>]*>/g, '').trim();
if (/^https?:\/\//i.test(href)) {
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label || href)}</a>`;
} else {
// Unbekanntes/unsicheres Ziel - lieber nur als Text zeigen als verlinken.
result += escapeHtml(label);
}
lastIndex = anchorRegex.lastIndex;
}
result += escapeHtml(raw.slice(lastIndex));
return result;
}
function toggleFavorite(station) {
const favorites = loadFavorites();
const idx = favorites.findIndex((f) => f.id === station.id);
@@ -82,20 +137,11 @@ const App = (() => {
}
function renderFavoriteChips() {
const container = document.getElementById('favorite-chips');
const cardContainer = document.getElementById('favorite-cards');
const favorites = loadFavorites();
if (container) container.innerHTML = '';
if (cardContainer) cardContainer.innerHTML = '';
favorites.forEach((fav) => {
if (container) {
const chip = document.createElement('button');
chip.className = 'chip';
chip.textContent = fav.name;
chip.addEventListener('click', () => selectStation(fav));
container.appendChild(chip);
}
if (cardContainer) {
const card = document.createElement('button');
card.className = 'favorite-card';
@@ -114,10 +160,11 @@ const App = (() => {
}
});
// Das Favoriten-Menü ist ein eigenes Tab-Panel (siehe activateTab) - hier
// wird nur noch der Inhalt darin gesteuert: Hinweistext, wenn leer,
// ansonsten die Karten.
const empty = document.getElementById('favorites-empty-hint');
if (empty) empty.hidden = favorites.length > 0;
const home = document.getElementById('favorites-home');
if (home) home.hidden = favorites.length === 0;
updateNotifyToggleVisibility(favorites.length > 0);
@@ -333,6 +380,27 @@ const App = (() => {
const input = document.getElementById('station-search');
const results = document.getElementById('search-results');
// Leeres Suchfeld antippen: zuletzt gesuchte Haltestellen als Vorschläge
// zeigen, statt dass man erst tippen muss, um überhaupt etwas zu sehen.
function showRecentSuggestions() {
const recents = loadRecentSearches();
if (!recents.length) {
results.hidden = true;
results.innerHTML = '';
return;
}
results.innerHTML = '<li class="search-result-heading">Zuletzt gesucht</li>';
renderSearchResults(results, recents, (station) => {
selectStation(station);
input.value = '';
results.hidden = true;
}, { append: true });
}
input.addEventListener('focus', () => {
if (input.value.trim().length < 2) showRecentSuggestions();
});
input.addEventListener('input', () => {
const clear = document.getElementById('search-clear');
if (clear) clear.hidden = input.value.length === 0;
@@ -340,8 +408,7 @@ const App = (() => {
const query = input.value.trim();
if (query.length < 2) {
results.hidden = true;
results.innerHTML = '';
showRecentSuggestions();
return;
}
@@ -378,8 +445,8 @@ const App = (() => {
return '🚏'; // VBB-Haltestelle (U/S/Tram/Bus)
}
function renderSearchResults(container, locations, onSelect) {
container.innerHTML = '';
function renderSearchResults(container, locations, onSelect, { append = false } = {}) {
if (!append) container.innerHTML = '';
if (locations.length === 0) {
container.innerHTML = '<li class="search-result search-result--empty">Keine Treffer</li>';
@@ -410,6 +477,14 @@ const App = (() => {
document.getElementById('departures-view').hidden = false;
updateFavoriteButton();
// Störungsanzeige der zuvor gewählten Haltestelle sofort verstecken,
// damit nicht kurz die alten Störungen der letzten Station aufblitzen,
// bevor die neuen Abfahrten geladen sind.
const disruptionEl = document.getElementById('station-disruption');
if (disruptionEl) { disruptionEl.hidden = true; disruptionEl.innerHTML = ''; }
const elevatorEl = document.getElementById('elevator-status');
if (elevatorEl) { elevatorEl.hidden = true; elevatorEl.innerHTML = ''; }
// Der Modefilter (Verkehrsmittel S/U/Tram/Bus/...) gilt nur für VBB-
// Haltestellen - die bundesweite DB-Anbindung unterstützt keine Filterung.
const modeFilterEl = document.getElementById('departures-mode-filter');
@@ -424,17 +499,61 @@ const App = (() => {
const board = document.getElementById('departure-board');
try {
const departures = currentStop.source === 'db'
? (await API.getDbDepartures(currentStop.id)).departures
: (await API.getDepartures(currentStop.id, {
const data = currentStop.source === 'db'
? await API.getDbDepartures(currentStop.id)
: await API.getDepartures(currentStop.id, {
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
})).departures;
renderDepartureBoard(departures);
});
renderDepartureBoard(data.departures);
renderStationDisruptions(data.disruptions);
} catch (err) {
board.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
}
// Zeigt echte Störungsmeldungen (Bauarbeiten, Ausfälle, Umleitungen) an, die
// auf einer der aktuell angezeigten Abfahrten der gewählten Haltestelle
// liegen - direkt unterhalb der Abfahrtstafel, damit man sie nicht übersieht.
// Aufzugsmeldungen sind eine Untermenge der allgemeinen Störungen (HAFAS
// liefert sie als ganz normale "warning"-Remarks) - werden aber separat und
// ruhiger dargestellt, weil es sich um eine Zugänglichkeits-Info handelt und
// nicht zwangsläufig um eine Störung der eigenen Fahrt.
const ELEVATOR_PATTERN = /aufzug|fahrstuhl|aufzüge/i;
function renderStationDisruptions(disruptions) {
const disruptionEl = document.getElementById('station-disruption');
const elevatorEl = document.getElementById('elevator-status');
if (!disruptionEl && !elevatorEl) return;
const all = disruptions || [];
const elevatorItems = all.filter((d) => ELEVATOR_PATTERN.test(`${d.summary || ''} ${d.text || ''}`));
const otherItems = all.filter((d) => !elevatorItems.includes(d));
if (elevatorEl) {
if (elevatorItems.length === 0) {
elevatorEl.hidden = true;
elevatorEl.innerHTML = '';
} else {
elevatorEl.hidden = false;
elevatorEl.innerHTML = elevatorItems
.map((d) => `<div class="elevator-status__item">♿ <strong>${escapeHtml(d.summary || 'Aufzugsstatus')}</strong>${d.text ? `<br>${linkifyRemarkText(d.text)}` : ''}</div>`)
.join('');
}
}
if (disruptionEl) {
if (otherItems.length === 0) {
disruptionEl.hidden = true;
disruptionEl.innerHTML = '';
} else {
disruptionEl.hidden = false;
disruptionEl.innerHTML = otherItems
.map((d) => `<div class="station-disruption__item">⚠ <strong>${escapeHtml(d.summary || 'Störung')}</strong>${d.text ? `<br>${linkifyRemarkText(d.text)}` : ''}</div>`)
.join('');
}
}
}
let lastRenderedSource = null;
function renderDepartureBoard(departures) {
@@ -530,7 +649,34 @@ const App = (() => {
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
SplitFlap.render(row.querySelector('[data-field="platform"]'), (dep.platform || '-').toUpperCase());
// Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach
// kommentarlos anzuzeigen - genau das übersieht man sonst leicht, wenn
// man aus Gewohnheit zum gewohnten Gleis läuft.
const platformCell = row.querySelector('[data-field="platform"]');
const hasPlatformChange = !dep.cancelled && dep.plannedPlatform && dep.platform && dep.platform !== dep.plannedPlatform;
if (hasPlatformChange) {
const newPlatform = String(dep.platform).toUpperCase();
const plannedPlatform = String(dep.plannedPlatform).toUpperCase();
if (platformCell.dataset.changedTo !== newPlatform || platformCell.dataset.changedFrom !== plannedPlatform) {
platformCell.dataset.changedTo = newPlatform;
platformCell.dataset.changedFrom = plannedPlatform;
platformCell.classList.add('flap-cell--platform-changed');
platformCell.innerHTML = `
<span class="platform-changed-flag">GLEIS ${escapeHtml(newPlatform)}</span>
<span class="platform-changed-original">statt ${escapeHtml(plannedPlatform)}</span>
`;
}
} else {
if (platformCell.classList.contains('flap-cell--platform-changed')) {
delete platformCell.dataset.changedTo;
delete platformCell.dataset.changedFrom;
platformCell.classList.remove('flap-cell--platform-changed');
platformCell.innerHTML = '';
}
SplitFlap.render(platformCell, (dep.platform || '-').toUpperCase());
}
const timeCell = row.querySelector('[data-field="time"]');
if (dep.cancelled) {
@@ -686,7 +832,7 @@ const App = (() => {
const stops = d.affectedStops.length ? ` · nahe ${d.affectedStops.join(', ')}` : '';
card.innerHTML = `
<div class="remark-card__summary">⚠ ${escapeHtml(d.summary || 'Hinweis')}</div>
<div class="remark-card__text">${escapeHtml(d.text || '')}</div>
<div class="remark-card__text">${linkifyRemarkText(d.text || '')}</div>
<div class="remark-card__meta">${escapeHtml(lines)}${escapeHtml(stops)}</div>
`;
container.appendChild(card);
@@ -753,9 +899,9 @@ const App = (() => {
const card = document.createElement('div');
card.className = 'remark-card';
card.innerHTML = `
<div class="remark-card__summary">⚠ ${r.summary || 'Hinweis'}</div>
<div class="remark-card__text">${r.text || ''}</div>
<div class="remark-card__meta">${r.company || ''}</div>
<div class="remark-card__summary">⚠ ${escapeHtml(r.summary || 'Hinweis')}</div>
<div class="remark-card__text">${linkifyRemarkText(r.text || '')}</div>
<div class="remark-card__meta">${escapeHtml(r.company || '')}</div>
`;
container.appendChild(card);
});
@@ -765,6 +911,26 @@ const App = (() => {
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
// Zuletzt gewählten Umstiegszeit-Puffer wiederherstellen, damit die
// Einstellung nicht bei jedem Besuch neu gesetzt werden muss.
const transferSlackSelect = document.getElementById('journey-transfer-slack');
if (transferSlackSelect) {
const saved = localStorage.getItem(TRANSFER_SLACK_KEY);
if (saved !== null) transferSlackSelect.value = saved;
transferSlackSelect.addEventListener('change', () => {
localStorage.setItem(TRANSFER_SLACK_KEY, transferSlackSelect.value);
});
}
// Ebenso für "nur rollstuhlgerechte Verbindungen".
const wheelchairToggle = document.getElementById('journey-wheelchair');
if (wheelchairToggle) {
wheelchairToggle.checked = localStorage.getItem(WHEELCHAIR_KEY) === 'true';
wheelchairToggle.addEventListener('change', () => {
localStorage.setItem(WHEELCHAIR_KEY, wheelchairToggle.checked ? 'true' : 'false');
});
}
document.getElementById('journey-use-location').addEventListener('click', async () => {
const input = document.getElementById('journey-from');
input.value = 'Standort wird ermittelt…';
@@ -780,47 +946,163 @@ const App = (() => {
document.querySelectorAll('.when-choice').forEach((button) => {
button.addEventListener('click', () => {
document.querySelectorAll('.when-choice').forEach((item) => item.classList.remove('is-active'));
button.classList.add('is-active');
const mode = button.dataset.when;
journeyArrivalMode = mode === 'arrival';
const when = document.getElementById('journey-when');
when.hidden = mode === 'now';
if (mode !== 'now' && !when.value) {
const d = new Date(Date.now() + 10 * 60000);
d.setSeconds(0, 0);
when.value = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0,16);
}
setWhenChoice(button.dataset.when);
});
});
document.getElementById('journey-submit').addEventListener('click', async () => {
const resultsEl = document.getElementById('journey-results');
document.getElementById('journey-submit').addEventListener('click', () => runJourneySearch());
if (!journeyFrom || !journeyTo) {
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
return;
const shareBtn = document.getElementById('journey-share-btn');
if (shareBtn) shareBtn.addEventListener('click', shareCurrentJourney);
}
// Setzt "Jetzt/Abfahrt um/Ankunft um" programmatisch (Klick-Handler UND
// Wiederherstellung eines geteilten Links nutzen dieselbe Logik).
function setWhenChoice(mode, whenValue) {
document.querySelectorAll('.when-choice').forEach((item) => item.classList.toggle('is-active', item.dataset.when === mode));
journeyArrivalMode = mode === 'arrival';
const when = document.getElementById('journey-when');
when.hidden = mode === 'now';
if (mode !== 'now') {
if (whenValue) {
when.value = whenValue;
} else if (!when.value) {
const d = new Date(Date.now() + 10 * 60000);
d.setSeconds(0, 0);
when.value = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
}
}
resultsEl.innerHTML = '<div class="board-empty">Suche Verbindungen…</div>';
async function runJourneySearch() {
const resultsEl = document.getElementById('journey-results');
const shareRow = document.getElementById('journey-share-row');
const whenInput = document.getElementById('journey-when').value;
const params = {
from: journeyFrom,
to: journeyTo,
when: whenInput || undefined,
arrival: journeyArrivalMode,
results: 5,
};
lastJourneyParams = params;
if (!journeyFrom || !journeyTo) {
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
return;
}
try {
const { journeys } = await API.getJourneys(params);
renderJourneys(journeys, resultsEl);
} catch (err) {
resultsEl.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
resultsEl.innerHTML = '<div class="board-empty">Suche Verbindungen…</div>';
if (shareRow) shareRow.hidden = true;
const whenInput = document.getElementById('journey-when').value;
const transferSlackSelect = document.getElementById('journey-transfer-slack');
const wheelchairToggle = document.getElementById('journey-wheelchair');
const params = {
from: journeyFrom,
to: journeyTo,
when: whenInput || undefined,
arrival: journeyArrivalMode,
results: 5,
transferSlack: transferSlackSelect && transferSlackSelect.value !== '' ? transferSlackSelect.value : undefined,
wheelchair: wheelchairToggle ? wheelchairToggle.checked : false,
};
lastJourneyParams = params;
try {
const { journeys } = await API.getJourneys(params);
renderJourneys(journeys, resultsEl);
if (shareRow) shareRow.hidden = false;
} catch (err) {
resultsEl.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
}
// ---------- Route teilen ----------
//
// Teilt nicht eine einzelne, schnell veraltende Verbindung, sondern die
// SUCHE selbst (Start, Ziel, Zeit, Optionen) - wer den Link öffnet, bekommt
// dieselbe Suche mit aktuellen Echtzeitdaten statt einer eingefrorenen,
// möglicherweise längst überholten Verbindung.
function buildJourneyShareUrl() {
if (!journeyFrom || !journeyTo) return null;
// "Mein Standort" wäre für den Empfänger irreführend (es ist ein
// eingefrorener Koordinatenpunkt, nicht dessen eigener Standort) - im
// geteilten Link daher neutral benennen.
const fromName = journeyFrom.name === 'Mein Standort' ? 'Startpunkt' : journeyFrom.name;
const toName = journeyTo.name === 'Mein Standort' ? 'Startpunkt' : journeyTo.name;
const whenInput = document.getElementById('journey-when').value;
const transferSlackSelect = document.getElementById('journey-transfer-slack');
const wheelchairToggle = document.getElementById('journey-wheelchair');
const params = new URLSearchParams({
route: '1',
fromLat: journeyFrom.lat,
fromLon: journeyFrom.lon,
fromName: fromName || 'Start',
toLat: journeyTo.lat,
toLon: journeyTo.lon,
toName: toName || 'Ziel',
arrival: journeyArrivalMode ? '1' : '0',
});
if (whenInput) params.set('when', whenInput);
if (transferSlackSelect && transferSlackSelect.value !== '') params.set('transferSlack', transferSlackSelect.value);
if (wheelchairToggle && wheelchairToggle.checked) params.set('wheelchair', '1');
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}
async function shareCurrentJourney() {
const url = buildJourneyShareUrl();
if (!url) return;
const shareData = {
title: 'ÖPNV Navi - Verbindung',
text: `${journeyFrom.name} → ${journeyTo.name}`,
url,
};
if (navigator.share) {
try {
await navigator.share(shareData);
} catch (err) {
// Nutzer hat den Teilen-Dialog abgebrochen - kein Fehler, kein Toast.
if (err.name !== 'AbortError') console.error('Teilen-Fehler:', err.message);
}
return;
}
try {
await navigator.clipboard.writeText(url);
showToast('Link kopiert!', 'info');
} catch (err) {
console.error('Clipboard-Fehler:', err.message);
window.prompt('Link kopieren:', url);
}
}
// Öffnet beim Start automatisch einen geteilten Link (?route=1&...), falls
// vorhanden - inklusive derselben Suche, die der Absender eingestellt hatte.
function restoreJourneyFromShareLink() {
const params = new URLSearchParams(window.location.search);
if (params.get('route') !== '1') return;
const fromLat = parseFloat(params.get('fromLat'));
const fromLon = parseFloat(params.get('fromLon'));
const toLat = parseFloat(params.get('toLat'));
const toLon = parseFloat(params.get('toLon'));
if ([fromLat, fromLon, toLat, toLon].some((v) => Number.isNaN(v))) return;
journeyFrom = { lat: fromLat, lon: fromLon, name: params.get('fromName') || 'Start' };
journeyTo = { lat: toLat, lon: toLon, name: params.get('toName') || 'Ziel' };
document.getElementById('journey-from').value = journeyFrom.name;
document.getElementById('journey-to').value = journeyTo.name;
const whenValue = params.get('when') || '';
setWhenChoice(params.get('arrival') === '1' ? 'arrival' : (whenValue ? 'departure' : 'now'), whenValue);
const transferSlackParam = params.get('transferSlack');
const transferSlackSelect = document.getElementById('journey-transfer-slack');
if (transferSlackSelect && transferSlackParam) transferSlackSelect.value = transferSlackParam;
const wheelchairToggle = document.getElementById('journey-wheelchair');
if (wheelchairToggle) wheelchairToggle.checked = params.get('wheelchair') === '1';
activateTab('journey');
runJourneySearch();
}
function setupJourneyInput(inputId, onSelect) {
@@ -985,6 +1267,7 @@ const App = (() => {
initNearby();
initNearbyDisruptions();
initNotifications();
TripAlarm.load();
renderFavoriteChips();
renderRecentSearches();
@@ -1011,6 +1294,7 @@ const App = (() => {
});
restoreLastStop();
restoreJourneyFromShareLink();
}
return { init };
+51 -3
View File
@@ -23,6 +23,7 @@ const LiveMap = (() => {
let isolatedMarker = null; // persistenter Marker im Isolations-Modus, wird nur aktualisiert statt neu erzeugt
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
let resizeScheduled = false;
function init() {
if (map) return;
@@ -41,6 +42,53 @@ const LiveMap = (() => {
map.on('moveend', () => {
if (mode === 'radar') refreshRadar();
});
// Die Karte soll die tatsächlich verfügbare Bildschirmhöhe ausfüllen -
// unabhängig von Gerät/Displaygröße, Adressleisten-Ein-/Ausblenden auf
// Mobilgeräten oder Bildschirmdrehung. Statt die Höhe über CSS mit
// geschätzten Pixelwerten zu berechnen (die bei jedem neuen Gerät wieder
// falsch wären), messen wir hier direkt den tatsächlich freien Platz und
// setzen die Höhe daraus - das funktioniert für jede Displaygröße gleich
// gut (Mobile First: der Startwert in CSS ist bereits mobilfreundlich,
// hier wird er nur noch exakt nachjustiert).
resizeToViewport();
window.addEventListener('resize', scheduleResize);
window.addEventListener('orientationchange', scheduleResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', scheduleResize);
}
}
function scheduleResize() {
if (resizeScheduled) return;
resizeScheduled = true;
requestAnimationFrame(() => {
resizeScheduled = false;
resizeToViewport();
});
}
function resizeToViewport() {
const el = document.getElementById('live-map');
if (!el) return;
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
const top = el.getBoundingClientRect().top;
// Die feste untere Mobil-Navigationsleiste überlappt den Inhalt
// (position: fixed) - ihre Höhe muss von der verfügbaren Fläche
// abgezogen werden, sofern sie gerade sichtbar ist (auf Desktop-
// Breiten ist sie per CSS ausgeblendet).
const mobileNav = document.querySelector('.mobile-nav');
const mobileNavHeight = mobileNav && getComputedStyle(mobileNav).display !== 'none'
? mobileNav.getBoundingClientRect().height
: 0;
const bottomSafety = 12; // etwas Luft, u.a. für mobile Adressleisten am unteren Rand
const available = viewportHeight - top - mobileNavHeight - bottomSafety;
el.style.height = `${Math.max(280, Math.round(available))}px`;
if (map) map.invalidateSize();
}
function setModeFilter(fn) {
@@ -106,7 +154,7 @@ const LiveMap = (() => {
refreshRadar();
if (radarPollHandle) clearInterval(radarPollHandle);
radarPollHandle = setInterval(refreshRadar, 15000);
setTimeout(() => map && map.invalidateSize(), 150);
setTimeout(() => resizeToViewport(), 150);
}
function stopRadar() {
@@ -279,7 +327,7 @@ const LiveMap = (() => {
refreshTrip();
if (tripPollHandle) clearInterval(tripPollHandle);
tripPollHandle = setInterval(refreshTrip, 10000);
setTimeout(() => map && map.invalidateSize(), 150);
setTimeout(() => resizeToViewport(), 150);
}
function stopTracking() {
@@ -327,7 +375,7 @@ const LiveMap = (() => {
function startPolling() {
// Rückwärtskompatibler Einstieg: Karten-Tab wurde geöffnet.
if (mode === 'radar' || mode === 'idle') startRadar();
else if (map) setTimeout(() => map.invalidateSize(), 150);
else if (map) setTimeout(() => resizeToViewport(), 150);
}
function stopPolling() {
+257
View File
@@ -0,0 +1,257 @@
// Fahrtalarm: weckt akustisch/optisch/via Vibration, wenn eine laufende Fahrt
// noch X Stationen von einer gewählten Zielhaltestelle entfernt ist. Läuft im
// Hintergrund weiter (eigenes Poll-Intervall), unabhängig davon, welcher Tab
// gerade aktiv ist - genau wie der Störungs-Push für Favoriten. Bleibt über
// einen Seiten-Reload hinweg erhalten (localStorage), damit ein versehentlicher
// Reload den Alarm nicht killt.
const TripAlarm = (() => {
const STORAGE_KEY = 'oepnv-navi:db-trip-alarm';
const POLL_MS = 15000;
let alarm = null; // { tripId, line, color, textColor, targetIndex, targetName, stopsBefore, triggered }
let pollHandle = null;
let audioCtx = null;
let soundLoopHandle = null;
function persist() {
if (alarm) localStorage.setItem(STORAGE_KEY, JSON.stringify(alarm));
else localStorage.removeItem(STORAGE_KEY);
}
function set({ tripId, line, color, textColor, targetIndex, targetName, stopsBefore }) {
stopSound();
alarm = {
tripId, line, color, textColor, targetIndex, targetName,
stopsBefore: Number.isFinite(stopsBefore) ? stopsBefore : 1,
triggered: false,
};
persist();
renderBanner();
restartPolling();
check(); // sofort einmal prüfen statt erst nach POLL_MS zu warten
}
function clear() {
stopSound();
alarm = null;
persist();
stopPolling();
hideBanner();
hideOverlay();
}
function getActive() {
return alarm;
}
function isActiveFor(tripId, index) {
return !!alarm && alarm.tripId === tripId && alarm.targetIndex === index;
}
// Beim App-Start einen zuvor gesetzten Alarm wiederherstellen (z.B. nach
// versehentlichem Reload der Seite während der Fahrt).
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (!saved || !saved.tripId) return;
alarm = saved;
if (alarm.triggered) {
// Alarm war schon ausgelöst, aber nicht bestätigt (z.B. Tab wurde
// neu geladen, während das Overlay offen war) - erneut anzeigen,
// ohne Ton/Vibration erneut auszulösen.
showOverlay(alarm.targetName, alarm.lastStopsRemaining || 0);
} else {
renderBanner();
restartPolling();
}
} catch (_) {
// kein gespeicherter Alarm, ignorieren
}
}
function restartPolling() {
stopPolling();
pollHandle = setInterval(check, POLL_MS);
}
function stopPolling() {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
}
}
async function check() {
if (!alarm) return;
let trip;
try {
trip = await API.getTrip(alarm.tripId);
} catch (err) {
console.error('Fahrtalarm-Fehler:', err.message);
return;
}
if (trip.cancelled) {
notify('Fahrt fällt aus', `Die Fahrt der Linie ${trip.line || alarm.line || ''} wurde storniert - Fahrtalarm wurde beendet.`);
clear();
return;
}
const stopovers = trip.stopovers || [];
const now = Date.now();
let currentIndex = stopovers.findIndex((s) => {
const t = s.arrival || s.departure || s.plannedArrival || s.plannedDeparture;
return t && new Date(t).getTime() >= now;
});
if (currentIndex === -1) currentIndex = stopovers.length;
// Ziel-Index robust wiederfinden, falls sich die Stopover-Liste seit dem
// Setzen des Alarms leicht verschoben hat (z.B. durch einen zusätzlich
// entfallenen Zwischenhalt).
let targetIndex = alarm.targetIndex;
if (!stopovers[targetIndex] || stopovers[targetIndex].name !== alarm.targetName) {
const found = stopovers.findIndex((s) => s.name === alarm.targetName);
if (found !== -1) targetIndex = found;
}
alarm.targetIndex = targetIndex;
const stopsRemaining = Math.max(0, targetIndex - currentIndex);
const reached = currentIndex >= targetIndex;
if (!alarm.triggered && (reached || stopsRemaining <= alarm.stopsBefore)) {
alarm.triggered = true;
alarm.lastStopsRemaining = stopsRemaining;
persist();
stopPolling();
triggerAlarm(alarm.targetName, stopsRemaining);
return;
}
persist();
renderBanner(stopsRemaining);
}
function triggerAlarm(stopName, stopsRemaining) {
showOverlay(stopName, stopsRemaining);
playSound();
if (navigator.vibrate) navigator.vibrate([400, 200, 400, 200, 400]);
notify('Fahrtalarm', stopsRemaining === 0
? `Gleich da: ${stopName}`
: `Noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${stopName}`);
}
function notify(title, body) {
if (window.Notification && Notification.permission === 'granted') {
try {
new Notification(title, { body, tag: 'oepnv-navi-fahrtalarm' });
} catch (err) {
console.error('Notification-Fehler:', err.message);
}
}
}
// ---------- Ton (Web Audio, kein externes Asset nötig) ----------
function playSound() {
stopSound();
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const beep = () => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.value = 880;
gain.gain.value = 0.16;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.35);
};
beep();
soundLoopHandle = setInterval(beep, 900);
} catch (err) {
console.error('Fahrtalarm-Ton-Fehler:', err.message);
}
}
function stopSound() {
if (soundLoopHandle) {
clearInterval(soundLoopHandle);
soundLoopHandle = null;
}
}
// ---------- Persistenter Hinweis-Banner (solange Alarm noch nicht ausgelöst) ----------
function ensureBanner() {
let banner = document.getElementById('trip-alarm-banner');
if (banner) return banner;
banner = document.createElement('div');
banner.id = 'trip-alarm-banner';
banner.className = 'trip-alarm-banner';
banner.innerHTML = `
<span class="trip-alarm-banner__icon">🔔</span>
<span id="trip-alarm-banner__text" class="trip-alarm-banner__text"></span>
<button id="trip-alarm-banner__cancel" type="button">Alarm beenden</button>
`;
document.body.appendChild(banner);
banner.querySelector('#trip-alarm-banner__cancel').addEventListener('click', clear);
return banner;
}
function renderBanner(stopsRemaining) {
if (!alarm) return;
const banner = ensureBanner();
const textEl = banner.querySelector('#trip-alarm-banner__text');
const line = alarm.line ? `${alarm.line} · ` : '';
textEl.textContent = stopsRemaining == null
? `Fahrtalarm gesetzt für ${alarm.targetName}`
: `${line}Fahrtalarm: noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${alarm.targetName}`;
banner.classList.add('is-visible');
}
function hideBanner() {
const banner = document.getElementById('trip-alarm-banner');
if (banner) banner.classList.remove('is-visible');
}
// ---------- Vollbild-Overlay bei Auslösung ----------
function ensureOverlay() {
let overlay = document.getElementById('trip-alarm-overlay');
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = 'trip-alarm-overlay';
overlay.className = 'trip-alarm-overlay';
overlay.hidden = true;
overlay.innerHTML = `
<div class="trip-alarm-overlay__card">
<div class="trip-alarm-overlay__icon">⏰</div>
<h2>Aufwachen!</h2>
<p id="trip-alarm-overlay__text"></p>
<button id="trip-alarm-overlay__dismiss" type="button">Alarm beenden</button>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#trip-alarm-overlay__dismiss').addEventListener('click', clear);
return overlay;
}
function showOverlay(stopName, stopsRemaining) {
hideBanner();
const overlay = ensureOverlay();
document.getElementById('trip-alarm-overlay__text').textContent = stopsRemaining === 0
? `Gleich da: ${stopName}`
: `Noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${stopName}`;
overlay.hidden = false;
}
function hideOverlay() {
const overlay = document.getElementById('trip-alarm-overlay');
if (overlay) overlay.hidden = true;
}
return { set, clear, getActive, isActiveFor, load };
})();
+107 -11
View File
@@ -1,11 +1,22 @@
// Modal, das beim Klick auf eine Abfahrtszeile oder einen Verbindungsabschnitt
// geöffnet wird: zeigt Linie, Richtung, eine kleine Karte mit dem Streckenverlauf
// und alle Zwischenhalte mit Zeiten/Verspätungen.
// und alle Zwischenhalte mit Zeiten/Verspätungen. Über die Glocken-Buttons an
// den kommenden Halten kann hier außerdem ein Fahrtalarm gesetzt werden
// (siehe tripalarm.js).
const TripModal = (() => {
const ALARM_OFFSET_KEY = 'oepnv-navi:db-alarm-offset';
let backdrop = null;
let miniMap = null;
// Zustand der aktuell geöffneten Fahrt, damit wir nach dem Setzen/Entfernen
// eines Alarms den Haltestellen-Teil neu rendern können, ohne die Fahrt
// erneut vom Server zu laden.
let currentTripId = null;
let currentTrip = null;
let currentIndex = -1;
function ensureBackdrop() {
if (backdrop) return backdrop;
backdrop = document.createElement('div');
@@ -37,6 +48,16 @@ const TripModal = (() => {
if (e.key === 'Escape' && !backdrop.hidden) close();
});
// Delegierter Klick-Handler für die Alarm-Glocken an den Haltestellen -
// einmalig registriert, damit er bei jedem open() weiter funktioniert,
// ohne mehrfach angehängt zu werden.
backdrop.querySelector('#trip-modal-body').addEventListener('click', (e) => {
const btn = e.target.closest('[data-alarm-index]');
if (!btn || !currentTrip) return;
const index = parseInt(btn.dataset.alarmIndex, 10);
toggleAlarm(index);
});
return backdrop;
}
@@ -54,10 +75,41 @@ const TripModal = (() => {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function renderStopover(s, isCurrentOrNext) {
function getAlarmOffset() {
return parseInt(localStorage.getItem(ALARM_OFFSET_KEY), 10) || 1;
}
function setAlarmOffset(value) {
localStorage.setItem(ALARM_OFFSET_KEY, String(value));
}
function toggleAlarm(index) {
if (!currentTrip || !currentTripId) return;
if (TripAlarm.isActiveFor(currentTripId, index)) {
TripAlarm.clear();
} else {
const select = document.getElementById('trip-alarm-offset-select');
const stopsBefore = select ? parseInt(select.value, 10) : getAlarmOffset();
setAlarmOffset(stopsBefore);
TripAlarm.set({
tripId: currentTripId,
line: currentTrip.line,
color: currentTrip.color,
textColor: currentTrip.textColor,
targetIndex: index,
targetName: (currentTrip.stopovers[index] || {}).name || '',
stopsBefore,
});
}
renderStopoversBody();
}
function renderStopover(s, index) {
const time = s.departure || s.arrival || s.plannedDeparture || s.plannedArrival;
const plannedTime = s.plannedDeparture || s.plannedArrival;
const delay = s.departureDelay ?? s.arrivalDelay;
const isCurrentOrNext = index === currentIndex;
let timeHtml;
if (s.cancelled) {
@@ -68,20 +120,68 @@ const TripModal = (() => {
timeHtml = `<span class="trip-stopover__time">${formatTime(time) || '–'}</span>`;
}
// Ein Fahrtalarm ergibt nur für noch bevorstehende, nicht ausgefallene
// Halte Sinn - nicht für bereits vergangene.
const canAlarm = !s.cancelled && index >= currentIndex;
const isActiveAlarm = canAlarm && TripAlarm.isActiveFor(currentTripId, index);
const alarmHtml = canAlarm
? `<button class="trip-stopover__alarm-btn ${isActiveAlarm ? 'is-active' : ''}" data-alarm-index="${index}" type="button" title="${isActiveAlarm ? 'Fahrtalarm entfernen' : 'Fahrtalarm für diese Haltestelle setzen'}">${isActiveAlarm ? '🔔' : '🔕'}</button>`
: '';
return `
<div class="trip-stopover ${isCurrentOrNext ? 'trip-stopover--current' : ''} ${s.cancelled ? 'trip-stopover--cancelled' : ''}">
<div class="trip-stopover__row">
<span class="trip-stopover__name">${s.name || ''}</span>
${timeHtml}
${alarmHtml}
</div>
</div>
`;
}
function renderStopoversBody() {
const body = document.getElementById('trip-modal-body');
if (!body || !currentTrip) return;
const stopovers = currentTrip.stopovers || [];
if (!stopovers.length) {
body.innerHTML = '<div class="trip-modal__loading">Keine Zwischenhalte verfügbar.</div>';
return;
}
const hasUpcoming = stopovers.some((s, i) => !s.cancelled && i >= currentIndex);
const offsetSelectHtml = hasUpcoming
? `
<div class="trip-alarm-offset-row">
<span>🔔 Fahrtalarm:</span>
<select id="trip-alarm-offset-select">
<option value="0">bei Ankunft</option>
<option value="1">1 Station vorher</option>
<option value="2">2 Stationen vorher</option>
<option value="3">3 Stationen vorher</option>
</select>
<small>Glocke an der Zielhaltestelle antippen</small>
</div>
`
: '';
body.innerHTML = `
${offsetSelectHtml}
<div class="trip-stopovers">
${stopovers.map((s, i) => renderStopover(s, i)).join('')}
</div>
`;
const select = document.getElementById('trip-alarm-offset-select');
if (select) select.value = String(getAlarmOffset());
}
async function open(tripId) {
if (!tripId) return;
const el = ensureBackdrop();
el.hidden = false;
currentTripId = tripId;
currentTrip = null;
document.getElementById('trip-modal-line').textContent = '…';
document.getElementById('trip-modal-line').style.background = '#8E99A6';
@@ -97,6 +197,8 @@ const TripModal = (() => {
return;
}
currentTrip = trip;
const lineEl = document.getElementById('trip-modal-line');
lineEl.textContent = trip.line || '?';
lineEl.style.background = trip.color || '#8E99A6';
@@ -130,21 +232,15 @@ const TripModal = (() => {
const stopovers = trip.stopovers || [];
const now = Date.now();
let currentIndex = stopovers.findIndex((s) => {
currentIndex = stopovers.findIndex((s) => {
const t = s.departure || s.plannedDeparture;
return t && new Date(t).getTime() >= now;
});
if (currentIndex === -1) currentIndex = stopovers.length - 1;
const bodyHtml = `
<div class="trip-stopovers">
${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
</div>
`;
document.getElementById('trip-modal-body').innerHTML = stopovers.length
? bodyHtml
: '<div class="trip-modal__loading">Keine Zwischenhalte verfügbar.</div>';
renderStopoversBody();
}
return { open, close };
})();