+
@@ -190,6 +218,7 @@
+
diff --git a/js/api.js b/js/api.js
index ad004ae..e723eca 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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,
})}`,
);
},
diff --git a/js/app.js b/js/app.js
index aba1432..940bd17 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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 = `
↺${escapeHtml(item.name)}`;
- 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 = `
↺${escapeHtml(item.name)}`;
+ 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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[char]));
}
+ // VBB-Störungstexte (HAFAS-remarks) enthalten gelegentlich einen eingebetteten
+ // "[MEHR/MORE]"-Link zur offiziellen Störungsmeldung, z.B.:
+ // Bauarbeiten ...
[MEHR/MORE]
+ // escapeHtml() allein macht daraus nur sichtbaren Text statt eines klickbaren
+ // Links. Diese Funktion erlaubt gezielt NUR
Label -
+ // 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 = /
]*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 += `${escapeHtml(label || href)}`;
+ } 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 = '
Zuletzt gesucht';
+ 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 = '
Keine Treffer';
@@ -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 = `
⚠ ${err.message}
`;
}
}
+ // 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) => `
♿ ${escapeHtml(d.summary || 'Aufzugsstatus')}${d.text ? `
${linkifyRemarkText(d.text)}` : ''}
`)
+ .join('');
+ }
+ }
+
+ if (disruptionEl) {
+ if (otherItems.length === 0) {
+ disruptionEl.hidden = true;
+ disruptionEl.innerHTML = '';
+ } else {
+ disruptionEl.hidden = false;
+ disruptionEl.innerHTML = otherItems
+ .map((d) => `
⚠ ${escapeHtml(d.summary || 'Störung')}${d.text ? `
${linkifyRemarkText(d.text)}` : ''}
`)
+ .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 = `
+
GLEIS ${escapeHtml(newPlatform)}
+
statt ${escapeHtml(plannedPlatform)}
+ `;
+ }
+ } 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 = `
-
+
`;
container.appendChild(card);
@@ -753,9 +899,9 @@ const App = (() => {
const card = document.createElement('div');
card.className = 'remark-card';
card.innerHTML = `
-
-
-
+
+
+
`;
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 = '
Bitte Start und Ziel aus der Liste auswählen.
';
- 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 = '
Suche Verbindungen…
';
+ 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 = '
Bitte Start und Ziel aus der Liste auswählen.
';
+ return;
+ }
- try {
- const { journeys } = await API.getJourneys(params);
- renderJourneys(journeys, resultsEl);
- } catch (err) {
- resultsEl.innerHTML = `
⚠ ${err.message}
`;
- }
+ resultsEl.innerHTML = '
Suche Verbindungen…
';
+ 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 = `
⚠ ${err.message}
`;
+ }
+ }
+
+ // ---------- 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 };
diff --git a/js/livemap.js b/js/livemap.js
index 281330f..de1834d 100644
--- a/js/livemap.js
+++ b/js/livemap.js
@@ -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() {
diff --git a/js/tripalarm.js b/js/tripalarm.js
new file mode 100644
index 0000000..39270ab
--- /dev/null
+++ b/js/tripalarm.js
@@ -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 = `
+
🔔
+
+
+ `;
+ 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 = `
+
+
⏰
+
Aufwachen!
+
+
+
+ `;
+ 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 };
+})();
diff --git a/js/tripmodal.js b/js/tripmodal.js
index 2b3e328..12d5c4f 100644
--- a/js/tripmodal.js
+++ b/js/tripmodal.js
@@ -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 = `
${formatTime(time) || '–'}`;
}
+ // 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
+ ? `
`
+ : '';
+
return `
${s.name || ''}
${timeHtml}
+ ${alarmHtml}
`;
}
+ function renderStopoversBody() {
+ const body = document.getElementById('trip-modal-body');
+ if (!body || !currentTrip) return;
+ const stopovers = currentTrip.stopovers || [];
+
+ if (!stopovers.length) {
+ body.innerHTML = '
Keine Zwischenhalte verfügbar.
';
+ return;
+ }
+
+ const hasUpcoming = stopovers.some((s, i) => !s.cancelled && i >= currentIndex);
+ const offsetSelectHtml = hasUpcoming
+ ? `
+
+ 🔔 Fahrtalarm:
+
+ Glocke an der Zielhaltestelle antippen
+
+ `
+ : '';
+
+ body.innerHTML = `
+ ${offsetSelectHtml}
+
+ ${stopovers.map((s, i) => renderStopover(s, i)).join('')}
+
+ `;
+
+ 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 = `
-
- ${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
-
- `;
- document.getElementById('trip-modal-body').innerHTML = stopovers.length
- ? bodyHtml
- : '
Keine Zwischenhalte verfügbar.
';
+ renderStopoversBody();
}
return { open, close };
})();
+
diff --git a/styles.css b/styles.css
index db2ae98..b1ee731 100644
--- a/styles.css
+++ b/styles.css
@@ -13,6 +13,7 @@
--signal-amber: #F5A623;
--signal-green: #3DDC84;
--signal-red: #FF5D6C;
+ --signal-blue: #4FA8E8;
--font-display: 'IBM Plex Mono', 'SFMono-Regular', monospace;
--font-body: 'IBM Plex Sans', -apple-system, sans-serif;
@@ -366,6 +367,41 @@ button {
font-family: var(--font-display);
}
+/* Gleiswechsel-Hinweis */
+.flap-cell--platform-changed {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ white-space: normal;
+}
+
+.platform-changed-flag {
+ display: inline-block;
+ background: var(--signal-red);
+ color: #fff;
+ font-weight: 700;
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ padding: 3px 7px;
+ border-radius: 4px;
+ width: fit-content;
+ animation: platform-changed-pulse 1s ease-out 2;
+}
+
+.platform-changed-original {
+ color: var(--signal-red);
+ font-size: 10px;
+ opacity: 0.85;
+ text-decoration: line-through;
+ font-family: var(--font-display);
+}
+
+@keyframes platform-changed-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 93, 108, 0.55); }
+ 50% { box-shadow: 0 0 0 6px rgba(255, 93, 108, 0); }
+}
+
.flap-field--line {
align-items: flex-start;
padding-right: 18px;
@@ -393,9 +429,15 @@ button {
Live-Karte
============================================================ */
+/* Mobile-First-Basis: sinnvoller Startwert für kleine Displays, bevor
+ livemap.js die Höhe zur Laufzeit anhand des tatsächlich verfügbaren
+ Platzes exakt nachjustiert (siehe resizeToViewport() in livemap.js).
+ dvh (dynamische Viewport-Höhe) berücksichtigt ein-/ausblendende mobile
+ Adressleisten korrekt; vh davor dient als Fallback für ältere Browser. */
.live-map {
- height: calc(100vh - var(--header-height) - 160px);
- min-height: 400px;
+ height: min(70vh, 560px);
+ height: min(70dvh, 560px);
+ min-height: 320px;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--line);
@@ -617,6 +659,13 @@ button {
margin-bottom: 8px;
}
+.remark-card__text a,
+.station-disruption__item a,
+.elevator-status__item a {
+ color: var(--signal-amber);
+ text-decoration: underline;
+}
+
.remark-card__meta {
color: var(--ink-muted);
font-size: 12px;
@@ -794,6 +843,21 @@ button:active { transform: translateY(1px); }
}
.search-clear:hover { background: var(--panel); color: var(--ink); }
+.header-icon-btn {
+ flex-shrink: 0;
+ border: 1px solid var(--line);
+ background: var(--panel-raised);
+ color: var(--signal-amber);
+ font-size: 18px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.header-icon-btn:hover { border-color: var(--signal-amber); }
+
.favorites-bar { display: none; }
.tab-nav {
max-width: 1120px;
@@ -921,7 +985,15 @@ button:active { transform: translateY(1px); }
.flap-cell--time { font-size: 17px; }
.flap-cell--direction { font-size: 15px; font-weight: 600; }
.flap-cell--platform { font-size: 14px; }
-.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-bottom: 12px; font-size: 12px; }
+.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-top: 14px; font-size: 12px; }
+.station-disruption__item { line-height: 1.5; }
+.station-disruption__item + .station-disruption__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,93,108,.25); }
+
+/* Aufzugsstatus - eigener, ruhigerer Ton (Info, nicht "akute Störung") */
+.elevator-status { background: rgba(79,168,232,.08); border: 1px solid rgba(79,168,232,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-top: 14px; font-size: 12px; }
+.elevator-status__item { line-height: 1.5; }
+.elevator-status__item + .elevator-status__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(79,168,232,.25); }
+.elevator-status__item strong { color: var(--signal-blue); }
.panel-heading { margin-bottom: 22px; }
.panel-heading h1 { font-size: 26px; margin-bottom: 6px; }
@@ -939,10 +1011,39 @@ button:active { transform: translateY(1px); }
.journey-input-marker--end { color: var(--signal-red); }
.journey-connector { height: 13px; border-left: 1px solid var(--line); margin-left: 10px; }
.journey-when { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
+
+.wheelchair-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--ink);
+ margin: 0 0 12px;
+ cursor: pointer;
+ width: fit-content;
+}
+.wheelchair-toggle input { accent-color: var(--signal-amber); width: 16px; height: 16px; }
.when-choice { background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; font-size: 12px; }
.when-choice.is-active { color: var(--signal-amber); border-color: var(--signal-amber); background: rgba(245,166,35,.08); }
.journey-options-row { display: flex; align-items: end; justify-content: space-between; gap: 12px; }
.journey-options-row .mode-filter { margin-bottom: 0; }
+
+.transfer-slack-field { display: flex; flex-direction: column; gap: 5px; }
+.transfer-slack-field label {
+ color: var(--ink-muted);
+ font: 600 10px var(--font-label);
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+.transfer-slack-field select {
+ background: var(--panel);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-family: inherit;
+ font-size: 13px;
+}
.journey-submit { min-height: 38px; padding: 9px 15px; }
.journey-submit span { margin-left: 6px; }
.journey-card { padding: 16px; }
@@ -956,11 +1057,29 @@ button:active { transform: translateY(1px); }
.more-card:hover { border-color: var(--signal-amber); }
.recent-section { margin-top: 28px; }
.recent-searches { display: flex; flex-wrap: wrap; gap: 8px; }
-.recent-item { display: inline-flex; align-items: center; gap: 7px; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; font-size: 12px; }
+.recent-item {
+ display: inline-flex; align-items: center; gap: 2px;
+ background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
+ padding: 2px 4px 2px 2px; font-size: 12px;
+}
.recent-item:hover { border-color: var(--signal-amber); }
-.recent-item span { color: var(--ink-muted); }
+.recent-item__select { display: inline-flex; align-items: center; gap: 7px; background: none; border: 0; color: var(--ink); padding: 6px 4px 6px 10px; font-size: 12px; font-family: inherit; }
+.recent-item__select span { color: var(--ink-muted); }
+.recent-item__remove {
+ background: none; border: 0; color: var(--ink-muted); width: 22px; height: 22px;
+ border-radius: 50%; font-size: 15px; line-height: 1; flex-shrink: 0;
+}
+.recent-item__remove:hover { background: var(--panel-raised); color: var(--signal-red); }
.recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; }
+.search-result-heading {
+ padding: 8px 12px 4px;
+ color: var(--ink-muted);
+ font: 600 10px var(--font-label);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+
.mobile-nav { display: none; }
/* ============================================================
@@ -1138,6 +1257,140 @@ button:active { transform: translateY(1px); }
text-decoration: line-through;
}
+/* ============================================================
+ Fahrtalarm: Offset-Auswahl, Glocken-Buttons an den Zwischenhalten,
+ Hinweis-Banner und Vollbild-Overlay bei Auslösung
+ ============================================================ */
+
+.trip-alarm-offset-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 10px 12px;
+ margin-bottom: 12px;
+ font-size: 13px;
+}
+
+.trip-alarm-offset-row select {
+ background: var(--panel);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 4px 8px;
+ font-family: inherit;
+ font-size: 12px;
+}
+
+.trip-alarm-offset-row small {
+ color: var(--ink-muted);
+ width: 100%;
+}
+
+.trip-stopover__alarm-btn {
+ background: none;
+ border: 0;
+ font-size: 16px;
+ padding: 2px 4px;
+ line-height: 1;
+ opacity: .6;
+}
+
+.trip-stopover__alarm-btn:hover { opacity: 1; }
+.trip-stopover__alarm-btn.is-active { opacity: 1; filter: drop-shadow(0 0 4px rgba(245,166,35,.6)); }
+
+.trip-alarm-banner {
+ display: none;
+ align-items: center;
+ gap: 10px;
+ position: fixed;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 190;
+ background: var(--panel-raised);
+ border: 1px solid var(--signal-amber);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ font-size: 13px;
+ width: min(480px, calc(100vw - 32px));
+ box-shadow: 0 8px 24px rgba(0,0,0,.35);
+}
+
+.trip-alarm-banner.is-visible { display: flex; }
+.trip-alarm-banner__text { flex: 1; }
+
+.trip-alarm-banner button {
+ background: none;
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: 6px;
+ padding: 5px 10px;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.trip-alarm-banner button:hover { border-color: var(--signal-amber); }
+
+.trip-alarm-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(12, 17, 22, .92);
+ animation: trip-modal-fade 0.2s ease;
+}
+
+.trip-alarm-overlay[hidden] { display: none; }
+
+.trip-alarm-overlay__card {
+ text-align: center;
+ background: var(--panel);
+ border: 1px solid var(--signal-amber);
+ border-radius: 16px;
+ padding: 36px 28px;
+ max-width: 340px;
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
+}
+
+.trip-alarm-overlay__icon {
+ font-size: 46px;
+ margin-bottom: 8px;
+ animation: trip-alarm-ring 0.6s ease-in-out infinite;
+}
+
+@keyframes trip-alarm-ring {
+ 0%, 100% { transform: rotate(0deg); }
+ 25% { transform: rotate(-12deg); }
+ 75% { transform: rotate(12deg); }
+}
+
+.trip-alarm-overlay__card h2 {
+ margin: 0 0 10px;
+ font-size: 22px;
+}
+
+.trip-alarm-overlay__card p {
+ margin: 0 0 20px;
+ color: var(--ink-muted);
+ font-size: 14px;
+}
+
+.trip-alarm-overlay__card button {
+ background: var(--signal-amber);
+ color: #17110a;
+ border: 0;
+ border-radius: 8px;
+ padding: 12px 24px;
+ font-weight: 600;
+ font-size: 14px;
+}
+
/* ============================================================
Live-Karten-Steuerung (Filter, Standort, Isolation)
============================================================ */
@@ -1477,7 +1730,7 @@ button:active { transform: translateY(1px); }
.journey-options-row { display: block; }
.journey-options-row .journey-submit { width: 100%; margin-top: 8px; }
.journey-options-row .mode-filter { margin-bottom: 8px; }
- .live-map { min-height: 62vh; height: 62vh; }
+ .transfer-slack-field select { width: 100%; }
.mobile-nav {
display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; bottom: 0; left: 0; right: 0;
z-index: 50; background: rgba(23,30,38,.97); border-top: 1px solid var(--line); padding: 5px 4px calc(5px + env(safe-area-inset-bottom));
@@ -1501,3 +1754,5 @@ button:active { transform: translateY(1px); }
text-align: center;
margin: 4px 0 14px;
}
+
+.journey-share-row { display: flex; justify-content: flex-end; margin-bottom: 8px; }