Add files via upload
This commit is contained in:
1 parent
e53cd580c5
commit
de8640d801
7 files changed
+1045
-74
No files matched your search
+39
-10
@@ -28,6 +28,8 @@
|
||||
<button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" hidden>×</button>
|
||||
<ul id="search-results" class="search-results" hidden></ul>
|
||||
</div>
|
||||
|
||||
<button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" aria-label="Favoriten">★</button>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav" aria-label="Hauptnavigation">
|
||||
@@ -55,14 +57,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="favorites-home" class="home-section" hidden>
|
||||
<div class="section-heading">
|
||||
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h2>Meine Haltestellen</h2></div>
|
||||
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 Störungsbenachrichtigungen aktivieren</button>
|
||||
</div>
|
||||
<div id="favorite-cards" class="favorite-cards"></div>
|
||||
</div>
|
||||
|
||||
<div id="nearby-home" class="home-section" hidden>
|
||||
<div class="section-heading">
|
||||
<div><span class="section-heading__eyebrow">OPTIONAL</span><h2>Haltestellen in der Nähe</h2></div>
|
||||
@@ -81,9 +75,10 @@
|
||||
</div>
|
||||
<div id="board-status" class="board-status" aria-live="polite"><span class="status-dot"></span> Aktualisierung alle 30 Sekunden</div>
|
||||
<div id="departures-mode-filter" class="mode-filter"></div>
|
||||
<div id="station-disruption" class="station-disruption" hidden></div>
|
||||
<div id="board-empty-state" class="board-empty-state">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
|
||||
<div id="departure-board" class="departure-board" role="table" aria-label="Abfahrtstafel"></div>
|
||||
<div id="board-empty-state" class="board-empty-state">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
|
||||
<div id="elevator-status" class="elevator-status" hidden></div>
|
||||
<div id="station-disruption" class="station-disruption" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -127,10 +122,28 @@
|
||||
<button class="when-choice" data-when="arrival" type="button">Ankunft um</button>
|
||||
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
|
||||
</div>
|
||||
<label class="wheelchair-toggle">
|
||||
<input type="checkbox" id="journey-wheelchair" />
|
||||
<span>♿ Nur rollstuhlgerechte Verbindungen</span>
|
||||
</label>
|
||||
<div class="journey-options-row">
|
||||
<div class="transfer-slack-field">
|
||||
<label for="journey-transfer-slack">Umstiegszeit-Puffer</label>
|
||||
<select id="journey-transfer-slack">
|
||||
<option value="">Standard</option>
|
||||
<option value="0">0 min (knapp)</option>
|
||||
<option value="3">3 min</option>
|
||||
<option value="5">5 min</option>
|
||||
<option value="10">10 min</option>
|
||||
<option value="15">15 min (sicher)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="journey-submit" class="journey-submit" type="button">Verbindungen suchen <span>→</span></button>
|
||||
</div>
|
||||
<p class="journey-source-note">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
|
||||
<div id="journey-share-row" class="journey-share-row" hidden>
|
||||
<button id="journey-share-btn" class="text-button" type="button">🔗 Route teilen</button>
|
||||
</div>
|
||||
<div id="journey-results" class="journey-results"></div>
|
||||
</section>
|
||||
|
||||
@@ -140,6 +153,20 @@
|
||||
<div id="nearby-list" class="nearby-list"><div class="board-empty-state">Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.</div></div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-tab-panel="favorites" hidden>
|
||||
<div class="panel-heading">
|
||||
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h1>Meine Haltestellen</h1><p>Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.</p></div>
|
||||
</div>
|
||||
<div id="favorites-home" class="home-section">
|
||||
<div class="section-heading">
|
||||
<div></div>
|
||||
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 Störungsbenachrichtigungen aktivieren</button>
|
||||
</div>
|
||||
<p id="favorites-empty-hint" class="favorites-empty-hint">Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.</p>
|
||||
<div id="favorite-cards" class="favorite-cards"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-tab-panel="disruptions" hidden>
|
||||
<div class="panel-heading"><div><span class="section-heading__eyebrow">AKTUELL</span><h1>Störungen</h1><p>Ortsbezogene und netzweite Hinweise.</p></div></div>
|
||||
<div class="disruption-section">
|
||||
@@ -164,6 +191,7 @@
|
||||
<section class="tab-panel" data-tab-panel="more" hidden>
|
||||
<div class="panel-heading"><div><span class="section-heading__eyebrow">WEITERE FUNKTIONEN</span><h1>Mehr</h1><p>Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div>
|
||||
<div class="more-grid">
|
||||
<button class="more-card" data-open-tab="favorites" type="button"><span>★</span><strong>Favoriten</strong><small>Deine gespeicherten Haltestellen</small></button>
|
||||
<button class="more-card" data-open-tab="nearby" type="button"><span>📍</span><strong>In der Nähe</strong><small>Haltestellen in deiner Umgebung</small></button>
|
||||
<button class="more-card" data-open-tab="disruptions" type="button"><span>⚠</span><strong>Störungen</strong><small>Ortsbezogen & netzweit</small></button>
|
||||
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong>Tickets</strong><small>Preise & wo kaufen</small></button>
|
||||
@@ -190,6 +218,7 @@
|
||||
<script src="js/splitflap.js"></script>
|
||||
<script src="js/modefilter.js"></script>
|
||||
<script src="js/livemap.js"></script>
|
||||
<script src="js/tripalarm.js"></script>
|
||||
<script src="js/tripmodal.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||
|
||||
@@ -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,
|
||||
})}`,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[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,22 +946,37 @@ 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 () => {
|
||||
document.getElementById('journey-submit').addEventListener('click', () => runJourneySearch());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runJourneySearch() {
|
||||
const resultsEl = document.getElementById('journey-results');
|
||||
const shareRow = document.getElementById('journey-share-row');
|
||||
|
||||
if (!journeyFrom || !journeyTo) {
|
||||
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
|
||||
@@ -803,24 +984,125 @@ const App = (() => {
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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 };
|
||||
})();
|
||||
|
||||
+261
-6
@@ -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; }
|
||||
Reference in new issue
Block a user