Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-08-28 16:19:41 +02:00
1 parent 055077803c
commit 63da9cdbba
7 files changed
+492 -58

No files matched your search

+22 -3
View File
@@ -127,9 +127,9 @@
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
</div>
<div class="journey-options-row">
<div id="journey-mode-filter" class="mode-filter"></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-results" class="journey-results"></div>
</section>
@@ -140,15 +140,32 @@
</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>Aktuelle Hinweise und Einschränkungen im Netz.</p></div></div>
<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">
<div class="section-heading"><div><h2>In deiner Nähe</h2></div></div>
<button id="disruptions-locate" class="location-btn location-btn--wide">📍 Standort verwenden</button>
<div id="nearby-disruptions-list" class="remarks-list"><div class="board-empty-state">Nutze deinen Standort für ortsbezogene Störungen.</div></div>
</div>
<div class="disruption-section">
<div class="section-heading"><div><h2>Netzweit</h2></div></div>
<div id="remarks-list" class="remarks-list"><div class="board-empty-state">Störungsmeldungen werden geladen…</div></div>
</div>
</section>
<section class="tab-panel" data-tab-panel="fares" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">PREISE</span><h1>Tickets</h1><p>Übliche VBB-Fahrpreise auf einen Blick.</p></div></div>
<div class="disclaimer-banner">⚠ Angaben ohne Gewähr – Preise können sich ändern. <span id="fares-stand"></span></div>
<div id="fares-content"><div class="board-empty-state">Lade Fahrpreise…</div></div>
<div class="section-heading"><div><h2>Tickets kaufen</h2></div></div>
<div id="fares-companies" class="company-links"></div>
</section>
<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="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>Aktuelle Hinweise im VBB-Netz</small></button>
<button class="more-card" data-open-tab="disruptions" type="button"><span>⚠</span><strong>Störungen</strong><small>Ortsbezogen &amp; netzweit</small></button>
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong>Tickets</strong><small>Preise &amp; wo kaufen</small></button>
</div>
<div class="recent-section">
<div class="section-heading"><div><span class="section-heading__eyebrow">VERLAUF</span><h2>Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button">Leeren</button></div>
@@ -157,6 +174,8 @@
</section>
</main>
<div class="global-disclaimer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div>
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
<button class="mobile-nav__item is-active" data-tab="departures"><span>🚏</span><small>Abfahrten</small></button>
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small>Route</small></button>
+70 -16
View File
@@ -4,10 +4,10 @@
const API = (() => {
const BASE = window.APP_CONFIG.API_BASE;
async function request(path) {
async function request(path, options = {}) {
let response;
try {
response = await fetch(`${BASE}${path}`);
response = await fetch(`${BASE}${path}`, options);
} catch (networkErr) {
throw new Error('Backend nicht erreichbar. Prüfe deine Internetverbindung.');
}
@@ -26,6 +26,15 @@ const API = (() => {
return response.json();
}
function postJson(path, body) {
return request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// Baut einen Query-String und lässt dabei undefined/null-Werte weg.
function qs(params) {
const parts = [];
Object.entries(params).forEach(([key, value]) => {
@@ -40,6 +49,42 @@ const API = (() => {
return request(`/locations/search?query=${encodeURIComponent(query)}`);
},
// Bundesweite DB-IRIS-Anbindung: nur "echte" DB-Bahnhöfe (Fernverkehr +
// viele Regio-Stationen), OHNE U-Bahn/Tram/Bus - siehe searchAll().
searchDbStations(query) {
return request(`/db/stations/search?query=${encodeURIComponent(query)}`);
},
/**
* Kombinierte Suche: fragt VBB (U-Bahn/S-Bahn/Tram/Bus, primär Berlin/
* Brandenburg) UND die bundesweite DB-Anbindung (nur echte Bahnhöfe)
* parallel ab und liefert eine gemeinsame, quellenmarkierte Liste zurück.
* So funktioniert Nahverkehr weiterhin lokal, und man bekommt zusätzlich
* bundesweite Fernverkehrs-/Regio-Bahnhöfe.
*/
async searchAll(query) {
const [vbbResult, dbResult] = await Promise.allSettled([
this.searchLocations(query),
this.searchDbStations(query),
]);
const vbbStations = vbbResult.status === 'fulfilled'
? vbbResult.value.locations
.filter((l) => l.kind === 'stop')
.map((l) => ({ id: l.id, name: l.name, source: 'vbb' }))
: [];
const dbStations = dbResult.status === 'fulfilled'
? dbResult.value.stations.map((s) => ({ id: s.evaNumber, name: s.name, source: 'db' }))
: [];
return [...vbbStations, ...dbStations];
},
getDbDepartures(evaNumber, { hours = 2, results = 20 } = {}) {
return request(`/db/departures/${encodeURIComponent(evaNumber)}?${qs({ hours, results })}`);
},
nearby({ lat, lon, distance, results, includePoi }) {
return request(`/locations/nearby?${qs({ lat, lon, distance, results, includePoi })}`);
},
@@ -49,30 +94,33 @@ const API = (() => {
},
/**
* `from`/`to` sind entweder { id, name } (Haltestelle) oder
* { lat, lon, name } (Adresse/POI/GPS-Standort, wird als Adresse behandelt).
* Nutzt unsere selbst gehostete OTP2-Instanz (VBB-Region) statt HAFAS.
* `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, modes, polylines, results = 5 }) {
const fromParams = from.id
? { fromId: from.id }
: { fromLat: from.lat, fromLon: from.lon, fromAddress: from.name };
const toParams = to.id
? { toId: to.id }
: { toLat: to.lat, toLon: to.lon, toAddress: to.name };
getJourneys({ from, to, when, arrival, polylines, results = 5 }) {
return request(
`/journeys?${qs({
...fromParams,
...toParams,
`/otp/journeys?${qs({
fromLat: from.lat,
fromLon: from.lon,
toLat: to.lat,
toLon: to.lon,
when: when || undefined,
arrival: arrival ? 'true' : undefined,
modes,
polylines: polylines ? 'true' : undefined,
results,
})}`,
);
},
getFares() {
return request('/fares');
},
getNearbyDisruptions({ lat, lon, distance, stops }) {
return request(`/disruptions/nearby?${qs({ lat, lon, distance, stops })}`);
},
getTrip(tripId, { polyline = false } = {}) {
return request(`/trip/${encodeURIComponent(tripId)}?${qs({ polyline: polyline ? 'true' : undefined })}`);
},
@@ -84,5 +132,11 @@ const API = (() => {
getRadar({ north, west, south, east, modes, tripId }) {
return request(`/radar?${qs({ north, west, south, east, modes, tripId })}`);
},
// Nächste Abfahrt für mehrere Haltestellen gleichzeitig (z.B. für die
// Favoriten-Übersicht auf der Startseite) - eine Anfrage statt N.
getSummaryDepartures(stations) {
return postJson('/summary/departures', { stations });
},
};
})();
+173 -28
View File
@@ -1,12 +1,11 @@
const App = (() => {
const FAVORITES_KEY = 'oepnv-navi:favorites';
const LAST_STOP_KEY = 'oepnv-navi:last-stop';
const RECENT_SEARCHES_KEY = 'oepnv-navi:recent-searches';
const FAVORITES_KEY = 'oepnv-navi:db-favorites';
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
let currentStop = null;
let departuresPollHandle = null;
let departuresModeFilter = null;
let journeyModeFilter = null;
let journeyFrom = null;
let journeyTo = null;
@@ -38,7 +37,7 @@ const App = (() => {
function saveRecentSearch(station) {
if (!station || !station.name) return;
const items = loadRecentSearches().filter((item) => item.id !== station.id);
items.unshift({ id: station.id, name: station.name, latitude: station.latitude, longitude: station.longitude });
items.unshift({ id: station.id, name: station.name, source: station.source });
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 6)));
renderRecentSearches();
}
@@ -97,7 +96,15 @@ const App = (() => {
if (cardContainer) {
const card = document.createElement('button');
card.className = 'favorite-card';
card.innerHTML = `<span class="favorite-card__star">★</span><span><strong>${escapeHtml(fav.name)}</strong><small>Abfahrten anzeigen</small></span><span class="favorite-card__arrow">→</span>`;
card.dataset.favId = fav.id;
card.innerHTML = `
<span class="favorite-card__star">★</span>
<span>
<strong>${escapeHtml(fav.name)}</strong>
<small class="favorite-card__next" data-next-departure>Lade nächste Abfahrt…</small>
</span>
<span class="favorite-card__arrow">→</span>
`;
card.addEventListener('click', () => selectStation(fav));
cardContainer.appendChild(card);
}
@@ -107,6 +114,38 @@ const App = (() => {
if (empty) empty.hidden = favorites.length > 0;
const home = document.getElementById('favorites-home');
if (home) home.hidden = favorites.length === 0;
if (favorites.length > 0) loadFavoriteNextDepartures(favorites);
}
async function loadFavoriteNextDepartures(favorites) {
const stations = favorites.map((f) => ({ id: f.id, source: f.source || 'vbb', name: f.name }));
try {
const { results } = await API.getSummaryDepartures(stations);
results.forEach((r) => {
const card = document.querySelector(`.favorite-card[data-fav-id="${CSS.escape(String(r.id))}"]`);
if (!card) return;
const el = card.querySelector('[data-next-departure]');
if (!el) return;
if (r.error || !r.nextDeparture) {
el.textContent = 'Keine Abfahrt gefunden';
return;
}
const dep = r.nextDeparture;
const minutesUntil = dep.when ? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000)) : null;
if (dep.cancelled) {
el.innerHTML = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
} else {
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
}
});
} catch (err) {
// Stillschweigend ignorieren - die Karten zeigen dann weiterhin "Lade..."
// bzw. der Nutzer bekommt die Abfahrten trotzdem beim Antippen der Karte.
console.error('Sammel-Abfahrten-Fehler:', err.message);
}
}
function updateFavoriteButton() {
@@ -155,9 +194,8 @@ const App = (() => {
searchDebounce = setTimeout(async () => {
try {
const { locations } = await API.searchLocations(query);
const stops = locations.filter((l) => l.kind === 'stop');
renderSearchResults(results, stops, (station) => {
const stations = await API.searchAll(query);
renderSearchResults(results, stations, (station) => {
selectStation(station);
input.value = '';
results.hidden = true;
@@ -178,10 +216,13 @@ const App = (() => {
});
}
function kindIcon(kind) {
if (kind === 'address') return '📍';
if (kind === 'poi') return '⭐';
return '🚏';
// Ein Symbol pro Ergebnis-Herkunft: VBB-Haltestelle (Nahverkehr), echter
// DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant).
function resultIcon(loc) {
if (loc.source === 'db') return '🚉';
if (loc.kind === 'address') return '📍';
if (loc.kind === 'poi') return '⭐';
return '🚏'; // VBB-Haltestelle (U/S/Tram/Bus)
}
function renderSearchResults(container, locations, onSelect) {
@@ -196,7 +237,8 @@ const App = (() => {
locations.forEach((loc) => {
const li = document.createElement('li');
li.className = 'search-result';
li.innerHTML = `<span class="search-result__icon">${kindIcon(loc.kind)}</span> ${loc.name}`;
const badge = loc.source === 'db' ? '<span class="search-result__badge">DB · bundesweit</span>' : '';
li.innerHTML = `<span class="search-result__icon">${resultIcon(loc)}</span> ${loc.name}${badge}`;
li.addEventListener('click', () => onSelect(loc));
container.appendChild(li);
});
@@ -206,7 +248,7 @@ const App = (() => {
function selectStation(station) {
currentStop = station;
localStorage.setItem(LAST_STOP_KEY, JSON.stringify({ id: station.id, name: station.name }));
localStorage.setItem(LAST_STOP_KEY, JSON.stringify({ id: station.id, name: station.name, source: station.source }));
saveRecentSearch(station);
activateTab('departures');
document.getElementById('board-station-name').textContent = station.name;
@@ -214,6 +256,12 @@ const App = (() => {
document.getElementById('welcome-panel').hidden = true;
document.getElementById('departures-view').hidden = false;
updateFavoriteButton();
// 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');
if (modeFilterEl) modeFilterEl.hidden = station.source === 'db';
loadDepartures();
restartDeparturesPolling();
}
@@ -223,28 +271,39 @@ const App = (() => {
const board = document.getElementById('departure-board');
try {
const { departures } = await API.getDepartures(currentStop.id, {
const departures = currentStop.source === 'db'
? (await API.getDbDepartures(currentStop.id)).departures
: (await API.getDepartures(currentStop.id, {
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
});
})).departures;
renderDepartureBoard(departures);
} catch (err) {
board.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
}
let lastRenderedSource = null;
function renderDepartureBoard(departures) {
const board = document.getElementById('departure-board');
const source = currentStop ? currentStop.source : null;
if (departures.length === 0) {
board.innerHTML = '<div class="board-empty">Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.</div>';
lastRenderedSource = source;
return;
}
const existingRows = board.querySelectorAll('.flap-row');
if (existingRows.length !== departures.length) {
// Board komplett neu aufbauen, wenn sich die Anzahl ändert ODER die
// Quelle wechselt (VBB<->DB) - sonst blieben bei zufällig gleicher
// Zeilenzahl alte Klick-Handler/Klassen vom vorherigen Backend hängen.
if (existingRows.length !== departures.length || lastRenderedSource !== source) {
board.innerHTML = '';
departures.forEach((dep, i) => {
board.appendChild(buildDepartureRow(dep, i));
});
lastRenderedSource = source;
}
departures.forEach((dep, i) => {
@@ -256,8 +315,6 @@ const App = (() => {
const row = document.createElement('div');
row.className = 'flap-row';
row.style.setProperty('--row-index', index);
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.innerHTML = `
<div class="flap-field flap-field--line">
<span class="flap-label">Linie</span>
@@ -276,6 +333,13 @@ const App = (() => {
<span class="flap-cell flap-cell--time" data-field="time"></span>
</div>
`;
// Klick-für-Fahrt-Details gibt es aktuell nur für VBB-Haltestellen (kein
// passender Backend-Endpunkt für DB-Streckenverlauf/Zwischenhalte).
if (currentStop && currentStop.source !== 'db') {
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.classList.add('flap-row--clickable');
const openModal = () => {
if (row.dataset.tripId) TripModal.open(row.dataset.tripId);
};
@@ -286,6 +350,8 @@ const App = (() => {
openModal();
}
});
}
return row;
}
@@ -361,6 +427,10 @@ const App = (() => {
if (tabName === 'disruptions' && !remarksLoadedOnce) {
loadRemarks();
}
if (tabName === 'fares' && !faresLoadedOnce) {
loadFares();
}
}
function initNearby() {
@@ -433,6 +503,78 @@ const App = (() => {
});
}
function initNearbyDisruptions() {
const btn = document.getElementById('disruptions-locate');
if (!btn) return;
btn.addEventListener('click', async () => {
const container = document.getElementById('nearby-disruptions-list');
container.innerHTML = '<div class="board-empty">Standort wird ermittelt…</div>';
try {
const { lat, lon } = await getUserLocation();
container.innerHTML = '<div class="board-empty">Suche Störungen in der Nähe…</div>';
const { disruptions } = await API.getNearbyDisruptions({ lat, lon, distance: 1000, stops: 6 });
renderNearbyDisruptions(disruptions, container);
} catch (err) {
container.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
});
}
function renderNearbyDisruptions(disruptions, container) {
container.innerHTML = '';
if (disruptions.length === 0) {
container.innerHTML = '<div class="board-empty">Aktuell keine Störungen in deiner Nähe bekannt.</div>';
return;
}
disruptions.forEach((d) => {
const card = document.createElement('div');
card.className = 'remark-card';
const lines = d.affectedLines.length ? `Linie${d.affectedLines.length > 1 ? 'n' : ''} ${d.affectedLines.join(', ')}` : '';
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__meta">${escapeHtml(lines)}${escapeHtml(stops)}</div>
`;
container.appendChild(card);
});
}
let faresLoadedOnce = false;
async function loadFares() {
const content = document.getElementById('fares-content');
const companies = document.getElementById('fares-companies');
try {
const fares = await API.getFares();
faresLoadedOnce = true;
document.getElementById('fares-stand').textContent = `Stand: ${new Date(fares.stand).toLocaleDateString('de-DE')}`;
content.innerHTML = fares.gruppen.map((gruppe) => `
<div class="fare-group">
<h3>${escapeHtml(gruppe.titel)}</h3>
<div class="fare-rows">
${gruppe.tickets.map((t) => `
<div class="fare-row">
<div><strong>${escapeHtml(t.name)}</strong><small>${escapeHtml(t.bereich)}${t.hinweis ? ' · ' + escapeHtml(t.hinweis) : ''}</small></div>
<div class="fare-row__price">${t.preis.toFixed(2).replace('.', ',')} €</div>
</div>
`).join('')}
</div>
</div>
`).join('');
companies.innerHTML = fares.unternehmen.map((u) => `
<a class="company-link" href="${u.url}" target="_blank" rel="noopener noreferrer">
<span><strong>${escapeHtml(u.name)}</strong><small>${escapeHtml(u.beschreibung)}</small></span>
<span class="company-link__arrow">↗</span>
</a>
`).join('');
} catch (err) {
content.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
}
async function loadRemarks() {
const container = document.getElementById('remarks-list');
container.innerHTML = '<div class="board-empty">Lade Störungsmeldungen…</div>';
@@ -465,6 +607,7 @@ const App = (() => {
container.appendChild(card);
});
}
function initJourneyPlanner() {
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
@@ -514,7 +657,6 @@ const App = (() => {
to: journeyTo,
when: whenInput || undefined,
arrival: journeyArrivalMode,
modes: journeyModeFilter ? journeyModeFilter.getModesParam() : null,
results: 5,
};
lastJourneyParams = params;
@@ -544,7 +686,7 @@ const App = (() => {
try {
const { locations } = await API.searchLocations(query);
renderSearchResults(results, locations, (loc) => {
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name });
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind });
input.value = loc.name;
results.hidden = true;
});
@@ -599,8 +741,15 @@ const App = (() => {
})
.join('');
const fareHtml = journey.fareEstimate && journey.fareEstimate.price != null
? journey.fareEstimate.exact
? `<div class="journey-fare journey-fare--exact">💶 ${journey.fareEstimate.price.toFixed(2).replace('.', ',')} € <small>(Tarifbereich ${journey.fareEstimate.zone}, Einzelfahrschein, ohne Gewähr)</small></div>`
: `<div class="journey-fare">💶 ca. ${journey.fareEstimate.price.toFixed(2).replace('.', ',')} € <small>(Tarifbereich ~${journey.fareEstimate.zone}, geschätzt, ohne Gewähr)</small></div>`
: '';
card.innerHTML = `
${legsHtml}
${fareHtml}
<button class="journey-map-toggle" data-journey-index="${journeyIndex}">🗺️ Streckenverlauf anzeigen</button>
<div class="journey-mini-map" id="journey-mini-map-${journeyIndex}"></div>
`;
@@ -664,7 +813,7 @@ const App = (() => {
setTimeout(() => {
miniMapInstance = LiveMap.createMiniMap(mapEl.id, journeyWithPolylines);
}, 200);
}, 200); // Wartet auf CSS-Höhen-Transition, damit Leaflet die Größe korrekt berechnet
}
function restoreLastStop() {
@@ -681,6 +830,7 @@ const App = (() => {
initTabs();
initJourneyPlanner();
initNearby();
initNearbyDisruptions();
renderFavoriteChips();
renderRecentSearches();
@@ -689,11 +839,6 @@ const App = (() => {
'oepnv-navi:modes:departures',
() => loadDepartures(),
);
journeyModeFilter = ModeFilter.create(
document.getElementById('journey-mode-filter'),
'oepnv-navi:modes:journey',
() => {},
);
const mapModeFilter = ModeFilter.create(
document.getElementById('map-mode-filter'),
+17 -2
View File
@@ -20,8 +20,8 @@ const LiveMap = (() => {
let radarPollHandle = null;
let tripPollHandle = null;
let currentTripId = null;
let isolatedVehicleMeta = null;
let getModesParam = () => null;
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
function init() {
if (map) return;
@@ -59,6 +59,8 @@ const LiveMap = (() => {
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
}
// ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ----------
async function refreshRadar() {
if (!map || mode !== 'radar') return;
const bounds = map.getBounds();
@@ -85,6 +87,8 @@ const LiveMap = (() => {
`<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`,
);
// Klick auf eine Linie: isoliert sie (alle anderen Fahrzeuge ausblenden,
// Streckenverlauf einzeichnen, live weiterverfolgen).
marker.on('click', () => isolateVehicle(v));
marker.addTo(radarLayer);
});
@@ -106,6 +110,8 @@ const LiveMap = (() => {
}
}
// ---------- Isolations-Modus (eine angeklickte Linie live verfolgen) ----------
async function refreshIsolated() {
if (!map || mode !== 'isolated' || !currentTripId) return;
const bounds = map.getBounds();
@@ -181,6 +187,8 @@ const LiveMap = (() => {
banner.classList.add('is-visible');
}
// ---------- Trip-Modus (bekanntes Fahrzeug aus einer Verbindung heraus verfolgen) ----------
async function refreshTrip() {
if (!currentTripId) return;
@@ -234,6 +242,8 @@ const LiveMap = (() => {
tripMarker = null;
}
// ---------- Standort ----------
function locateUser() {
init();
if (!navigator.geolocation) {
@@ -254,6 +264,8 @@ const LiveMap = (() => {
);
}
// ---------- Modus-Verwaltung ----------
function setMode(newMode) {
if (mode === newMode) return;
if (mode === 'radar') stopRadar();
@@ -264,6 +276,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);
}
@@ -273,6 +286,8 @@ const LiveMap = (() => {
stopTracking();
}
// ---------- Mini-Karte für Verbindungsvorschau (Routenplaner) ----------
function createMiniMap(containerId, journey) {
const miniMap = L.map(containerId, {
zoomControl: false,
+1
View File
@@ -107,6 +107,7 @@ const TripModal = (() => {
? 'Fahrt fällt aus'
: `${trip.origin || ''} → ${trip.destination || ''}`;
// Mini-Karte mit dem Streckenverlauf zeichnen
if (miniMap) { miniMap.remove(); miniMap = null; }
if (trip.polyline) {
setTimeout(() => {
BIN
View File
Binary file not shown.
+209 -9
View File
@@ -286,15 +286,6 @@ button {
opacity: 0.5;
}
.flap-row {
cursor: pointer;
}
.flap-row:hover,
.flap-row:focus-visible {
border-color: var(--signal-amber);
}
.flap-field {
min-width: 0;
display: flex;
@@ -640,6 +631,27 @@ button {
width: 20px;
}
.search-result__badge {
float: right;
font-size: 10px;
color: var(--ink-muted);
background: var(--panel-raised);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
font-family: var(--font-label);
letter-spacing: 0.02em;
}
.flap-row--clickable {
cursor: pointer;
}
.flap-row--clickable:hover,
.flap-row--clickable:focus-visible {
border-color: var(--signal-amber);
}
/* ============================================================
Live-Verfolgen-Button in Journey-Legs
============================================================ */
@@ -838,6 +850,7 @@ button:active { transform: translateY(1px); }
.favorite-card__star { color: var(--signal-amber); font-size: 18px; }
.favorite-card strong, .favorite-card small { display: block; }
.favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
.favorite-card__cancelled { color: var(--signal-red); font-weight: 600; }
.favorite-card__arrow { color: var(--ink-muted); font-size: 18px; }
.nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.nearby-preview__item {
@@ -1214,6 +1227,186 @@ button:active { transform: translateY(1px); }
margin-top: 6px;
}
/* ============================================================
Barrierefrei/Fahrrad-Toggles (Routenplaner)
============================================================ */
.journey-extra-toggles {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.toggle-chip {
display: flex;
align-items: center;
gap: 6px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
color: var(--ink);
cursor: pointer;
user-select: none;
}
.toggle-chip:has(input:checked) {
border-color: var(--signal-amber);
color: var(--signal-amber);
background: rgba(245, 166, 35, 0.1);
}
.toggle-chip input {
accent-color: var(--signal-amber);
}
/* ============================================================
Preisschätzung in Journey-Karten
============================================================ */
.journey-fare {
margin-top: 10px;
padding: 8px 10px;
background: var(--panel-raised);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.journey-fare--exact {
border: 1px solid var(--signal-green);
}
.journey-fare small {
display: block;
margin-top: 2px;
font-size: 11px;
font-weight: 400;
color: var(--ink-muted);
}
/* ============================================================
Störungen: ortsbezogen + netzweit als getrennte Sektionen
============================================================ */
.disruption-section {
margin-bottom: 24px;
}
.disruption-section .section-heading {
margin-bottom: 10px;
}
/* ============================================================
Tickets-Tab
============================================================ */
.disclaimer-banner {
background: rgba(255, 93, 108, 0.1);
border: 1px solid var(--signal-red);
color: var(--signal-red);
border-radius: var(--radius);
padding: 10px 14px;
font-size: 13px;
margin-bottom: 18px;
line-height: 1.4;
}
.fare-group {
margin-bottom: 20px;
}
.fare-group h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-muted);
font-family: var(--font-label);
margin: 0 0 8px;
}
.fare-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.fare-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 14px;
}
.fare-row strong {
display: block;
font-size: 14px;
color: var(--ink);
}
.fare-row small {
display: block;
font-size: 11px;
color: var(--ink-muted);
margin-top: 2px;
}
.fare-row__price {
font-family: var(--font-display);
font-weight: 700;
font-size: 15px;
color: var(--signal-amber);
white-space: nowrap;
}
.company-links {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.company-link {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px 14px;
color: var(--ink);
text-decoration: none;
}
.company-link:hover {
border-color: var(--signal-amber);
}
.company-link strong {
display: block;
font-size: 14px;
}
.company-link small {
display: block;
font-size: 12px;
color: var(--ink-muted);
margin-top: 2px;
}
.company-link__arrow {
color: var(--signal-amber);
font-size: 16px;
flex-shrink: 0;
}
@media (max-width: 700px) {
body { padding-bottom: 66px; }
.app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 12px; }
@@ -1251,3 +1444,10 @@ button:active { transform: translateY(1px); }
.flap-field--platform { display: none; }
.flap-field--time { grid-column: 3; }
}
.journey-source-note {
font-size: 12px;
color: var(--ink-muted);
text-align: center;
margin: 4px 0 14px;
}