Add files via upload
This commit is contained in:
1 parent
055077803c
commit
63da9cdbba
7 files changed
+1046
-612
No files matched your search
+24
-5
@@ -127,9 +127,9 @@
|
|||||||
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
|
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
|
||||||
</div>
|
</div>
|
||||||
<div class="journey-options-row">
|
<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>
|
<button id="journey-submit" class="journey-submit" type="button">Verbindungen suchen <span>→</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="journey-source-note">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
|
||||||
<div id="journey-results" class="journey-results"></div>
|
<div id="journey-results" class="journey-results"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -140,15 +140,32 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tab-panel" data-tab-panel="disruptions" hidden>
|
<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 id="remarks-list" class="remarks-list"><div class="board-empty-state">Störungsmeldungen werden geladen…</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>
|
||||||
|
|
||||||
<section class="tab-panel" data-tab-panel="more" hidden>
|
<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="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">
|
<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="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 & netzweit</small></button>
|
||||||
|
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong>Tickets</strong><small>Preise & wo kaufen</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="recent-section">
|
<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>
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="global-disclaimer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div>
|
||||||
|
|
||||||
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
|
<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 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>
|
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small>Route</small></button>
|
||||||
@@ -173,4 +192,4 @@
|
|||||||
<script src="js/tripmodal.js"></script>
|
<script src="js/tripmodal.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,88 +1,142 @@
|
|||||||
// Schlanker Wrapper um alle Aufrufe an das ÖPNV-Navi-Backend.
|
// Schlanker Wrapper um alle Aufrufe an das ÖPNV-Navi-Backend.
|
||||||
// Wirft bei Fehlern verständliche Errors, die die UI direkt anzeigen kann.
|
// Wirft bei Fehlern verständliche Errors, die die UI direkt anzeigen kann.
|
||||||
|
|
||||||
const API = (() => {
|
const API = (() => {
|
||||||
const BASE = window.APP_CONFIG.API_BASE;
|
const BASE = window.APP_CONFIG.API_BASE;
|
||||||
|
|
||||||
async function request(path) {
|
async function request(path, options = {}) {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${BASE}${path}`);
|
response = await fetch(`${BASE}${path}`, options);
|
||||||
} catch (networkErr) {
|
} catch (networkErr) {
|
||||||
throw new Error('Backend nicht erreichbar. Prüfe deine Internetverbindung.');
|
throw new Error('Backend nicht erreichbar. Prüfe deine Internetverbindung.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `Fehler ${response.status}`;
|
let message = `Fehler ${response.status}`;
|
||||||
try {
|
try {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (body.error) message = body.error;
|
if (body.error) message = body.error;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Antwort war kein JSON, Standardmeldung behalten
|
// Antwort war kein JSON, Standardmeldung behalten
|
||||||
}
|
}
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function qs(params) {
|
function postJson(path, body) {
|
||||||
const parts = [];
|
return request(path, {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
method: 'POST',
|
||||||
if (value === undefined || value === null || value === '') return;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
return parts.join('&');
|
}
|
||||||
}
|
|
||||||
|
// Baut einen Query-String und lässt dabei undefined/null-Werte weg.
|
||||||
return {
|
function qs(params) {
|
||||||
searchLocations(query) {
|
const parts = [];
|
||||||
return request(`/locations/search?query=${encodeURIComponent(query)}`);
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
},
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||||
nearby({ lat, lon, distance, results, includePoi }) {
|
});
|
||||||
return request(`/locations/nearby?${qs({ lat, lon, distance, results, includePoi })}`);
|
return parts.join('&');
|
||||||
},
|
}
|
||||||
|
|
||||||
getDepartures(stationId, { duration = 30, results = 15, modes } = {}) {
|
return {
|
||||||
return request(`/departures/${encodeURIComponent(stationId)}?${qs({ duration, results, modes })}`);
|
searchLocations(query) {
|
||||||
},
|
return request(`/locations/search?query=${encodeURIComponent(query)}`);
|
||||||
|
},
|
||||||
/**
|
|
||||||
* `from`/`to` sind entweder { id, name } (Haltestelle) oder
|
// Bundesweite DB-IRIS-Anbindung: nur "echte" DB-Bahnhöfe (Fernverkehr +
|
||||||
* { lat, lon, name } (Adresse/POI/GPS-Standort, wird als Adresse behandelt).
|
// viele Regio-Stationen), OHNE U-Bahn/Tram/Bus - siehe searchAll().
|
||||||
*/
|
searchDbStations(query) {
|
||||||
getJourneys({ from, to, when, arrival, modes, polylines, results = 5 }) {
|
return request(`/db/stations/search?query=${encodeURIComponent(query)}`);
|
||||||
const fromParams = from.id
|
},
|
||||||
? { fromId: from.id }
|
|
||||||
: { fromLat: from.lat, fromLon: from.lon, fromAddress: from.name };
|
/**
|
||||||
const toParams = to.id
|
* Kombinierte Suche: fragt VBB (U-Bahn/S-Bahn/Tram/Bus, primär Berlin/
|
||||||
? { toId: to.id }
|
* Brandenburg) UND die bundesweite DB-Anbindung (nur echte Bahnhöfe)
|
||||||
: { toLat: to.lat, toLon: to.lon, toAddress: to.name };
|
* parallel ab und liefert eine gemeinsame, quellenmarkierte Liste zurück.
|
||||||
|
* So funktioniert Nahverkehr weiterhin lokal, und man bekommt zusätzlich
|
||||||
return request(
|
* bundesweite Fernverkehrs-/Regio-Bahnhöfe.
|
||||||
`/journeys?${qs({
|
*/
|
||||||
...fromParams,
|
async searchAll(query) {
|
||||||
...toParams,
|
const [vbbResult, dbResult] = await Promise.allSettled([
|
||||||
when: when || undefined,
|
this.searchLocations(query),
|
||||||
arrival: arrival ? 'true' : undefined,
|
this.searchDbStations(query),
|
||||||
modes,
|
]);
|
||||||
polylines: polylines ? 'true' : undefined,
|
|
||||||
results,
|
const vbbStations = vbbResult.status === 'fulfilled'
|
||||||
})}`,
|
? vbbResult.value.locations
|
||||||
);
|
.filter((l) => l.kind === 'stop')
|
||||||
},
|
.map((l) => ({ id: l.id, name: l.name, source: 'vbb' }))
|
||||||
|
: [];
|
||||||
getTrip(tripId, { polyline = false } = {}) {
|
|
||||||
return request(`/trip/${encodeURIComponent(tripId)}?${qs({ polyline: polyline ? 'true' : undefined })}`);
|
const dbStations = dbResult.status === 'fulfilled'
|
||||||
},
|
? dbResult.value.stations.map((s) => ({ id: s.evaNumber, name: s.name, source: 'db' }))
|
||||||
|
: [];
|
||||||
getRemarks({ modes, results = 30 } = {}) {
|
|
||||||
return request(`/remarks?${qs({ modes, results })}`);
|
return [...vbbStations, ...dbStations];
|
||||||
},
|
},
|
||||||
|
|
||||||
getRadar({ north, west, south, east, modes, tripId }) {
|
getDbDepartures(evaNumber, { hours = 2, results = 20 } = {}) {
|
||||||
return request(`/radar?${qs({ north, west, south, east, modes, tripId })}`);
|
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 })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDepartures(stationId, { duration = 30, results = 15, modes } = {}) {
|
||||||
|
return request(`/departures/${encodeURIComponent(stationId)}?${qs({ duration, results, modes })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, polylines, results = 5 }) {
|
||||||
|
return request(
|
||||||
|
`/otp/journeys?${qs({
|
||||||
|
fromLat: from.lat,
|
||||||
|
fromLon: from.lon,
|
||||||
|
toLat: to.lat,
|
||||||
|
toLon: to.lon,
|
||||||
|
when: when || undefined,
|
||||||
|
arrival: arrival ? 'true' : undefined,
|
||||||
|
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 })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRemarks({ modes, results = 30 } = {}) {
|
||||||
|
return request(`/remarks?${qs({ modes, results })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
const App = (() => {
|
const App = (() => {
|
||||||
const FAVORITES_KEY = 'oepnv-navi:favorites';
|
const FAVORITES_KEY = 'oepnv-navi:db-favorites';
|
||||||
const LAST_STOP_KEY = 'oepnv-navi:last-stop';
|
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
|
||||||
const RECENT_SEARCHES_KEY = 'oepnv-navi:recent-searches';
|
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
|
||||||
|
|
||||||
let currentStop = null;
|
let currentStop = null;
|
||||||
let departuresPollHandle = null;
|
let departuresPollHandle = null;
|
||||||
let departuresModeFilter = null;
|
let departuresModeFilter = null;
|
||||||
let journeyModeFilter = null;
|
|
||||||
|
|
||||||
let journeyFrom = null;
|
let journeyFrom = null;
|
||||||
let journeyTo = null;
|
let journeyTo = null;
|
||||||
@@ -38,7 +37,7 @@ const App = (() => {
|
|||||||
function saveRecentSearch(station) {
|
function saveRecentSearch(station) {
|
||||||
if (!station || !station.name) return;
|
if (!station || !station.name) return;
|
||||||
const items = loadRecentSearches().filter((item) => item.id !== station.id);
|
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)));
|
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 6)));
|
||||||
renderRecentSearches();
|
renderRecentSearches();
|
||||||
}
|
}
|
||||||
@@ -97,7 +96,15 @@ const App = (() => {
|
|||||||
if (cardContainer) {
|
if (cardContainer) {
|
||||||
const card = document.createElement('button');
|
const card = document.createElement('button');
|
||||||
card.className = 'favorite-card';
|
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));
|
card.addEventListener('click', () => selectStation(fav));
|
||||||
cardContainer.appendChild(card);
|
cardContainer.appendChild(card);
|
||||||
}
|
}
|
||||||
@@ -107,6 +114,38 @@ const App = (() => {
|
|||||||
if (empty) empty.hidden = favorites.length > 0;
|
if (empty) empty.hidden = favorites.length > 0;
|
||||||
const home = document.getElementById('favorites-home');
|
const home = document.getElementById('favorites-home');
|
||||||
if (home) home.hidden = favorites.length === 0;
|
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() {
|
function updateFavoriteButton() {
|
||||||
@@ -155,9 +194,8 @@ const App = (() => {
|
|||||||
|
|
||||||
searchDebounce = setTimeout(async () => {
|
searchDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { locations } = await API.searchLocations(query);
|
const stations = await API.searchAll(query);
|
||||||
const stops = locations.filter((l) => l.kind === 'stop');
|
renderSearchResults(results, stations, (station) => {
|
||||||
renderSearchResults(results, stops, (station) => {
|
|
||||||
selectStation(station);
|
selectStation(station);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
@@ -178,10 +216,13 @@ const App = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function kindIcon(kind) {
|
// Ein Symbol pro Ergebnis-Herkunft: VBB-Haltestelle (Nahverkehr), echter
|
||||||
if (kind === 'address') return '📍';
|
// DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant).
|
||||||
if (kind === 'poi') return '⭐';
|
function resultIcon(loc) {
|
||||||
return '🚏';
|
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) {
|
function renderSearchResults(container, locations, onSelect) {
|
||||||
@@ -196,7 +237,8 @@ const App = (() => {
|
|||||||
locations.forEach((loc) => {
|
locations.forEach((loc) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'search-result';
|
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));
|
li.addEventListener('click', () => onSelect(loc));
|
||||||
container.appendChild(li);
|
container.appendChild(li);
|
||||||
});
|
});
|
||||||
@@ -206,7 +248,7 @@ const App = (() => {
|
|||||||
|
|
||||||
function selectStation(station) {
|
function selectStation(station) {
|
||||||
currentStop = 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);
|
saveRecentSearch(station);
|
||||||
activateTab('departures');
|
activateTab('departures');
|
||||||
document.getElementById('board-station-name').textContent = station.name;
|
document.getElementById('board-station-name').textContent = station.name;
|
||||||
@@ -214,6 +256,12 @@ const App = (() => {
|
|||||||
document.getElementById('welcome-panel').hidden = true;
|
document.getElementById('welcome-panel').hidden = true;
|
||||||
document.getElementById('departures-view').hidden = false;
|
document.getElementById('departures-view').hidden = false;
|
||||||
updateFavoriteButton();
|
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();
|
loadDepartures();
|
||||||
restartDeparturesPolling();
|
restartDeparturesPolling();
|
||||||
}
|
}
|
||||||
@@ -223,28 +271,39 @@ const App = (() => {
|
|||||||
const board = document.getElementById('departure-board');
|
const board = document.getElementById('departure-board');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { departures } = await API.getDepartures(currentStop.id, {
|
const departures = currentStop.source === 'db'
|
||||||
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
|
? (await API.getDbDepartures(currentStop.id)).departures
|
||||||
});
|
: (await API.getDepartures(currentStop.id, {
|
||||||
|
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
|
||||||
|
})).departures;
|
||||||
renderDepartureBoard(departures);
|
renderDepartureBoard(departures);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
board.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
board.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderDepartureBoard(departures) {
|
|
||||||
|
let lastRenderedSource = null;
|
||||||
|
|
||||||
|
function renderDepartureBoard(departures) {
|
||||||
const board = document.getElementById('departure-board');
|
const board = document.getElementById('departure-board');
|
||||||
|
const source = currentStop ? currentStop.source : null;
|
||||||
|
|
||||||
if (departures.length === 0) {
|
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>';
|
board.innerHTML = '<div class="board-empty">Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.</div>';
|
||||||
|
lastRenderedSource = source;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingRows = board.querySelectorAll('.flap-row');
|
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 = '';
|
board.innerHTML = '';
|
||||||
departures.forEach((dep, i) => {
|
departures.forEach((dep, i) => {
|
||||||
board.appendChild(buildDepartureRow(dep, i));
|
board.appendChild(buildDepartureRow(dep, i));
|
||||||
});
|
});
|
||||||
|
lastRenderedSource = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
departures.forEach((dep, i) => {
|
departures.forEach((dep, i) => {
|
||||||
@@ -256,8 +315,6 @@ const App = (() => {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'flap-row';
|
row.className = 'flap-row';
|
||||||
row.style.setProperty('--row-index', index);
|
row.style.setProperty('--row-index', index);
|
||||||
row.setAttribute('role', 'button');
|
|
||||||
row.setAttribute('tabindex', '0');
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="flap-field flap-field--line">
|
<div class="flap-field flap-field--line">
|
||||||
<span class="flap-label">Linie</span>
|
<span class="flap-label">Linie</span>
|
||||||
@@ -276,16 +333,25 @@ const App = (() => {
|
|||||||
<span class="flap-cell flap-cell--time" data-field="time"></span>
|
<span class="flap-cell flap-cell--time" data-field="time"></span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const openModal = () => {
|
|
||||||
if (row.dataset.tripId) TripModal.open(row.dataset.tripId);
|
// Klick-für-Fahrt-Details gibt es aktuell nur für VBB-Haltestellen (kein
|
||||||
};
|
// passender Backend-Endpunkt für DB-Streckenverlauf/Zwischenhalte).
|
||||||
row.addEventListener('click', openModal);
|
if (currentStop && currentStop.source !== 'db') {
|
||||||
row.addEventListener('keydown', (e) => {
|
row.setAttribute('role', 'button');
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
row.setAttribute('tabindex', '0');
|
||||||
e.preventDefault();
|
row.classList.add('flap-row--clickable');
|
||||||
openModal();
|
const openModal = () => {
|
||||||
}
|
if (row.dataset.tripId) TripModal.open(row.dataset.tripId);
|
||||||
});
|
};
|
||||||
|
row.addEventListener('click', openModal);
|
||||||
|
row.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +427,10 @@ const App = (() => {
|
|||||||
if (tabName === 'disruptions' && !remarksLoadedOnce) {
|
if (tabName === 'disruptions' && !remarksLoadedOnce) {
|
||||||
loadRemarks();
|
loadRemarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tabName === 'fares' && !faresLoadedOnce) {
|
||||||
|
loadFares();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initNearby() {
|
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() {
|
async function loadRemarks() {
|
||||||
const container = document.getElementById('remarks-list');
|
const container = document.getElementById('remarks-list');
|
||||||
container.innerHTML = '<div class="board-empty">Lade Störungsmeldungen…</div>';
|
container.innerHTML = '<div class="board-empty">Lade Störungsmeldungen…</div>';
|
||||||
@@ -465,6 +607,7 @@ const App = (() => {
|
|||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initJourneyPlanner() {
|
function initJourneyPlanner() {
|
||||||
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
|
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
|
||||||
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
|
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
|
||||||
@@ -514,7 +657,6 @@ const App = (() => {
|
|||||||
to: journeyTo,
|
to: journeyTo,
|
||||||
when: whenInput || undefined,
|
when: whenInput || undefined,
|
||||||
arrival: journeyArrivalMode,
|
arrival: journeyArrivalMode,
|
||||||
modes: journeyModeFilter ? journeyModeFilter.getModesParam() : null,
|
|
||||||
results: 5,
|
results: 5,
|
||||||
};
|
};
|
||||||
lastJourneyParams = params;
|
lastJourneyParams = params;
|
||||||
@@ -544,7 +686,7 @@ const App = (() => {
|
|||||||
try {
|
try {
|
||||||
const { locations } = await API.searchLocations(query);
|
const { locations } = await API.searchLocations(query);
|
||||||
renderSearchResults(results, locations, (loc) => {
|
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;
|
input.value = loc.name;
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
});
|
});
|
||||||
@@ -599,8 +741,15 @@ const App = (() => {
|
|||||||
})
|
})
|
||||||
.join('');
|
.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 = `
|
card.innerHTML = `
|
||||||
${legsHtml}
|
${legsHtml}
|
||||||
|
${fareHtml}
|
||||||
<button class="journey-map-toggle" data-journey-index="${journeyIndex}">🗺️ Streckenverlauf anzeigen</button>
|
<button class="journey-map-toggle" data-journey-index="${journeyIndex}">🗺️ Streckenverlauf anzeigen</button>
|
||||||
<div class="journey-mini-map" id="journey-mini-map-${journeyIndex}"></div>
|
<div class="journey-mini-map" id="journey-mini-map-${journeyIndex}"></div>
|
||||||
`;
|
`;
|
||||||
@@ -664,7 +813,7 @@ const App = (() => {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
miniMapInstance = LiveMap.createMiniMap(mapEl.id, journeyWithPolylines);
|
miniMapInstance = LiveMap.createMiniMap(mapEl.id, journeyWithPolylines);
|
||||||
}, 200);
|
}, 200); // Wartet auf CSS-Höhen-Transition, damit Leaflet die Größe korrekt berechnet
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreLastStop() {
|
function restoreLastStop() {
|
||||||
@@ -681,6 +830,7 @@ const App = (() => {
|
|||||||
initTabs();
|
initTabs();
|
||||||
initJourneyPlanner();
|
initJourneyPlanner();
|
||||||
initNearby();
|
initNearby();
|
||||||
|
initNearbyDisruptions();
|
||||||
renderFavoriteChips();
|
renderFavoriteChips();
|
||||||
renderRecentSearches();
|
renderRecentSearches();
|
||||||
|
|
||||||
@@ -689,11 +839,6 @@ const App = (() => {
|
|||||||
'oepnv-navi:modes:departures',
|
'oepnv-navi:modes:departures',
|
||||||
() => loadDepartures(),
|
() => loadDepartures(),
|
||||||
);
|
);
|
||||||
journeyModeFilter = ModeFilter.create(
|
|
||||||
document.getElementById('journey-mode-filter'),
|
|
||||||
'oepnv-navi:modes:journey',
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapModeFilter = ModeFilter.create(
|
const mapModeFilter = ModeFilter.create(
|
||||||
document.getElementById('map-mode-filter'),
|
document.getElementById('map-mode-filter'),
|
||||||
|
|||||||
+336
-321
@@ -1,321 +1,336 @@
|
|||||||
// Live-Karte mit mehreren Modi:
|
// Live-Karte mit mehreren Modi:
|
||||||
// - "radar": zeigt alle Fahrzeuge im aktuellen Kartenausschnitt (Standard),
|
// - "radar": zeigt alle Fahrzeuge im aktuellen Kartenausschnitt (Standard),
|
||||||
// gefiltert nach den gewählten Verkehrsmitteln
|
// gefiltert nach den gewählten Verkehrsmitteln
|
||||||
// - "isolated": ein Fahrzeug wurde angeklickt -> nur dieses wird live weiter-
|
// - "isolated": ein Fahrzeug wurde angeklickt -> nur dieses wird live weiter-
|
||||||
// verfolgt (serverseitig via tripId gefiltert), sein Streckenverlauf
|
// verfolgt (serverseitig via tripId gefiltert), sein Streckenverlauf
|
||||||
// wird eingezeichnet, alle anderen Fahrzeuge sind ausgeblendet
|
// wird eingezeichnet, alle anderen Fahrzeuge sind ausgeblendet
|
||||||
// - "trip": verfolgt ein bekanntes Fahrzeug (z.B. aus einer Verbindung heraus)
|
// - "trip": verfolgt ein bekanntes Fahrzeug (z.B. aus einer Verbindung heraus)
|
||||||
// Nutzt bewusst reines OpenStreetMap ohne zusätzlichen Kartenstil.
|
// Nutzt bewusst reines OpenStreetMap ohne zusätzlichen Kartenstil.
|
||||||
// Zusätzlich: createMiniMap() für kleine, unabhängige Vorschau-Karten in den
|
// Zusätzlich: createMiniMap() für kleine, unabhängige Vorschau-Karten in den
|
||||||
// Verbindungsergebnissen (Routenplaner).
|
// Verbindungsergebnissen (Routenplaner).
|
||||||
|
|
||||||
const LiveMap = (() => {
|
const LiveMap = (() => {
|
||||||
let map = null;
|
let map = null;
|
||||||
let radarLayer = null;
|
let radarLayer = null;
|
||||||
let routeLayer = null;
|
let routeLayer = null;
|
||||||
let userLayer = null;
|
let userLayer = null;
|
||||||
let tripMarker = null;
|
let tripMarker = null;
|
||||||
|
|
||||||
let mode = 'radar';
|
let mode = 'radar';
|
||||||
let radarPollHandle = null;
|
let radarPollHandle = null;
|
||||||
let tripPollHandle = null;
|
let tripPollHandle = null;
|
||||||
let currentTripId = null;
|
let currentTripId = null;
|
||||||
let isolatedVehicleMeta = null;
|
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
|
||||||
let getModesParam = () => null;
|
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (map) return;
|
if (map) return;
|
||||||
|
|
||||||
map = L.map('live-map', { zoomControl: true }).setView([52.5200, 13.4050], 13);
|
map = L.map('live-map', { zoomControl: true }).setView([52.5200, 13.4050], 13);
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
radarLayer = L.layerGroup().addTo(map);
|
radarLayer = L.layerGroup().addTo(map);
|
||||||
routeLayer = L.layerGroup().addTo(map);
|
routeLayer = L.layerGroup().addTo(map);
|
||||||
userLayer = L.layerGroup().addTo(map);
|
userLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
map.on('moveend', () => {
|
map.on('moveend', () => {
|
||||||
if (mode === 'radar') refreshRadar();
|
if (mode === 'radar') refreshRadar();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModeFilter(fn) {
|
function setModeFilter(fn) {
|
||||||
getModesParam = fn;
|
getModesParam = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vehicleIcon(label, bg, fg) {
|
function vehicleIcon(label, bg, fg) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'vehicle-marker',
|
className: 'vehicle-marker',
|
||||||
html: `<span style="background:${bg || '#F5A623'};color:${fg || '#fff'};">${label || '?'}</span>`,
|
html: `<span style="background:${bg || '#F5A623'};color:${fg || '#fff'};">${label || '?'}</span>`,
|
||||||
iconSize: [null, null],
|
iconSize: [null, null],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function polylineToLatLngs(geojson) {
|
function polylineToLatLngs(geojson) {
|
||||||
if (!geojson || !geojson.features) return [];
|
if (!geojson || !geojson.features) return [];
|
||||||
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
|
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRadar() {
|
// ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ----------
|
||||||
if (!map || mode !== 'radar') return;
|
|
||||||
const bounds = map.getBounds();
|
async function refreshRadar() {
|
||||||
|
if (!map || mode !== 'radar') return;
|
||||||
let data;
|
const bounds = map.getBounds();
|
||||||
try {
|
|
||||||
data = await API.getRadar({
|
let data;
|
||||||
north: bounds.getNorth().toFixed(5),
|
try {
|
||||||
west: bounds.getWest().toFixed(5),
|
data = await API.getRadar({
|
||||||
south: bounds.getSouth().toFixed(5),
|
north: bounds.getNorth().toFixed(5),
|
||||||
east: bounds.getEast().toFixed(5),
|
west: bounds.getWest().toFixed(5),
|
||||||
modes: getModesParam(),
|
south: bounds.getSouth().toFixed(5),
|
||||||
});
|
east: bounds.getEast().toFixed(5),
|
||||||
} catch (err) {
|
modes: getModesParam(),
|
||||||
console.error('Radar-Fehler:', err.message);
|
});
|
||||||
return;
|
} catch (err) {
|
||||||
}
|
console.error('Radar-Fehler:', err.message);
|
||||||
|
return;
|
||||||
radarLayer.clearLayers();
|
}
|
||||||
data.vehicles.forEach((v) => {
|
|
||||||
if (v.latitude == null || v.longitude == null) return;
|
radarLayer.clearLayers();
|
||||||
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
data.vehicles.forEach((v) => {
|
||||||
marker.bindPopup(
|
if (v.latitude == null || v.longitude == null) return;
|
||||||
`<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
|
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
||||||
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`,
|
marker.bindPopup(
|
||||||
);
|
`<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
|
||||||
marker.on('click', () => isolateVehicle(v));
|
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`,
|
||||||
marker.addTo(radarLayer);
|
);
|
||||||
});
|
// Klick auf eine Linie: isoliert sie (alle anderen Fahrzeuge ausblenden,
|
||||||
}
|
// Streckenverlauf einzeichnen, live weiterverfolgen).
|
||||||
|
marker.on('click', () => isolateVehicle(v));
|
||||||
function startRadar() {
|
marker.addTo(radarLayer);
|
||||||
init();
|
});
|
||||||
setMode('radar');
|
}
|
||||||
refreshRadar();
|
|
||||||
if (radarPollHandle) clearInterval(radarPollHandle);
|
function startRadar() {
|
||||||
radarPollHandle = setInterval(refreshRadar, 15000);
|
init();
|
||||||
setTimeout(() => map && map.invalidateSize(), 150);
|
setMode('radar');
|
||||||
}
|
refreshRadar();
|
||||||
|
if (radarPollHandle) clearInterval(radarPollHandle);
|
||||||
function stopRadar() {
|
radarPollHandle = setInterval(refreshRadar, 15000);
|
||||||
if (radarPollHandle) {
|
setTimeout(() => map && map.invalidateSize(), 150);
|
||||||
clearInterval(radarPollHandle);
|
}
|
||||||
radarPollHandle = null;
|
|
||||||
}
|
function stopRadar() {
|
||||||
}
|
if (radarPollHandle) {
|
||||||
|
clearInterval(radarPollHandle);
|
||||||
async function refreshIsolated() {
|
radarPollHandle = null;
|
||||||
if (!map || mode !== 'isolated' || !currentTripId) return;
|
}
|
||||||
const bounds = map.getBounds();
|
}
|
||||||
|
|
||||||
let data;
|
// ---------- Isolations-Modus (eine angeklickte Linie live verfolgen) ----------
|
||||||
try {
|
|
||||||
data = await API.getRadar({
|
async function refreshIsolated() {
|
||||||
north: bounds.getNorth().toFixed(5),
|
if (!map || mode !== 'isolated' || !currentTripId) return;
|
||||||
west: bounds.getWest().toFixed(5),
|
const bounds = map.getBounds();
|
||||||
south: bounds.getSouth().toFixed(5),
|
|
||||||
east: bounds.getEast().toFixed(5),
|
let data;
|
||||||
tripId: currentTripId,
|
try {
|
||||||
});
|
data = await API.getRadar({
|
||||||
} catch (err) {
|
north: bounds.getNorth().toFixed(5),
|
||||||
console.error('Radar-Fehler (isoliert):', err.message);
|
west: bounds.getWest().toFixed(5),
|
||||||
return;
|
south: bounds.getSouth().toFixed(5),
|
||||||
}
|
east: bounds.getEast().toFixed(5),
|
||||||
|
tripId: currentTripId,
|
||||||
radarLayer.clearLayers();
|
});
|
||||||
const v = data.vehicles[0];
|
} catch (err) {
|
||||||
if (v && v.latitude != null && v.longitude != null) {
|
console.error('Radar-Fehler (isoliert):', err.message);
|
||||||
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
return;
|
||||||
marker.bindPopup(`<strong>${v.line || ''}</strong><br>${v.direction || ''}<br><small>Nächster Halt: ${v.nextStopover || '–'}</small>`);
|
}
|
||||||
marker.addTo(radarLayer);
|
|
||||||
}
|
radarLayer.clearLayers();
|
||||||
}
|
const v = data.vehicles[0];
|
||||||
|
if (v && v.latitude != null && v.longitude != null) {
|
||||||
async function isolateVehicle(vehicle) {
|
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
||||||
if (!vehicle || !vehicle.tripId) return;
|
marker.bindPopup(`<strong>${v.line || ''}</strong><br>${v.direction || ''}<br><small>Nächster Halt: ${v.nextStopover || '–'}</small>`);
|
||||||
setMode('isolated');
|
marker.addTo(radarLayer);
|
||||||
currentTripId = vehicle.tripId;
|
}
|
||||||
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
|
}
|
||||||
updateIsolationBanner();
|
|
||||||
|
async function isolateVehicle(vehicle) {
|
||||||
routeLayer.clearLayers();
|
if (!vehicle || !vehicle.tripId) return;
|
||||||
try {
|
setMode('isolated');
|
||||||
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
|
currentTripId = vehicle.tripId;
|
||||||
if (trip.polyline) {
|
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
|
||||||
const latlngs = polylineToLatLngs(trip.polyline);
|
updateIsolationBanner();
|
||||||
if (latlngs.length > 0) {
|
|
||||||
L.polyline(latlngs, { color: vehicle.color || '#F5A623', weight: 5, opacity: 0.85 }).addTo(routeLayer);
|
routeLayer.clearLayers();
|
||||||
}
|
try {
|
||||||
}
|
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
|
||||||
} catch (err) {
|
if (trip.polyline) {
|
||||||
console.error('Trip-Polyline-Fehler:', err.message);
|
const latlngs = polylineToLatLngs(trip.polyline);
|
||||||
}
|
if (latlngs.length > 0) {
|
||||||
|
L.polyline(latlngs, { color: vehicle.color || '#F5A623', weight: 5, opacity: 0.85 }).addTo(routeLayer);
|
||||||
refreshIsolated();
|
}
|
||||||
if (radarPollHandle) clearInterval(radarPollHandle);
|
}
|
||||||
radarPollHandle = setInterval(refreshIsolated, 10000);
|
} catch (err) {
|
||||||
}
|
console.error('Trip-Polyline-Fehler:', err.message);
|
||||||
|
}
|
||||||
function clearIsolation() {
|
|
||||||
currentTripId = null;
|
refreshIsolated();
|
||||||
isolatedVehicleMeta = null;
|
if (radarPollHandle) clearInterval(radarPollHandle);
|
||||||
updateIsolationBanner();
|
radarPollHandle = setInterval(refreshIsolated, 10000);
|
||||||
startRadar();
|
}
|
||||||
}
|
|
||||||
|
function clearIsolation() {
|
||||||
function updateIsolationBanner() {
|
currentTripId = null;
|
||||||
const banner = document.getElementById('map-isolation-banner');
|
isolatedVehicleMeta = null;
|
||||||
if (!banner) return;
|
updateIsolationBanner();
|
||||||
if (!isolatedVehicleMeta) {
|
startRadar();
|
||||||
banner.classList.remove('is-visible');
|
}
|
||||||
return;
|
|
||||||
}
|
function updateIsolationBanner() {
|
||||||
const lineEl = document.getElementById('map-isolation-line');
|
const banner = document.getElementById('map-isolation-banner');
|
||||||
if (lineEl) {
|
if (!banner) return;
|
||||||
lineEl.textContent = isolatedVehicleMeta.line || '?';
|
if (!isolatedVehicleMeta) {
|
||||||
lineEl.style.background = isolatedVehicleMeta.color || '#F5A623';
|
banner.classList.remove('is-visible');
|
||||||
lineEl.style.color = isolatedVehicleMeta.textColor || '#fff';
|
return;
|
||||||
}
|
}
|
||||||
banner.classList.add('is-visible');
|
const lineEl = document.getElementById('map-isolation-line');
|
||||||
}
|
if (lineEl) {
|
||||||
|
lineEl.textContent = isolatedVehicleMeta.line || '?';
|
||||||
async function refreshTrip() {
|
lineEl.style.background = isolatedVehicleMeta.color || '#F5A623';
|
||||||
if (!currentTripId) return;
|
lineEl.style.color = isolatedVehicleMeta.textColor || '#fff';
|
||||||
|
}
|
||||||
let data;
|
banner.classList.add('is-visible');
|
||||||
try {
|
}
|
||||||
data = await API.getTrip(currentTripId, { polyline: !tripMarker });
|
|
||||||
} catch (err) {
|
// ---------- Trip-Modus (bekanntes Fahrzeug aus einer Verbindung heraus verfolgen) ----------
|
||||||
console.error('Trip-Fehler:', err.message);
|
|
||||||
return;
|
async function refreshTrip() {
|
||||||
}
|
if (!currentTripId) return;
|
||||||
|
|
||||||
if (data.polyline && routeLayer) {
|
let data;
|
||||||
routeLayer.clearLayers();
|
try {
|
||||||
const latlngs = polylineToLatLngs(data.polyline);
|
data = await API.getTrip(currentTripId, { polyline: !tripMarker });
|
||||||
if (latlngs.length > 0) {
|
} catch (err) {
|
||||||
L.polyline(latlngs, { color: data.color || '#F5A623', weight: 4, opacity: 0.7 }).addTo(routeLayer);
|
console.error('Trip-Fehler:', err.message);
|
||||||
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (data.polyline && routeLayer) {
|
||||||
if (data.currentLocation) {
|
routeLayer.clearLayers();
|
||||||
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
|
const latlngs = polylineToLatLngs(data.polyline);
|
||||||
if (tripMarker) {
|
if (latlngs.length > 0) {
|
||||||
tripMarker.setLatLng(pos);
|
L.polyline(latlngs, { color: data.color || '#F5A623', weight: 4, opacity: 0.7 }).addTo(routeLayer);
|
||||||
} else {
|
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
|
||||||
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
|
}
|
||||||
}
|
}
|
||||||
tripMarker.bindPopup(`<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`);
|
|
||||||
}
|
if (data.currentLocation) {
|
||||||
}
|
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
|
||||||
|
if (tripMarker) {
|
||||||
function trackTrip(tripId) {
|
tripMarker.setLatLng(pos);
|
||||||
init();
|
} else {
|
||||||
setMode('trip');
|
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
|
||||||
currentTripId = tripId;
|
}
|
||||||
tripMarker = null;
|
tripMarker.bindPopup(`<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`);
|
||||||
routeLayer.clearLayers();
|
}
|
||||||
|
}
|
||||||
refreshTrip();
|
|
||||||
if (tripPollHandle) clearInterval(tripPollHandle);
|
function trackTrip(tripId) {
|
||||||
tripPollHandle = setInterval(refreshTrip, 10000);
|
init();
|
||||||
setTimeout(() => map && map.invalidateSize(), 150);
|
setMode('trip');
|
||||||
}
|
currentTripId = tripId;
|
||||||
|
tripMarker = null;
|
||||||
function stopTracking() {
|
routeLayer.clearLayers();
|
||||||
if (tripPollHandle) {
|
|
||||||
clearInterval(tripPollHandle);
|
refreshTrip();
|
||||||
tripPollHandle = null;
|
if (tripPollHandle) clearInterval(tripPollHandle);
|
||||||
}
|
tripPollHandle = setInterval(refreshTrip, 10000);
|
||||||
currentTripId = null;
|
setTimeout(() => map && map.invalidateSize(), 150);
|
||||||
tripMarker = null;
|
}
|
||||||
}
|
|
||||||
|
function stopTracking() {
|
||||||
function locateUser() {
|
if (tripPollHandle) {
|
||||||
init();
|
clearInterval(tripPollHandle);
|
||||||
if (!navigator.geolocation) {
|
tripPollHandle = null;
|
||||||
alert('Geolocation wird von diesem Browser nicht unterstützt.');
|
}
|
||||||
return;
|
currentTripId = null;
|
||||||
}
|
tripMarker = null;
|
||||||
navigator.geolocation.getCurrentPosition(
|
}
|
||||||
(pos) => {
|
|
||||||
const { latitude, longitude } = pos.coords;
|
// ---------- Standort ----------
|
||||||
userLayer.clearLayers();
|
|
||||||
L.marker([latitude, longitude], {
|
function locateUser() {
|
||||||
icon: L.divIcon({ className: 'user-location-marker', iconSize: [16, 16] }),
|
init();
|
||||||
}).addTo(userLayer);
|
if (!navigator.geolocation) {
|
||||||
map.setView([latitude, longitude], 16);
|
alert('Geolocation wird von diesem Browser nicht unterstützt.');
|
||||||
},
|
return;
|
||||||
(err) => alert('Standort konnte nicht ermittelt werden: ' + err.message),
|
}
|
||||||
{ enableHighAccuracy: true, timeout: 10000 },
|
navigator.geolocation.getCurrentPosition(
|
||||||
);
|
(pos) => {
|
||||||
}
|
const { latitude, longitude } = pos.coords;
|
||||||
|
userLayer.clearLayers();
|
||||||
function setMode(newMode) {
|
L.marker([latitude, longitude], {
|
||||||
if (mode === newMode) return;
|
icon: L.divIcon({ className: 'user-location-marker', iconSize: [16, 16] }),
|
||||||
if (mode === 'radar') stopRadar();
|
}).addTo(userLayer);
|
||||||
if (mode === 'isolated') stopRadar();
|
map.setView([latitude, longitude], 16);
|
||||||
if (mode === 'trip') stopTracking();
|
},
|
||||||
if (newMode === 'radar') routeLayer.clearLayers();
|
(err) => alert('Standort konnte nicht ermittelt werden: ' + err.message),
|
||||||
mode = newMode;
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
}
|
);
|
||||||
|
}
|
||||||
function startPolling() {
|
|
||||||
if (mode === 'radar' || mode === 'idle') startRadar();
|
// ---------- Modus-Verwaltung ----------
|
||||||
else if (map) setTimeout(() => map.invalidateSize(), 150);
|
|
||||||
}
|
function setMode(newMode) {
|
||||||
|
if (mode === newMode) return;
|
||||||
function stopPolling() {
|
if (mode === 'radar') stopRadar();
|
||||||
stopRadar();
|
if (mode === 'isolated') stopRadar();
|
||||||
stopTracking();
|
if (mode === 'trip') stopTracking();
|
||||||
}
|
if (newMode === 'radar') routeLayer.clearLayers();
|
||||||
|
mode = newMode;
|
||||||
function createMiniMap(containerId, journey) {
|
}
|
||||||
const miniMap = L.map(containerId, {
|
|
||||||
zoomControl: false,
|
function startPolling() {
|
||||||
attributionControl: false,
|
// Rückwärtskompatibler Einstieg: Karten-Tab wurde geöffnet.
|
||||||
dragging: true,
|
if (mode === 'radar' || mode === 'idle') startRadar();
|
||||||
scrollWheelZoom: false,
|
else if (map) setTimeout(() => map.invalidateSize(), 150);
|
||||||
tap: true,
|
}
|
||||||
}).setView([52.5200, 13.4050], 12);
|
|
||||||
|
function stopPolling() {
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
stopRadar();
|
||||||
attribution: '© OpenStreetMap-Mitwirkende',
|
stopTracking();
|
||||||
maxZoom: 19,
|
}
|
||||||
}).addTo(miniMap);
|
|
||||||
|
// ---------- Mini-Karte für Verbindungsvorschau (Routenplaner) ----------
|
||||||
const allPoints = [];
|
|
||||||
(journey.legs || []).forEach((leg) => {
|
function createMiniMap(containerId, journey) {
|
||||||
if (!leg.polyline) return;
|
const miniMap = L.map(containerId, {
|
||||||
const latlngs = polylineToLatLngs(leg.polyline);
|
zoomControl: false,
|
||||||
if (latlngs.length === 0) return;
|
attributionControl: false,
|
||||||
L.polyline(latlngs, {
|
dragging: true,
|
||||||
color: leg.walking ? '#8E99A6' : leg.color || '#F5A623',
|
scrollWheelZoom: false,
|
||||||
weight: leg.walking ? 3 : 4,
|
tap: true,
|
||||||
dashArray: leg.walking ? '5, 7' : null,
|
}).setView([52.5200, 13.4050], 12);
|
||||||
opacity: 0.9,
|
|
||||||
}).addTo(miniMap);
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
allPoints.push(...latlngs);
|
attribution: '© OpenStreetMap-Mitwirkende',
|
||||||
});
|
maxZoom: 19,
|
||||||
|
}).addTo(miniMap);
|
||||||
if (allPoints.length > 0) {
|
|
||||||
miniMap.fitBounds(L.latLngBounds(allPoints), { padding: [16, 16] });
|
const allPoints = [];
|
||||||
}
|
(journey.legs || []).forEach((leg) => {
|
||||||
|
if (!leg.polyline) return;
|
||||||
setTimeout(() => miniMap.invalidateSize(), 50);
|
const latlngs = polylineToLatLngs(leg.polyline);
|
||||||
return miniMap;
|
if (latlngs.length === 0) return;
|
||||||
}
|
L.polyline(latlngs, {
|
||||||
|
color: leg.walking ? '#8E99A6' : leg.color || '#F5A623',
|
||||||
return {
|
weight: leg.walking ? 3 : 4,
|
||||||
startPolling,
|
dashArray: leg.walking ? '5, 7' : null,
|
||||||
stopPolling,
|
opacity: 0.9,
|
||||||
trackTrip,
|
}).addTo(miniMap);
|
||||||
setModeFilter,
|
allPoints.push(...latlngs);
|
||||||
clearIsolation,
|
});
|
||||||
locateUser,
|
|
||||||
createMiniMap,
|
if (allPoints.length > 0) {
|
||||||
};
|
miniMap.fitBounds(L.latLngBounds(allPoints), { padding: [16, 16] });
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
setTimeout(() => miniMap.invalidateSize(), 50);
|
||||||
|
return miniMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
trackTrip,
|
||||||
|
setModeFilter,
|
||||||
|
clearIsolation,
|
||||||
|
locateUser,
|
||||||
|
createMiniMap,
|
||||||
|
};
|
||||||
|
})();
|
||||||
+150
-149
@@ -1,149 +1,150 @@
|
|||||||
// Modal, das beim Klick auf eine Abfahrtszeile oder einen Verbindungsabschnitt
|
// Modal, das beim Klick auf eine Abfahrtszeile oder einen Verbindungsabschnitt
|
||||||
// geöffnet wird: zeigt Linie, Richtung, eine kleine Karte mit dem Streckenverlauf
|
// 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.
|
||||||
|
|
||||||
const TripModal = (() => {
|
const TripModal = (() => {
|
||||||
let backdrop = null;
|
let backdrop = null;
|
||||||
let miniMap = null;
|
let miniMap = null;
|
||||||
|
|
||||||
function ensureBackdrop() {
|
function ensureBackdrop() {
|
||||||
if (backdrop) return backdrop;
|
if (backdrop) return backdrop;
|
||||||
backdrop = document.createElement('div');
|
backdrop = document.createElement('div');
|
||||||
backdrop.className = 'trip-modal-backdrop';
|
backdrop.className = 'trip-modal-backdrop';
|
||||||
backdrop.hidden = true;
|
backdrop.hidden = true;
|
||||||
backdrop.innerHTML = `
|
backdrop.innerHTML = `
|
||||||
<div class="trip-modal" role="dialog" aria-modal="true" aria-label="Fahrt-Details">
|
<div class="trip-modal" role="dialog" aria-modal="true" aria-label="Fahrt-Details">
|
||||||
<div class="trip-modal__header">
|
<div class="trip-modal__header">
|
||||||
<span class="trip-modal__line" id="trip-modal-line"></span>
|
<span class="trip-modal__line" id="trip-modal-line"></span>
|
||||||
<div class="trip-modal__title">
|
<div class="trip-modal__title">
|
||||||
<strong id="trip-modal-direction"></strong>
|
<strong id="trip-modal-direction"></strong>
|
||||||
<small id="trip-modal-meta"></small>
|
<small id="trip-modal-meta"></small>
|
||||||
</div>
|
</div>
|
||||||
<button class="trip-modal__close" type="button" aria-label="Schließen">×</button>
|
<button class="trip-modal__close" type="button" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="trip-modal__map" id="trip-modal-map"></div>
|
<div class="trip-modal__map" id="trip-modal-map"></div>
|
||||||
<div class="trip-modal__body" id="trip-modal-body">
|
<div class="trip-modal__body" id="trip-modal-body">
|
||||||
<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>
|
<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
backdrop.querySelector('.trip-modal__close').addEventListener('click', close);
|
backdrop.querySelector('.trip-modal__close').addEventListener('click', close);
|
||||||
backdrop.addEventListener('click', (e) => {
|
backdrop.addEventListener('click', (e) => {
|
||||||
if (e.target === backdrop) close();
|
if (e.target === backdrop) close();
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && !backdrop.hidden) close();
|
if (e.key === 'Escape' && !backdrop.hidden) close();
|
||||||
});
|
});
|
||||||
|
|
||||||
return backdrop;
|
return backdrop;
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!backdrop) return;
|
if (!backdrop) return;
|
||||||
backdrop.hidden = true;
|
backdrop.hidden = true;
|
||||||
if (miniMap) {
|
if (miniMap) {
|
||||||
miniMap.remove();
|
miniMap.remove();
|
||||||
miniMap = null;
|
miniMap = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso) {
|
function formatTime(iso) {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStopover(s, isCurrentOrNext) {
|
function renderStopover(s, isCurrentOrNext) {
|
||||||
const time = s.departure || s.arrival || s.plannedDeparture || s.plannedArrival;
|
const time = s.departure || s.arrival || s.plannedDeparture || s.plannedArrival;
|
||||||
const plannedTime = s.plannedDeparture || s.plannedArrival;
|
const plannedTime = s.plannedDeparture || s.plannedArrival;
|
||||||
const delay = s.departureDelay ?? s.arrivalDelay;
|
const delay = s.departureDelay ?? s.arrivalDelay;
|
||||||
|
|
||||||
let timeHtml;
|
let timeHtml;
|
||||||
if (s.cancelled) {
|
if (s.cancelled) {
|
||||||
timeHtml = `<span class="trip-stopover__time trip-stopover__time--cancelled">${formatTime(plannedTime) || '–'}</span>`;
|
timeHtml = `<span class="trip-stopover__time trip-stopover__time--cancelled">${formatTime(plannedTime) || '–'}</span>`;
|
||||||
} else if (delay && delay > 0) {
|
} else if (delay && delay > 0) {
|
||||||
timeHtml = `<span class="trip-stopover__time trip-stopover__time--delayed">${formatTime(time)} (+${Math.round(delay / 60)})</span>`;
|
timeHtml = `<span class="trip-stopover__time trip-stopover__time--delayed">${formatTime(time)} (+${Math.round(delay / 60)})</span>`;
|
||||||
} else {
|
} else {
|
||||||
timeHtml = `<span class="trip-stopover__time">${formatTime(time) || '–'}</span>`;
|
timeHtml = `<span class="trip-stopover__time">${formatTime(time) || '–'}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="trip-stopover ${isCurrentOrNext ? 'trip-stopover--current' : ''} ${s.cancelled ? 'trip-stopover--cancelled' : ''}">
|
<div class="trip-stopover ${isCurrentOrNext ? 'trip-stopover--current' : ''} ${s.cancelled ? 'trip-stopover--cancelled' : ''}">
|
||||||
<div class="trip-stopover__row">
|
<div class="trip-stopover__row">
|
||||||
<span class="trip-stopover__name">${s.name || ''}</span>
|
<span class="trip-stopover__name">${s.name || ''}</span>
|
||||||
${timeHtml}
|
${timeHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(tripId) {
|
async function open(tripId) {
|
||||||
if (!tripId) return;
|
if (!tripId) return;
|
||||||
const el = ensureBackdrop();
|
const el = ensureBackdrop();
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
|
|
||||||
document.getElementById('trip-modal-line').textContent = '…';
|
document.getElementById('trip-modal-line').textContent = '…';
|
||||||
document.getElementById('trip-modal-line').style.background = '#8E99A6';
|
document.getElementById('trip-modal-line').style.background = '#8E99A6';
|
||||||
document.getElementById('trip-modal-direction').textContent = '';
|
document.getElementById('trip-modal-direction').textContent = '';
|
||||||
document.getElementById('trip-modal-meta').textContent = '';
|
document.getElementById('trip-modal-meta').textContent = '';
|
||||||
document.getElementById('trip-modal-body').innerHTML = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
|
document.getElementById('trip-modal-body').innerHTML = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
|
||||||
|
|
||||||
let trip;
|
let trip;
|
||||||
try {
|
try {
|
||||||
trip = await API.getTrip(tripId, { polyline: true });
|
trip = await API.getTrip(tripId, { polyline: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('trip-modal-body').innerHTML = `<div class="trip-modal__error">⚠ ${err.message}</div>`;
|
document.getElementById('trip-modal-body').innerHTML = `<div class="trip-modal__error">⚠ ${err.message}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineEl = document.getElementById('trip-modal-line');
|
const lineEl = document.getElementById('trip-modal-line');
|
||||||
lineEl.textContent = trip.line || '?';
|
lineEl.textContent = trip.line || '?';
|
||||||
lineEl.style.background = trip.color || '#8E99A6';
|
lineEl.style.background = trip.color || '#8E99A6';
|
||||||
lineEl.style.color = trip.textColor || '#fff';
|
lineEl.style.color = trip.textColor || '#fff';
|
||||||
|
|
||||||
document.getElementById('trip-modal-direction').textContent = `→ ${trip.direction || trip.destination || ''}`;
|
document.getElementById('trip-modal-direction').textContent = `→ ${trip.direction || trip.destination || ''}`;
|
||||||
document.getElementById('trip-modal-meta').textContent = trip.cancelled
|
document.getElementById('trip-modal-meta').textContent = trip.cancelled
|
||||||
? 'Fahrt fällt aus'
|
? 'Fahrt fällt aus'
|
||||||
: `${trip.origin || ''} → ${trip.destination || ''}`;
|
: `${trip.origin || ''} → ${trip.destination || ''}`;
|
||||||
|
|
||||||
if (miniMap) { miniMap.remove(); miniMap = null; }
|
// Mini-Karte mit dem Streckenverlauf zeichnen
|
||||||
if (trip.polyline) {
|
if (miniMap) { miniMap.remove(); miniMap = null; }
|
||||||
setTimeout(() => {
|
if (trip.polyline) {
|
||||||
miniMap = LiveMap.createMiniMap('trip-modal-map', {
|
setTimeout(() => {
|
||||||
legs: [{ polyline: trip.polyline, walking: false, color: trip.color }],
|
miniMap = LiveMap.createMiniMap('trip-modal-map', {
|
||||||
});
|
legs: [{ polyline: trip.polyline, walking: false, color: trip.color }],
|
||||||
if (trip.currentLocation) {
|
});
|
||||||
L.marker([trip.currentLocation.latitude, trip.currentLocation.longitude], {
|
if (trip.currentLocation) {
|
||||||
icon: L.divIcon({
|
L.marker([trip.currentLocation.latitude, trip.currentLocation.longitude], {
|
||||||
className: 'vehicle-marker',
|
icon: L.divIcon({
|
||||||
html: `<span style="background:${trip.color || '#F5A623'};color:${trip.textColor || '#fff'};">${trip.line || '?'}</span>`,
|
className: 'vehicle-marker',
|
||||||
iconSize: [null, null],
|
html: `<span style="background:${trip.color || '#F5A623'};color:${trip.textColor || '#fff'};">${trip.line || '?'}</span>`,
|
||||||
}),
|
iconSize: [null, null],
|
||||||
}).addTo(miniMap);
|
}),
|
||||||
}
|
}).addTo(miniMap);
|
||||||
}, 50);
|
}
|
||||||
} else {
|
}, 50);
|
||||||
document.getElementById('trip-modal-map').innerHTML = '';
|
} else {
|
||||||
}
|
document.getElementById('trip-modal-map').innerHTML = '';
|
||||||
|
}
|
||||||
const stopovers = trip.stopovers || [];
|
|
||||||
const now = Date.now();
|
const stopovers = trip.stopovers || [];
|
||||||
let currentIndex = stopovers.findIndex((s) => {
|
const now = Date.now();
|
||||||
const t = s.departure || s.plannedDeparture;
|
let currentIndex = stopovers.findIndex((s) => {
|
||||||
return t && new Date(t).getTime() >= now;
|
const t = s.departure || s.plannedDeparture;
|
||||||
});
|
return t && new Date(t).getTime() >= now;
|
||||||
if (currentIndex === -1) currentIndex = stopovers.length - 1;
|
});
|
||||||
|
if (currentIndex === -1) currentIndex = stopovers.length - 1;
|
||||||
const bodyHtml = `
|
|
||||||
<div class="trip-stopovers">
|
const bodyHtml = `
|
||||||
${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
|
<div class="trip-stopovers">
|
||||||
</div>
|
${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
|
||||||
`;
|
</div>
|
||||||
document.getElementById('trip-modal-body').innerHTML = stopovers.length
|
`;
|
||||||
? bodyHtml
|
document.getElementById('trip-modal-body').innerHTML = stopovers.length
|
||||||
: '<div class="trip-modal__loading">Keine Zwischenhalte verfügbar.</div>';
|
? bodyHtml
|
||||||
}
|
: '<div class="trip-modal__loading">Keine Zwischenhalte verfügbar.</div>';
|
||||||
|
}
|
||||||
return { open, close };
|
|
||||||
})();
|
return { open, close };
|
||||||
|
})();
|
||||||
Binary file not shown.
+210
-10
@@ -286,15 +286,6 @@ button {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flap-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flap-row:hover,
|
|
||||||
.flap-row:focus-visible {
|
|
||||||
border-color: var(--signal-amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flap-field {
|
.flap-field {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -640,6 +631,27 @@ button {
|
|||||||
width: 20px;
|
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
|
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__star { color: var(--signal-amber); font-size: 18px; }
|
||||||
.favorite-card strong, .favorite-card small { display: block; }
|
.favorite-card strong, .favorite-card small { display: block; }
|
||||||
.favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
|
.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; }
|
.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 { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
|
||||||
.nearby-preview__item {
|
.nearby-preview__item {
|
||||||
@@ -1214,6 +1227,186 @@ button:active { transform: translateY(1px); }
|
|||||||
margin-top: 6px;
|
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) {
|
@media (max-width: 700px) {
|
||||||
body { padding-bottom: 66px; }
|
body { padding-bottom: 66px; }
|
||||||
.app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 12px; }
|
.app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 12px; }
|
||||||
@@ -1250,4 +1443,11 @@ button:active { transform: translateY(1px); }
|
|||||||
.flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
|
.flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
|
||||||
.flap-field--platform { display: none; }
|
.flap-field--platform { display: none; }
|
||||||
.flap-field--time { grid-column: 3; }
|
.flap-field--time { grid-column: 3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.journey-source-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user