Routenplanung auf HAFAS (/journeys) umstellen, Zwischenhalte, Filter, Paginierung
Löst die WebApp aus der eigenen OTP2-Instanz heraus und nutzt dieselbe
HAFAS-Anbindung (VBB + Deutsche Bahn) wie die iOS-App, damit beide Clients
gegen dasselbe Backend-Feature-Set laufen:
- js/api.js: getJourneys() ruft jetzt /journeys statt /otp/journeys auf,
inkl. via0-2 (Lat/Lon/Name/WaitMinutes), modes, bike, earlierRef/laterRef.
Gibt die volle Antwort zurück statt nur { journeys }.
- js/app.js: bis zu 3 Zwischenhalte mit eigener Ortssuche und Aufenthaltszeit,
Verkehrsmittel-Filter (ModeFilter-Komponente wiederverwendet), Fahrrad-
Toggle, Früher/Später-Buttons zum Nachladen. Share-Link-Format um
Vias/Modes/Bike erweitert, alter Umstiegszeit-Puffer entfernt (von HAFAS
nicht unterstützt).
- index.html/styles.css: neue Formularelemente für Zwischenhalte, Filter
und Paginierung.
- js/i18n.js: neue Übersetzungsschlüssel in DE/EN, alten sourceNote-String
korrigiert (& wurde per textContent gesetzt und wäre literal
angezeigt worden statt als &).
- README.md: Feature-Beschreibung aktualisiert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01De7H8hR1Q9DdgyuS2ukN6y
This commit is contained in:
6 files changed
+358
-73
No files matched your search
@@ -56,8 +56,9 @@ Ordnerinhalt auf einen Webserver (nginx, Apache, Vercel, o.ä.) legen.
|
||||
|
||||
- **Abfahrten**: Live-Abfahrtstafel (VBB + echte DB-Bahnhöfe), Verkehrsmittel-Filter,
|
||||
Gleiswechsel-Hinweis, Störungen & Aufzugsstatus direkt unter der Tafel
|
||||
- **Route**: Routenplanung über eine selbst gehostete OTP2-Instanz, konfigurierbarer
|
||||
Umstiegszeit-Puffer, rollstuhlgerechte Verbindungen, Route teilen (Link)
|
||||
- **Route**: Routenplanung über HAFAS (VBB + Deutsche Bahn, dieselbe Anbindung wie die
|
||||
iOS-App), bis zu 3 Zwischenhalte, Verkehrsmittel-Filter, Fahrrad-Option,
|
||||
rollstuhlgerechte Verbindungen, Früher/Später-Nachladen, Route teilen (Link)
|
||||
- **Karte**: Live-Fahrzeugpositionen, Fahrt isolieren/verfolgen, display-breite
|
||||
Darstellung auf jeder Bildschirmgröße
|
||||
- **Favoriten**: eigener Tab, Live-Vorschau der nächsten Abfahrt, Störungs-Badges
|
||||
|
||||
+18
-12
@@ -130,29 +130,35 @@
|
||||
<button class="when-choice" data-when="arrival" type="button" aria-pressed="false" data-i18n="journey.arrivalAt">Ankunft um</button>
|
||||
<input type="datetime-local" id="journey-when" class="journey-datetime" aria-label="Datum und Uhrzeit" hidden />
|
||||
</div>
|
||||
<div id="journey-vias" class="journey-vias"></div>
|
||||
<button id="journey-add-via" class="text-button journey-add-via" type="button">+ <span data-i18n="journey.addVia">Zwischenhalt hinzufügen</span></button>
|
||||
|
||||
<label class="wheelchair-toggle">
|
||||
<input type="checkbox" id="journey-wheelchair" />
|
||||
<span><span aria-hidden="true">♿</span> <span data-i18n="journey.wheelchair">Nur rollstuhlgerechte Verbindungen</span></span>
|
||||
</label>
|
||||
<label class="wheelchair-toggle" id="journey-bike-row">
|
||||
<input type="checkbox" id="journey-bike" />
|
||||
<span><span aria-hidden="true">🚲</span> <span data-i18n="journey.bike">Fahrrad mitnehmen</span></span>
|
||||
</label>
|
||||
|
||||
<div class="journey-modes-field" id="journey-modes-field">
|
||||
<span class="journey-modes-field__label" data-i18n="journey.modes">Verkehrsmittel</span>
|
||||
<div id="journey-mode-filter" class="mode-filter"></div>
|
||||
</div>
|
||||
|
||||
<div class="journey-options-row">
|
||||
<div class="transfer-slack-field">
|
||||
<label for="journey-transfer-slack" data-i18n="journey.transferSlack">Umstiegszeit-Puffer</label>
|
||||
<select id="journey-transfer-slack">
|
||||
<option value="" data-i18n="journey.transferSlackDefault">Standard</option>
|
||||
<option value="0" data-i18n="journey.transferSlack0">0 min (knapp)</option>
|
||||
<option value="3">3 min</option>
|
||||
<option value="5">5 min</option>
|
||||
<option value="10">10 min</option>
|
||||
<option value="15" data-i18n="journey.transferSlack15">15 min (sicher)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="journey-submit" class="journey-submit" type="button"><span data-i18n="journey.submit">Verbindungen suchen</span> <span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
<p class="journey-source-note" data-i18n="journey.sourceNote">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
|
||||
<p class="journey-source-note" data-i18n="journey.sourceNote">🛰 Routenplanung mit Echtzeitdaten von VBB & Deutscher Bahn</p>
|
||||
<div id="journey-share-row" class="journey-share-row" hidden>
|
||||
<button id="journey-share-btn" class="text-button" type="button"><span aria-hidden="true">🔗</span> <span data-i18n="journey.share">Route teilen</span></button>
|
||||
</div>
|
||||
<div id="journey-results" class="journey-results" aria-live="polite"></div>
|
||||
<div id="journey-load-more-row" class="journey-load-more-row" hidden>
|
||||
<button id="journey-earlier-btn" class="journey-load-more-btn" type="button" disabled>↑ <span data-i18n="journey.earlier">Früher</span></button>
|
||||
<button id="journey-later-btn" class="journey-load-more-btn" type="button" disabled>↓ <span data-i18n="journey.later">Später</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-tab-panel="nearby" hidden>
|
||||
|
||||
@@ -102,25 +102,52 @@ const API = (() => {
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Nutzt dieselbe HAFAS-Anbindung (VBB + Deutsche Bahn) wie die iOS-App.
|
||||
* `from`/`to` brauchen { lat, lon, name }. `vias` ist eine Liste von bis
|
||||
* zu 3 Zwischenhalten ({ lat, lon, name, waitMinutes }), in Reihenfolge
|
||||
* der Route. Sobald mindestens ein Via gesetzt ist, verkettet das
|
||||
* Backend mehrere Einzelabfragen - dann gelten immer "Abfahrt um" sowie
|
||||
* feste results=1 pro Abschnitt, und earlierRef/laterRef liefern null
|
||||
* zurück (siehe routes/journeys.js im Backend).
|
||||
* `earlierRef`/`laterRef` laden, aus einer vorherigen Antwort übernommen,
|
||||
* die nächste bzw. vorherige Seite an Verbindungen nach (ohne Vias).
|
||||
* Gibt die volle Antwort zurück ({ journeys, earlierRef, laterRef }).
|
||||
*/
|
||||
getJourneys({ from, to, when, arrival, polylines, results = 5, transferSlack, wheelchair }) {
|
||||
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,
|
||||
transferSlack,
|
||||
wheelchair: wheelchair ? 'true' : undefined,
|
||||
})}`,
|
||||
);
|
||||
getJourneys({
|
||||
from, to, when, arrival, polylines, results = 5,
|
||||
wheelchair, modes, bike, vias = [], earlierRef, laterRef,
|
||||
}) {
|
||||
const params = {
|
||||
fromLat: from.lat,
|
||||
fromLon: from.lon,
|
||||
fromName: from.name || undefined,
|
||||
toLat: to.lat,
|
||||
toLon: to.lon,
|
||||
toName: to.name || undefined,
|
||||
results,
|
||||
polylines: polylines ? 'true' : undefined,
|
||||
accessibility: wheelchair ? 'true' : undefined,
|
||||
modes: modes || undefined,
|
||||
bike: bike ? 'true' : undefined,
|
||||
};
|
||||
|
||||
if (earlierRef) {
|
||||
params.earlierRef = earlierRef;
|
||||
} else if (laterRef) {
|
||||
params.laterRef = laterRef;
|
||||
} else if (when) {
|
||||
params.when = when;
|
||||
if (arrival) params.arrival = 'true';
|
||||
}
|
||||
|
||||
vias.slice(0, 3).forEach((via, i) => {
|
||||
params[`via${i}Lat`] = via.lat;
|
||||
params[`via${i}Lon`] = via.lon;
|
||||
if (via.name) params[`via${i}Name`] = via.name;
|
||||
if (via.waitMinutes) params[`via${i}WaitMinutes`] = via.waitMinutes;
|
||||
});
|
||||
|
||||
return request(`/journeys?${qs(params)}`);
|
||||
},
|
||||
|
||||
getFares() {
|
||||
|
||||
@@ -3,18 +3,24 @@ const App = (() => {
|
||||
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
|
||||
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
|
||||
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
|
||||
const TRANSFER_SLACK_KEY = 'oepnv-navi:db-transfer-slack';
|
||||
const WHEELCHAIR_KEY = 'oepnv-navi:db-wheelchair';
|
||||
const BIKE_KEY = 'oepnv-navi:db-bike';
|
||||
const FAVORITES_POLL_MS = 30000;
|
||||
const MAX_VIAS = 3;
|
||||
const VIA_WAIT_OPTIONS = [0, 5, 10, 15, 20, 30, 45, 60];
|
||||
|
||||
let currentStop = null;
|
||||
let departuresPollHandle = null;
|
||||
let departuresModeFilter = null;
|
||||
let journeyModeFilter = null;
|
||||
let favoritesPollHandle = null;
|
||||
|
||||
let journeyFrom = null;
|
||||
let journeyTo = null;
|
||||
let journeyArrivalMode = false;
|
||||
let journeyVias = []; // { lat, lon, name, waitMinutes }
|
||||
let journeyEarlierRef = null;
|
||||
let journeyLaterRef = null;
|
||||
let lastJourneyParams = null;
|
||||
let remarksLoadedOnce = false;
|
||||
|
||||
@@ -1185,17 +1191,6 @@ const App = (() => {
|
||||
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
|
||||
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
|
||||
|
||||
// Zuletzt gewählten Umstiegszeit-Puffer wiederherstellen, damit die
|
||||
// Einstellung nicht bei jedem Besuch neu gesetzt werden muss.
|
||||
const transferSlackSelect = document.getElementById('journey-transfer-slack');
|
||||
if (transferSlackSelect) {
|
||||
const saved = localStorage.getItem(TRANSFER_SLACK_KEY);
|
||||
if (saved !== null) transferSlackSelect.value = saved;
|
||||
transferSlackSelect.addEventListener('change', () => {
|
||||
localStorage.setItem(TRANSFER_SLACK_KEY, transferSlackSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Ebenso für "nur rollstuhlgerechte Verbindungen".
|
||||
const wheelchairToggle = document.getElementById('journey-wheelchair');
|
||||
if (wheelchairToggle) {
|
||||
@@ -1205,6 +1200,15 @@ const App = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Ebenso für "Fahrrad mitnehmen".
|
||||
const bikeToggle = document.getElementById('journey-bike');
|
||||
if (bikeToggle) {
|
||||
bikeToggle.checked = localStorage.getItem(BIKE_KEY) === 'true';
|
||||
bikeToggle.addEventListener('change', () => {
|
||||
localStorage.setItem(BIKE_KEY, bikeToggle.checked ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('journey-use-location').addEventListener('click', async () => {
|
||||
const input = document.getElementById('journey-from');
|
||||
input.value = 'Standort wird ermittelt…';
|
||||
@@ -1228,6 +1232,135 @@ const App = (() => {
|
||||
|
||||
const shareBtn = document.getElementById('journey-share-btn');
|
||||
if (shareBtn) shareBtn.addEventListener('click', shareCurrentJourney);
|
||||
|
||||
const addViaBtn = document.getElementById('journey-add-via');
|
||||
if (addViaBtn) addViaBtn.addEventListener('click', addJourneyVia);
|
||||
renderJourneyVias();
|
||||
|
||||
const earlierBtn = document.getElementById('journey-earlier-btn');
|
||||
const laterBtn = document.getElementById('journey-later-btn');
|
||||
if (earlierBtn) earlierBtn.addEventListener('click', () => loadAdjacentJourneys('earlier'));
|
||||
if (laterBtn) laterBtn.addEventListener('click', () => loadAdjacentJourneys('later'));
|
||||
}
|
||||
|
||||
// ---------- Zwischenhalte (Vias) ----------
|
||||
//
|
||||
// Bis zu MAX_VIAS Zwischenhalte, analog zur iOS-App. Das Backend verkettet
|
||||
// dafür intern mehrere HAFAS-Einzelabfragen (siehe routes/journeys.js) -
|
||||
// sobald mindestens ein Via gesetzt ist, gelten serverseitig immer
|
||||
// "Abfahrt um" und es gibt keine Früher/Später-Buttons mehr.
|
||||
function addJourneyVia() {
|
||||
if (journeyVias.length >= MAX_VIAS) {
|
||||
showToast(I18N.t('journey.maxViasReached'), 'warning');
|
||||
return;
|
||||
}
|
||||
journeyVias.push({ lat: null, lon: null, name: '', waitMinutes: 0 });
|
||||
renderJourneyVias();
|
||||
}
|
||||
|
||||
function removeJourneyVia(index) {
|
||||
journeyVias.splice(index, 1);
|
||||
renderJourneyVias();
|
||||
}
|
||||
|
||||
function renderJourneyVias() {
|
||||
const container = document.getElementById('journey-vias');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
journeyVias.forEach((via, index) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'journey-via-row';
|
||||
|
||||
const waitOptionsHtml = VIA_WAIT_OPTIONS
|
||||
.map((m) => `<option value="${m}">${m === 0 ? escapeHtml(I18N.t('journey.viaWaitDirect')) : `${m} min`}</option>`)
|
||||
.join('');
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="journey-input-marker journey-input-marker--via" aria-hidden="true"></span>
|
||||
<div class="journey-via-row__fields">
|
||||
<div class="journey-via-row__search">
|
||||
<input type="text" class="journey-via-input" placeholder="${escapeHtml(I18N.t('journey.viaPlaceholder', { n: index + 1 }))}" autocomplete="off" value="${escapeHtml(via.name || '')}" />
|
||||
<div class="search-results" hidden></div>
|
||||
</div>
|
||||
<label class="journey-via-wait">
|
||||
<span>${escapeHtml(I18N.t('journey.viaWaitLabel'))}</span>
|
||||
<select>${waitOptionsHtml}</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="journey-via-remove" aria-label="${escapeHtml(I18N.t('journey.removeVia'))}">✕</button>
|
||||
`;
|
||||
|
||||
const input = row.querySelector('.journey-via-input');
|
||||
const resultsEl = row.querySelector('.search-results');
|
||||
setupLocationAutocomplete(input, resultsEl, (loc) => {
|
||||
journeyVias[index] = {
|
||||
...journeyVias[index],
|
||||
lat: loc.latitude,
|
||||
lon: loc.longitude,
|
||||
name: loc.name,
|
||||
};
|
||||
input.value = loc.name;
|
||||
resultsEl.hidden = true;
|
||||
});
|
||||
|
||||
const waitSelect = row.querySelector('select');
|
||||
waitSelect.value = String(via.waitMinutes || 0);
|
||||
waitSelect.addEventListener('change', () => {
|
||||
journeyVias[index] = { ...journeyVias[index], waitMinutes: parseInt(waitSelect.value, 10) || 0 };
|
||||
});
|
||||
|
||||
row.querySelector('.journey-via-remove').addEventListener('click', () => removeJourneyVia(index));
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
const addBtn = document.getElementById('journey-add-via');
|
||||
if (addBtn) addBtn.hidden = journeyVias.length >= MAX_VIAS;
|
||||
}
|
||||
|
||||
// ---------- Früher / Später ----------
|
||||
//
|
||||
// Lädt die vorherige bzw. nächste Seite an Verbindungen nach, mit der
|
||||
// Referenz aus der vorherigen Antwort - ohne Vias verfügbar (siehe
|
||||
// routes/journeys.js).
|
||||
async function loadAdjacentJourneys(direction) {
|
||||
if (!lastJourneyParams) return;
|
||||
const resultsEl = document.getElementById('journey-results');
|
||||
const earlierBtn = document.getElementById('journey-earlier-btn');
|
||||
const laterBtn = document.getElementById('journey-later-btn');
|
||||
|
||||
const params = {
|
||||
...lastJourneyParams,
|
||||
earlierRef: direction === 'earlier' ? journeyEarlierRef : undefined,
|
||||
laterRef: direction === 'later' ? journeyLaterRef : undefined,
|
||||
};
|
||||
|
||||
if (earlierBtn) earlierBtn.disabled = true;
|
||||
if (laterBtn) laterBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await API.getJourneys(params);
|
||||
lastJourneyParams = params;
|
||||
journeyEarlierRef = response.earlierRef || null;
|
||||
journeyLaterRef = response.laterRef || null;
|
||||
renderJourneys(response.journeys, resultsEl);
|
||||
updateJourneyLoadMoreButtons();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'warning');
|
||||
if (earlierBtn) earlierBtn.disabled = !journeyEarlierRef;
|
||||
if (laterBtn) laterBtn.disabled = !journeyLaterRef;
|
||||
}
|
||||
}
|
||||
|
||||
function updateJourneyLoadMoreButtons() {
|
||||
const row = document.getElementById('journey-load-more-row');
|
||||
const earlierBtn = document.getElementById('journey-earlier-btn');
|
||||
const laterBtn = document.getElementById('journey-later-btn');
|
||||
const hasRefs = !!(journeyEarlierRef || journeyLaterRef);
|
||||
if (row) row.hidden = !hasRefs;
|
||||
if (earlierBtn) earlierBtn.disabled = !journeyEarlierRef;
|
||||
if (laterBtn) laterBtn.disabled = !journeyLaterRef;
|
||||
}
|
||||
|
||||
// Setzt "Jetzt/Abfahrt um/Ankunft um" programmatisch (Klick-Handler UND
|
||||
@@ -1265,22 +1398,30 @@ const App = (() => {
|
||||
if (shareRow) shareRow.hidden = true;
|
||||
|
||||
const whenInput = document.getElementById('journey-when').value;
|
||||
const transferSlackSelect = document.getElementById('journey-transfer-slack');
|
||||
const wheelchairToggle = document.getElementById('journey-wheelchair');
|
||||
const bikeToggle = document.getElementById('journey-bike');
|
||||
const validVias = journeyVias.filter((v) => v.lat != null && v.lon != null);
|
||||
const params = {
|
||||
from: journeyFrom,
|
||||
to: journeyTo,
|
||||
when: whenInput || undefined,
|
||||
arrival: journeyArrivalMode,
|
||||
results: 5,
|
||||
transferSlack: transferSlackSelect && transferSlackSelect.value !== '' ? transferSlackSelect.value : undefined,
|
||||
wheelchair: wheelchairToggle ? wheelchairToggle.checked : false,
|
||||
bike: bikeToggle ? bikeToggle.checked : false,
|
||||
modes: journeyModeFilter ? journeyModeFilter.getModesParam() : undefined,
|
||||
vias: validVias,
|
||||
};
|
||||
lastJourneyParams = params;
|
||||
journeyEarlierRef = null;
|
||||
journeyLaterRef = null;
|
||||
|
||||
try {
|
||||
const { journeys } = await API.getJourneys(params);
|
||||
renderJourneys(journeys, resultsEl);
|
||||
const response = await API.getJourneys(params);
|
||||
journeyEarlierRef = response.earlierRef || null;
|
||||
journeyLaterRef = response.laterRef || null;
|
||||
renderJourneys(response.journeys, resultsEl);
|
||||
updateJourneyLoadMoreButtons();
|
||||
if (shareRow) shareRow.hidden = false;
|
||||
} catch (err) {
|
||||
resultsEl.innerHTML = `
|
||||
@@ -1289,6 +1430,7 @@ const App = (() => {
|
||||
<button id="journey-retry" class="text-button" type="button">${escapeHtml(I18N.t('common.retry'))}</button>
|
||||
</div>
|
||||
`;
|
||||
updateJourneyLoadMoreButtons();
|
||||
const retryBtn = document.getElementById('journey-retry');
|
||||
if (retryBtn) retryBtn.addEventListener('click', () => runJourneySearch());
|
||||
}
|
||||
@@ -1310,8 +1452,8 @@ const App = (() => {
|
||||
const toName = journeyTo.name === 'Mein Standort' ? 'Startpunkt' : journeyTo.name;
|
||||
|
||||
const whenInput = document.getElementById('journey-when').value;
|
||||
const transferSlackSelect = document.getElementById('journey-transfer-slack');
|
||||
const wheelchairToggle = document.getElementById('journey-wheelchair');
|
||||
const bikeToggle = document.getElementById('journey-bike');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
route: '1',
|
||||
@@ -1324,8 +1466,20 @@ const App = (() => {
|
||||
arrival: journeyArrivalMode ? '1' : '0',
|
||||
});
|
||||
if (whenInput) params.set('when', whenInput);
|
||||
if (transferSlackSelect && transferSlackSelect.value !== '') params.set('transferSlack', transferSlackSelect.value);
|
||||
if (wheelchairToggle && wheelchairToggle.checked) params.set('wheelchair', '1');
|
||||
if (bikeToggle && bikeToggle.checked) params.set('bike', '1');
|
||||
if (journeyModeFilter) {
|
||||
const modes = journeyModeFilter.getModesParam();
|
||||
if (modes) params.set('modes', modes);
|
||||
}
|
||||
journeyVias
|
||||
.filter((v) => v.lat != null && v.lon != null)
|
||||
.forEach((via, i) => {
|
||||
params.set(`via${i}Lat`, via.lat);
|
||||
params.set(`via${i}Lon`, via.lon);
|
||||
if (via.name) params.set(`via${i}Name`, via.name);
|
||||
if (via.waitMinutes) params.set(`via${i}Wait`, via.waitMinutes);
|
||||
});
|
||||
|
||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||
}
|
||||
@@ -1379,13 +1533,26 @@ const App = (() => {
|
||||
const whenValue = params.get('when') || '';
|
||||
setWhenChoice(params.get('arrival') === '1' ? 'arrival' : (whenValue ? 'departure' : 'now'), whenValue);
|
||||
|
||||
const transferSlackParam = params.get('transferSlack');
|
||||
const transferSlackSelect = document.getElementById('journey-transfer-slack');
|
||||
if (transferSlackSelect && transferSlackParam) transferSlackSelect.value = transferSlackParam;
|
||||
|
||||
const wheelchairToggle = document.getElementById('journey-wheelchair');
|
||||
if (wheelchairToggle) wheelchairToggle.checked = params.get('wheelchair') === '1';
|
||||
|
||||
const bikeToggle = document.getElementById('journey-bike');
|
||||
if (bikeToggle) bikeToggle.checked = params.get('bike') === '1';
|
||||
|
||||
journeyVias = [];
|
||||
for (let i = 0; i < MAX_VIAS; i++) {
|
||||
const viaLat = parseFloat(params.get(`via${i}Lat`));
|
||||
const viaLon = parseFloat(params.get(`via${i}Lon`));
|
||||
if (Number.isNaN(viaLat) || Number.isNaN(viaLon)) continue;
|
||||
journeyVias.push({
|
||||
lat: viaLat,
|
||||
lon: viaLon,
|
||||
name: params.get(`via${i}Name`) || '',
|
||||
waitMinutes: parseInt(params.get(`via${i}Wait`), 10) || 0,
|
||||
});
|
||||
}
|
||||
renderJourneyVias();
|
||||
|
||||
activateTab('journey');
|
||||
runJourneySearch();
|
||||
}
|
||||
@@ -1393,6 +1560,15 @@ const App = (() => {
|
||||
function setupJourneyInput(inputId, onSelect) {
|
||||
const input = document.getElementById(inputId);
|
||||
const results = document.getElementById(`${inputId}-results`);
|
||||
setupLocationAutocomplete(input, results, (loc) => {
|
||||
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind });
|
||||
});
|
||||
}
|
||||
|
||||
// Wiederverwendbare Haltestellen-/Adress-Autovervollständigung. Wird für
|
||||
// Start/Ziel (feste IDs im DOM) UND für dynamisch erzeugte
|
||||
// Zwischenhalt-Zeilen genutzt (siehe renderJourneyVias()).
|
||||
function setupLocationAutocomplete(input, results, onSelect) {
|
||||
let debounce = null;
|
||||
let requestId = 0;
|
||||
wireSearchKeyboardNav(input, results);
|
||||
@@ -1410,7 +1586,7 @@ const App = (() => {
|
||||
const { locations } = await API.searchLocations(query);
|
||||
if (thisRequestId !== requestId) return; // veraltete Antwort, verwerfen
|
||||
renderSearchResults(results, locations, (loc) => {
|
||||
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind });
|
||||
onSelect(loc);
|
||||
input.value = loc.name;
|
||||
results.hidden = true;
|
||||
});
|
||||
@@ -1421,7 +1597,7 @@ const App = (() => {
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${inputId}-results`)) {
|
||||
if (e.target !== input && !results.contains(e.target)) {
|
||||
results.hidden = true;
|
||||
}
|
||||
});
|
||||
@@ -1606,6 +1782,12 @@ const App = (() => {
|
||||
() => {},
|
||||
);
|
||||
LiveMap.setModeFilter(() => mapModeFilter.getModesParam());
|
||||
|
||||
journeyModeFilter = ModeFilter.create(
|
||||
document.getElementById('journey-mode-filter'),
|
||||
'oepnv-navi:modes:journey',
|
||||
() => {},
|
||||
);
|
||||
document.getElementById('map-locate').addEventListener('click', () => LiveMap.locateUser());
|
||||
document.getElementById('map-isolation-reset').addEventListener('click', () => LiveMap.clearIsolation());
|
||||
|
||||
|
||||
+22
-10
@@ -89,16 +89,22 @@ const I18N = (() => {
|
||||
'journey.departureAt': 'Abfahrt um',
|
||||
'journey.arrivalAt': 'Ankunft um',
|
||||
'journey.wheelchair': 'Nur rollstuhlgerechte Verbindungen',
|
||||
'journey.transferSlack': 'Umstiegszeit-Puffer',
|
||||
'journey.transferSlackDefault': 'Standard',
|
||||
'journey.transferSlack0': '0 min (knapp)',
|
||||
'journey.transferSlack15': '15 min (sicher)',
|
||||
'journey.bike': 'Fahrrad mitnehmen',
|
||||
'journey.modes': 'Verkehrsmittel',
|
||||
'journey.addVia': 'Zwischenhalt hinzufügen',
|
||||
'journey.viaPlaceholder': 'Zwischenhalt {n}',
|
||||
'journey.removeVia': 'Zwischenhalt entfernen',
|
||||
'journey.viaWaitLabel': 'Aufenthalt dort',
|
||||
'journey.viaWaitDirect': 'Direkt weiter',
|
||||
'journey.earlier': 'Früher',
|
||||
'journey.later': 'Später',
|
||||
'journey.submit': 'Verbindungen suchen',
|
||||
'journey.sourceNote': '🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)',
|
||||
'journey.sourceNote': '🛰 Routenplanung mit Echtzeitdaten von VBB & Deutscher Bahn',
|
||||
'journey.share': 'Route teilen',
|
||||
'journey.selectFromResults': 'Bitte Start und Ziel aus der Liste auswählen.',
|
||||
'journey.searching': 'Suche Verbindungen…',
|
||||
'journey.noResults': 'Keine Verbindungen gefunden.',
|
||||
'journey.maxViasReached': 'Maximal 3 Zwischenhalte möglich.',
|
||||
'disruptions.title': 'Störungen',
|
||||
'disruptions.text': 'Ortsbezogene und netzweite Hinweise.',
|
||||
'disruptions.nearbyTitle': 'In deiner Nähe',
|
||||
@@ -204,16 +210,22 @@ const I18N = (() => {
|
||||
'journey.departureAt': 'Depart at',
|
||||
'journey.arrivalAt': 'Arrive by',
|
||||
'journey.wheelchair': 'Wheelchair-accessible connections only',
|
||||
'journey.transferSlack': 'Transfer buffer',
|
||||
'journey.transferSlackDefault': 'Default',
|
||||
'journey.transferSlack0': '0 min (tight)',
|
||||
'journey.transferSlack15': '15 min (safe)',
|
||||
'journey.bike': 'Take my bike along',
|
||||
'journey.modes': 'Transit modes',
|
||||
'journey.addVia': 'Add stopover',
|
||||
'journey.viaPlaceholder': 'Stopover {n}',
|
||||
'journey.removeVia': 'Remove stopover',
|
||||
'journey.viaWaitLabel': 'Wait time there',
|
||||
'journey.viaWaitDirect': 'Continue immediately',
|
||||
'journey.earlier': 'Earlier',
|
||||
'journey.later': 'Later',
|
||||
'journey.submit': 'Search connections',
|
||||
'journey.sourceNote': '🛰 Journey planning via our self-hosted, open instance (VBB region)',
|
||||
'journey.sourceNote': '🛰 Journey planning with real-time VBB & Deutsche Bahn data',
|
||||
'journey.share': 'Share route',
|
||||
'journey.selectFromResults': 'Please choose start and destination from the list.',
|
||||
'journey.searching': 'Searching for connections…',
|
||||
'journey.noResults': 'No connections found.',
|
||||
'journey.maxViasReached': 'Maximum of 3 stopovers.',
|
||||
'disruptions.title': 'Disruptions',
|
||||
'disruptions.text': 'Location-based and network-wide notices.',
|
||||
'disruptions.nearbyTitle': 'Near you',
|
||||
|
||||
+64
-7
@@ -1426,22 +1426,78 @@ button:active { transform: translateY(1px); }
|
||||
.journey-options-row { display: flex; align-items: end; justify-content: space-between; gap: 12px; }
|
||||
.journey-options-row .mode-filter { margin-bottom: 0; }
|
||||
|
||||
.transfer-slack-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.transfer-slack-field label {
|
||||
.journey-vias { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 8px; }
|
||||
.journey-via-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.journey-input-marker--via { color: var(--ink-muted); }
|
||||
.journey-via-row__fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.journey-via-row__search { position: relative; flex: 1; min-width: 160px; }
|
||||
.journey-via-row__search input {
|
||||
width: 100%;
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.journey-via-wait { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-muted); white-space: nowrap; }
|
||||
.journey-via-wait select {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.journey-via-remove {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-muted);
|
||||
border-radius: 999px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.journey-via-remove:hover { color: var(--signal-red); border-color: var(--signal-red); }
|
||||
.journey-add-via { margin: 0 0 12px; }
|
||||
|
||||
.journey-modes-field { display: flex; flex-direction: column; gap: 6px; margin: 0 0 12px; }
|
||||
.journey-modes-field__label {
|
||||
color: var(--ink-muted);
|
||||
font: 600 10px var(--font-label);
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.transfer-slack-field select {
|
||||
.journey-modes-field .mode-filter { margin-bottom: 0; }
|
||||
|
||||
.journey-load-more-row { display: flex; justify-content: space-between; gap: 10px; margin-top: 12px; }
|
||||
.journey-load-more-btn {
|
||||
flex: 1;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
border-radius: 999px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.journey-load-more-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.journey-load-more-btn:not(:disabled):hover { border-color: var(--signal-amber); color: var(--signal-amber); }
|
||||
|
||||
.journey-submit { min-height: 38px; padding: 9px 15px; }
|
||||
.journey-submit span { margin-left: 6px; }
|
||||
.journey-card { padding: 16px; }
|
||||
@@ -2161,7 +2217,8 @@ button:active { transform: translateY(1px); }
|
||||
.journey-options-row { display: block; }
|
||||
.journey-options-row .journey-submit { width: 100%; margin-top: 8px; }
|
||||
.journey-options-row .mode-filter { margin-bottom: 8px; }
|
||||
.transfer-slack-field select { width: 100%; }
|
||||
.journey-via-row__fields { flex-direction: column; align-items: stretch; }
|
||||
.journey-via-wait select { width: 100%; }
|
||||
.mobile-nav {
|
||||
display: block; position: fixed; bottom: 0; left: 0; right: 0;
|
||||
/* Muss über der (jetzt display-breiten) Live-Karte liegen - Leaflet nutzt
|
||||
|
||||
Reference in new issue
Block a user