fix(hikes): link journey planner to the current SBB deep-link format
Replace the search.ch link with an sbb.ch deep link that respects the searched date/time and departure-vs-arrival. The live SBB timetable (2026) uses stops=<from>~<to> with each stop as `<label>_I<stationId>`, time with an underscore (09_10), a short dep/arr token, and a `day` mirror of date — not the older von/nach + colon/ARRIVAL form. Station IDs (didok) are captured during resolution (transport.opendata.ch returns SBB's IDs); fall back to label-only stops when an ID can't be resolved.
This commit is contained in:
@@ -113,7 +113,15 @@
|
|||||||
sections: Section[];
|
sections: Section[];
|
||||||
};
|
};
|
||||||
let connections = $state<Connection[] | null>(null);
|
let connections = $state<Connection[] | null>(null);
|
||||||
let lastQuery = $state<{ from: string; to: string } | null>(null);
|
let lastQuery = $state<{
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
fromId: string | null;
|
||||||
|
toId: string | null;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
arrival: boolean;
|
||||||
|
} | null>(null);
|
||||||
let expanded = $state<number | null>(null);
|
let expanded = $state<number | null>(null);
|
||||||
|
|
||||||
// Map a transport.opendata.ch vehicle category to a coarse type + icon, so a
|
// Map a transport.opendata.ch vehicle category to a coarse type + icon, so a
|
||||||
@@ -240,7 +248,12 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nearestStation(): Promise<string> {
|
// A resolved stop: canonical name + the station id (didok/UIC) that SBB's
|
||||||
|
// deep link needs in its `stops` parameter. id is null when we couldn't
|
||||||
|
// resolve the typed text to a real station.
|
||||||
|
type Station = { name: string; id: string | null };
|
||||||
|
|
||||||
|
async function nearestStation(): Promise<Station> {
|
||||||
const pos = await getPosition();
|
const pos = await getPosition();
|
||||||
const u = new URL('https://transport.opendata.ch/v1/locations');
|
const u = new URL('https://transport.opendata.ch/v1/locations');
|
||||||
u.searchParams.set('type', 'station');
|
u.searchParams.set('type', 'station');
|
||||||
@@ -248,10 +261,10 @@
|
|||||||
u.searchParams.set('y', String(pos.coords.longitude));
|
u.searchParams.set('y', String(pos.coords.longitude));
|
||||||
const res = await fetch(u);
|
const res = await fetch(u);
|
||||||
if (!res.ok) throw new Error('Haltestellensuche fehlgeschlagen.');
|
if (!res.ok) throw new Error('Haltestellensuche fehlgeschlagen.');
|
||||||
const json = (await res.json()) as { stations?: { name?: string }[] };
|
const json = (await res.json()) as { stations?: { name?: string; id?: string | number }[] };
|
||||||
const name = json.stations?.find((s) => s.name)?.name;
|
const st = json.stations?.find((s) => s.name);
|
||||||
if (!name) throw new Error('Keine Haltestelle in der Nähe gefunden.');
|
if (!st?.name) throw new Error('Keine Haltestelle in der Nähe gefunden.');
|
||||||
return name;
|
return { name: st.name, id: st.id != null ? String(st.id) : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station typeahead — the same /locations?type=station endpoint sbb-tui uses,
|
// Station typeahead — the same /locations?type=station endpoint sbb-tui uses,
|
||||||
@@ -261,14 +274,16 @@
|
|||||||
let suggestions = $state<string[]>([]);
|
let suggestions = $state<string[]>([]);
|
||||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
async function fetchStations(query: string): Promise<string[]> {
|
async function fetchStations(query: string): Promise<Station[]> {
|
||||||
const u = new URL('https://transport.opendata.ch/v1/locations');
|
const u = new URL('https://transport.opendata.ch/v1/locations');
|
||||||
u.searchParams.set('type', 'station');
|
u.searchParams.set('type', 'station');
|
||||||
u.searchParams.set('query', query);
|
u.searchParams.set('query', query);
|
||||||
const res = await fetch(u);
|
const res = await fetch(u);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const json = (await res.json()) as { stations?: { name?: string }[] };
|
const json = (await res.json()) as { stations?: { name?: string; id?: string | number }[] };
|
||||||
return (json.stations ?? []).map((s) => s.name?.trim()).filter((n): n is string => !!n);
|
return (json.stations ?? [])
|
||||||
|
.filter((s): s is { name: string; id?: string | number } => !!s.name?.trim())
|
||||||
|
.map((s) => ({ name: s.name.trim(), id: s.id != null ? String(s.id) : null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSuggestions(query: string) {
|
async function refreshSuggestions(query: string) {
|
||||||
@@ -278,7 +293,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
suggestions = (await fetchStations(q)).slice(0, 6);
|
suggestions = (await fetchStations(q)).map((s) => s.name).slice(0, 6);
|
||||||
} catch {
|
} catch {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
}
|
}
|
||||||
@@ -313,13 +328,13 @@
|
|||||||
|
|
||||||
// Resolve a typed (un-picked) field to its canonical station before the
|
// Resolve a typed (un-picked) field to its canonical station before the
|
||||||
// connections call; fall back to the raw text on miss/error.
|
// connections call; fall back to the raw text on miss/error.
|
||||||
async function resolveStation(query: string): Promise<string> {
|
async function resolveStation(query: string): Promise<Station> {
|
||||||
if (!query) return query;
|
if (!query) return { name: query, id: null };
|
||||||
try {
|
try {
|
||||||
const [first] = await fetchStations(query);
|
const [first] = await fetchStations(query);
|
||||||
return first ?? query;
|
return first ?? { name: query, id: null };
|
||||||
} catch {
|
} catch {
|
||||||
return query;
|
return { name: query, id: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,20 +367,29 @@
|
|||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const fromQ = fromCurrent ? await nearestStation() : await resolveStation(fromText.trim());
|
const fromR = fromCurrent ? await nearestStation() : await resolveStation(fromText.trim());
|
||||||
const toQ = toCurrent ? await nearestStation() : await resolveStation(toText.trim());
|
const toR = toCurrent ? await nearestStation() : await resolveStation(toText.trim());
|
||||||
const u = new URL('https://transport.opendata.ch/v1/connections');
|
const u = new URL('https://transport.opendata.ch/v1/connections');
|
||||||
u.searchParams.set('from', fromQ);
|
u.searchParams.set('from', fromR.name);
|
||||||
u.searchParams.set('to', toQ);
|
u.searchParams.set('to', toR.name);
|
||||||
u.searchParams.set('limit', String(limit));
|
u.searchParams.set('limit', String(limit));
|
||||||
|
const reqTime = timeStr || '08:00';
|
||||||
u.searchParams.set('date', dateStr);
|
u.searchParams.set('date', dateStr);
|
||||||
u.searchParams.set('time', timeStr || '08:00');
|
u.searchParams.set('time', reqTime);
|
||||||
if (timeMode === 'arrival') u.searchParams.set('isArrivalTime', '1');
|
if (timeMode === 'arrival') u.searchParams.set('isArrivalTime', '1');
|
||||||
const res = await fetch(u);
|
const res = await fetch(u);
|
||||||
if (!res.ok) throw new Error('Verbindungsabfrage fehlgeschlagen.');
|
if (!res.ok) throw new Error('Verbindungsabfrage fehlgeschlagen.');
|
||||||
const json = (await res.json()) as { connections?: Connection[] };
|
const json = (await res.json()) as { connections?: Connection[] };
|
||||||
connections = json.connections ?? [];
|
connections = json.connections ?? [];
|
||||||
lastQuery = { from: fromQ, to: toQ };
|
lastQuery = {
|
||||||
|
from: fromR.name,
|
||||||
|
to: toR.name,
|
||||||
|
fromId: fromR.id,
|
||||||
|
toId: toR.id,
|
||||||
|
date: dateStr,
|
||||||
|
time: reqTime,
|
||||||
|
arrival: timeMode === 'arrival'
|
||||||
|
};
|
||||||
if (connections.length === 0) error = 'Keine Verbindungen gefunden.';
|
if (connections.length === 0) error = 'Keine Verbindungen gefunden.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[JourneyPlanner]', e);
|
console.warn('[JourneyPlanner]', e);
|
||||||
@@ -391,11 +415,23 @@
|
|||||||
function transfersLabel(n: number): string {
|
function transfersLabel(n: number): string {
|
||||||
return n === 0 ? 'direkt' : n === 1 ? '1 Umstieg' : `${n} Umstiege`;
|
return n === 0 ? 'direkt' : n === 1 ? '1 Umstieg' : `${n} Umstiege`;
|
||||||
}
|
}
|
||||||
const searchChLink = $derived(
|
// Deep link to the full journey on the official SBB timetable. The live site
|
||||||
lastQuery
|
// (2026) uses: stops=<from>~<to> where each stop is `<label>_I<stationId>`
|
||||||
? `https://fahrplan.search.ch/?from=${encodeURIComponent(lastQuery.from)}&to=${encodeURIComponent(lastQuery.to)}`
|
// (spaces as "+"), time with an underscore ("09:10" → "09_10"), and a short
|
||||||
: null
|
// lowercase moment token (dep/arr). `day` mirrors `date`. URLSearchParams
|
||||||
);
|
// can't produce this (it would percent-encode "_"/"~"/":" and use "+" only
|
||||||
|
// for spaces), so the query is assembled by hand.
|
||||||
|
const sbbLink = $derived.by(() => {
|
||||||
|
if (!lastQuery) return null;
|
||||||
|
const stop = (name: string, id: string | null) => {
|
||||||
|
const label = encodeURIComponent(name).replace(/%20/g, '+');
|
||||||
|
return id ? `${label}_I${id}` : label;
|
||||||
|
};
|
||||||
|
const stops = `${stop(lastQuery.from, lastQuery.fromId)}~${stop(lastQuery.to, lastQuery.toId)}`;
|
||||||
|
const time = lastQuery.time.replace(':', '_');
|
||||||
|
const moment = lastQuery.arrival ? 'arr' : 'dep';
|
||||||
|
return `https://www.sbb.ch/de?date=${lastQuery.date}&time=${time}&moment=${moment}&stops=${stops}&day=${lastQuery.date}`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="jp" aria-label="Anreise mit dem öffentlichen Verkehr">
|
<section class="jp" aria-label="Anreise mit dem öffentlichen Verkehr">
|
||||||
@@ -569,9 +605,9 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{#if searchChLink}
|
{#if sbbLink}
|
||||||
<a class="more" href={searchChLink} target="_blank" rel="external noopener noreferrer">
|
<a class="more" href={sbbLink} target="_blank" rel="external noopener noreferrer">
|
||||||
Ganzer Fahrplan <ExternalLink size={13} strokeWidth={2} aria-hidden="true" />
|
Ganzer Fahrplan auf SBB.ch <ExternalLink size={13} strokeWidth={2} aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="credit">Verbindungen: transport.opendata.ch</p>
|
<p class="credit">Verbindungen: transport.opendata.ch</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user