aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/Timetable.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Timetable.svelte')
-rw-r--r--src/lib/Timetable.svelte424
1 files changed, 424 insertions, 0 deletions
diff --git a/src/lib/Timetable.svelte b/src/lib/Timetable.svelte
new file mode 100644
index 0000000..4a17ca7
--- /dev/null
+++ b/src/lib/Timetable.svelte
@@ -0,0 +1,424 @@
+<script lang="ts">
+ import { browser, dev } from '$app/environment';
+ import { page } from '$app/state';
+ import { S } from '$lib';
+ import { operators } from './aliases';
+ import LineGlyph from './assets/LineGlyph.svelte';
+ import Pictogram from './assets/Pictogram.svelte';
+ import { Mode, type StoptimesResponse } from './motis-types';
+ import { m } from './paraglide/messages';
+
+ let relativeSecondPrecision = $derived(
+ ['seconds', 'second', 'sec', 'secs', 's', '2'].includes(
+ page.url.searchParams.get('relative')!
+ )
+ );
+ let isRelativeTime = $derived(
+ relativeSecondPrecision ||
+ ['1', 'true'].includes(page.url.searchParams.get('relative')!)
+ );
+ /** only if isRelativeTime is true */
+ let now = $state(0);
+ const updateNow = () => {
+ now = Date.now();
+ setTimeout(updateNow, 33);
+ };
+ $effect(() => (isRelativeTime ? updateNow() : void 0));
+
+ let {
+ stopTimes,
+ isArrivals = false,
+ placeName = '',
+ placeId,
+ isResultsPage = true,
+ setSearch = (q) => void 0,
+ }: {
+ stopTimes: null | StoptimesResponse;
+ isArrivals?: boolean;
+ placeName?: string;
+ isResultsPage?: boolean;
+ placeId: string | null;
+ setSearch: (query: string) => void;
+ } = $props();
+ const timeRelative = (ms: number, relativeTo = now) => {
+ const _totalMillis = ms - relativeTo;
+ const totalMillis = Math.abs(_totalMillis);
+ const totalSeconds = totalMillis / 1000;
+ const totalMinutes = totalSeconds / 60;
+ const totalHours = totalMinutes / 60;
+ const hours = Math.floor(totalHours);
+ const minutes = Math.floor(totalMinutes) - hours * 60;
+ const seconds = Math.floor(totalSeconds) - hours * 60 * 60 - minutes * 60;
+ const results = [] as string[];
+ if (hours) results.push(`${hours} ${hours !== 1 ? m.hours() : m.hour()}`);
+ if (hours || minutes)
+ results.push(`${minutes} ${minutes !== 1 ? m.minutes() : m.minute()}`);
+ if (seconds && relativeSecondPrecision)
+ results.push(`${seconds} ${seconds !== 1 ? m.seconds() : m.second()}`);
+ if (results.length > 1) {
+ const [r, l] = [results.pop()!, results.pop()!];
+ results.push(
+ m.timeJoiner({
+ l,
+ r,
+ })
+ );
+ }
+ const relTime =
+ results.length === 1
+ ? results[0]
+ : results.length === 0
+ ? ''
+ : results.reduce((pv, cv) => m.timeJoiner2({ l: pv, r: cv }));
+ if (relTime === '')
+ return m.timeImmediate({
+ isPastTense: _totalMillis < 0 ? 'true' : 'false',
+ });
+ if (_totalMillis < 0)
+ return m.timeInPast({
+ relTime,
+ });
+ else
+ return m.timeInFuture({
+ relTime,
+ });
+ };
+ const localTime = async (
+ time: Date,
+ forceAbsolute = false,
+ relativeTo = now
+ ) => {
+ const offset = parseFloat(
+ browser
+ ? // Browsers like Librewolf love patching time to be a fixed timezone, breaking us.
+ (localStorage.getItem(
+ isRelativeTime && !forceAbsolute
+ ? 'relative-offset-time'
+ : 'offset-time'
+ ) ?? '0')
+ : ((await import('$env/dynamic/private')).env[
+ isRelativeTime && !forceAbsolute
+ ? 'PRIVATE_RELATIVE_TIME_OFFSET'
+ : 'PRIVATE_SERVER_TIMEZONE'
+ ] ?? '0')
+ );
+ if (isRelativeTime && !forceAbsolute) {
+ return timeRelative(time.getTime() - offset * 60 * 60 * 1000, relativeTo);
+ } else {
+ const hours = time.getHours() + Math.floor(offset);
+ const minutes = time.getMinutes() + Math.floor((offset % 1) * 60);
+ const seconds =
+ time.getSeconds() + Math.floor(((offset % 1) * 60) % 1) * 60;
+ return (
+ hours.toString().padStart(2, '0') +
+ ':' +
+ minutes.toString().padStart(2, '0') +
+ (seconds !== 0 ? `:${seconds.toString().padStart(2, '0')}` : '')
+ );
+ }
+ };
+</script>
+
+{#snippet renderLocalTime(
+ time: Date,
+ forceAbsolute = false
+)}{#await localTime(time, forceAbsolute, now)}{isRelativeTime && !forceAbsolute
+ ? timeRelative(time.getTime(), now)
+ : time.getUTCHours() +
+ ':' +
+ time.getUTCMinutes() +
+ (time.getUTCSeconds() !== 0 ? `:${time.getUTCSeconds()}` : '') +
+ ' UTC'}{:then t}{t}{/await}{/snippet}
+
+{#if stopTimes}
+ {#each stopTimes.stopTimes
+ // garbage data
+ .filter((v) => v.agencyUrl !== 'http://www.rta.ae') as departure}
+ {@const expectedTime =
+ new Date(
+ (isArrivals
+ ? departure.place.scheduledArrival
+ : departure.place.scheduledDeparture) ?? '0'
+ ).getTime() /
+ 1000 /
+ 60}
+ {@const receivedTime =
+ new Date(
+ (isArrivals ? departure.place.arrival : departure.place.departure) ??
+ '0'
+ ).getTime() /
+ 1000 /
+ 60}
+ {@const delayMinutes = receivedTime - expectedTime}
+ {@const avoidGlyph = departure.routeShortName.startsWith('FlixTrain')}
+ {@const routeShortName = (() => {
+ let n = departure.routeShortName;
+ if (n.startsWith('EC ')) n = n.replace('EC ', 'EC');
+ if (departure.mode === 'TRAM' && !isNaN(parseInt(n))) n = 'T ' + n;
+ if (departure.mode === 'BUS' && !isNaN(parseInt(n))) n = 'B ' + n;
+ if (
+ departure.routeShortName === 'European Sleeper' &&
+ departure.agencyName === 'Eu Sleeper'
+ )
+ n = 'EN'; // TODO: validate these are real euronights
+ if (n === '?') n = '';
+ if (n.startsWith('FlixTrain ')) n = n.substring(10);
+ // Note: may also catch ECs/ICs/EXTs operated by DB
+ if (
+ departure.agencyId === '12681' &&
+ departure.agencyName === 'DB Fernverkehr AG' &&
+ departure.mode === 'HIGHSPEED_RAIL' &&
+ departure.source.startsWith('de_DELFI.gtfs.zip/') &&
+ !isNaN(parseInt(n))
+ )
+ n = `ICE ${n}`;
+ return n;
+ })()}
+ {@const pictogram = (() => {
+ switch (true) {
+ case departure.mode === Mode.Bike:
+ return 'Velo_l';
+ case departure.mode === Mode.ODM:
+ case departure.mode === Mode.Rental:
+ return 'Taxi_l';
+ case departure.mode === Mode.Car:
+ case departure.mode === Mode.CarDropoff:
+ case departure.mode === Mode.CarParking:
+ return 'Auto_l';
+
+ // Transit //
+ case departure.mode === Mode.Airplane:
+ return 'Abflug_l';
+ case departure.mode === Mode.LongDistanceRail:
+ case departure.mode === Mode.RegionalFastRail:
+ case departure.mode === Mode.RegionalRail:
+ case departure.mode === Mode.Metro:
+ case departure.mode === Mode.HighspeedRail:
+ return 'Zug_l';
+ case departure.mode === Mode.NightRail:
+ return 'Schlafwagen';
+ case departure.mode === Mode.Subway:
+ return 'Metro_l_' + (m.lang_short() === 'en' ? 'de' : m.lang_short());
+ case departure.mode === Mode.Bus:
+ return 'Bus_l';
+ case departure.mode === Mode.Coach:
+ return 'Fernbus_l';
+ case departure.mode === Mode.Tram:
+ case departure.mode === Mode.CableTram:
+ return 'Tram_l';
+ case departure.mode === Mode.Funicular:
+ return 'Zahnradbahn_l';
+ case departure.mode === Mode.AerialLift:
+ // return 'Gondelbahn_l';
+ return 'Luftseilbahn_l';
+ case departure.mode === Mode.Ferry:
+ return 'Schiff_l';
+ case departure.mode === Mode.Other:
+ default:
+ return 'Licht';
+ }
+ })()}
+ {@const notices = (() => {
+ let notices = [] as [pictogram: string[], content: string][];
+ if (departure.cancelled)
+ notices.push([
+ ['Cancellation', 'Attention'],
+ m.brief_north_otter_cherish(),
+ ]);
+ if (delayMinutes < -0.5) {
+ notices.push([
+ ['Hint'],
+ m.vehicle_is_early({
+ minutes: -delayMinutes,
+ arrival: isArrivals.toString(),
+ }),
+ ]);
+ } else if (delayMinutes >= 1) {
+ notices.push([
+ delayMinutes >= 5
+ ? ['Delay', 'Attention']
+ : delayMinutes >= 3
+ ? ['Delay', 'Hint']
+ : ['Hint'],
+ m.next_long_lark_bask({
+ minutes: delayMinutes.toFixed(0),
+ }),
+ ]);
+ }
+ return notices;
+ })()}
+ {@const situationIsBad = notices.find((v) => v[0].includes('Attention'))}
+ <div
+ class={{
+ 'p-4 pr-3 sm:pr-4 md:p-6 md:pr-6 rounded-xl': true,
+ 'bg-[#28282C]': !situationIsBad,
+ 'bg-[#452525]': situationIsBad,
+ }}
+ data-data={dev ? JSON.stringify(departure, null, 2) : undefined}
+ >
+ <div class="flex gap-1 md:items-center md:flex-row flex-col flex-wrap">
+ <div class="pictoline flex gap-1 md:items-center flex-r">
+ {#if pictogram}
+ <Pictogram which={pictogram} />
+ {/if}
+ {#if ([Mode.NightRail || Mode.HighspeedRail || Mode.LongDistanceRail || Mode.RegionalFastRail || Mode.RegionalRail].includes(departure.mode) || (departure.mode === 'BUS' && routeShortName.startsWith('EV')) || (departure.mode === 'METRO' && departure.routeShortName.startsWith('S'))) && !avoidGlyph}
+ <LineGlyph currentColor="#fff" kind={routeShortName} />
+ {:else}
+ <span
+ class="ml-1 -mr-0.5 md:mt-0.5 font-sbb-typo text-nowrap font-bold"
+ >
+ {routeShortName}
+ </span>
+ {/if}
+ <span class="ml-1 -mr-0.5 md:mt-0.5 font-sbb-typo">
+ <!-- {isArrivals ? m.from() : m.to()} -->
+ {m.to()}
+ <span class="font-semibold">{departure.headsign}</span>
+ </span>
+ </div>
+ <div class="flex-1"></div>
+ {#if departure.place.scheduledTrack && departure.place.track}
+ <span
+ class={{
+ 'ml-1 mt-0.5 font-[SBB,Inter,system-ui,sans-serif]': true,
+ 'text-red-400':
+ departure.place.scheduledTrack !== departure.place.track,
+ }}
+ >
+ {#if departure.place.name !== placeName}{`${
+ departure.place.name === placeName + ', Bahnhof'
+ ? placeName + ', Busbahnhof'
+ : departure.place.name
+ }, `}
+ {/if}<span class="font-semibold"
+ >{m.station_location({
+ track: departure.place.track,
+ mode: departure.mode,
+ })}</span
+ >
+ </span>
+ {:else if departure.place.name !== placeName}{departure.place.name ===
+ placeName + ', Bahnhof'
+ ? placeName + ', Busbahnhof'
+ : departure.place.name}
+ {/if}
+ </div>
+ <div class="flex gap-1 items-center">
+ {departure.cancelled
+ ? m.antsy_weird_cowfish_wish() + ' '
+ : ''}{isRelativeTime && Math.abs(expectedTime - receivedTime) < 1
+ ? isArrivals
+ ? m.hour_tidy_hawk_explore()
+ : m.free_knotty_ray_soar()
+ : isArrivals
+ ? m.polite_lucky_angelfish_amaze()
+ : m.home_flaky_jurgen_ascend()}
+ {#if Math.abs(expectedTime - receivedTime) < 1}
+ <span class="font-bold">
+ {@render renderLocalTime(new Date(receivedTime * 60 * 1000))}
+ </span>
+ {:else}
+ <span class="line-through">
+ {@render renderLocalTime(new Date(expectedTime * 60 * 1000), true)}
+ </span>
+ <span class="font-bold">
+ {@render renderLocalTime(new Date(receivedTime * 60 * 1000), true)}
+ </span>
+ {#if isRelativeTime}
+ <span class="opacity">
+ ({@render renderLocalTime(new Date(receivedTime * 60 * 1000))})
+ </span>
+ {/if}
+ {/if}
+ </div>
+ {#if notices.length !== 0}
+ <div class="notices pt-2 flex flex-col gap-1">
+ {#each notices as notice}
+ <div class="flex items-center gap-2">
+ {#each notice[0] as pictogram}<Pictogram
+ which={pictogram}
+ />{/each}
+ <span class="ml-0.5">
+ {notice[1]}
+ </span>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <!-- <pre>{JSON.stringify(departure, null, 2)}</pre> -->
+ {#if departure.agencyName}
+ <small class="-mb-1 mt-2 block opacity-70"
+ >{m.operated_by({
+ operator: operators.has(departure.agencyName)
+ ? operators.get(departure.agencyName)!
+ : departure.agencyName,
+ })}{#if departure.agencyName === 'DB Fernverkehr AG'}
+ {' '}
+ <b>·</b>
+ {m.line_number_accuracy()}{/if}</small
+ >
+ {/if}
+ </div>
+ {/each}
+{:else}
+ <div class="flex items-center justify-center">
+ <div class="results">
+ {#if (placeName || placeId) && isResultsPage}
+ <h2 class="text-2xl opacity-90">No results</h2>
+ <p>
+ No results have been found for the station <b
+ >{placeName ?? placeId}</b
+ >.<br />
+ Please try again.
+ </p>
+ {:else if placeId}
+ <h2 class="text-2xl opacity-90">No results</h2>
+ <p>
+ No results have been found for the station <b
+ >{placeName ?? placeId}</b
+ >.<br />
+ Please try again.
+ </p>
+ {:else}
+ <h2 class="text-2xl opacity-90">No Station</h2>
+ <p class="pb-1">
+ Please input a station in the search field above and select a search
+ result.
+ </p>
+ <p class="py-1">
+ Examples:
+ <span class="pt-2 flex flex-wrap gap-2">
+ <button
+ class="{S.button('secondary').replace(
+ 'not-disabled:bg-[#0000]',
+ 'not-disabled:bg-[#2E2E3299]'
+ )} flex-1 text-nowrap p-3"
+ onclick={() => setSearch('Zürich HB')}>Zürich HB</button
+ >
+ <button
+ class="{S.button('secondary').replace(
+ 'not-disabled:bg-[#0000]',
+ 'not-disabled:bg-[#2E2E3299]'
+ )} flex-1 text-nowrap p-3"
+ onclick={() => setSearch('Berlin Hbf')}>Berlin Hbf</button
+ >
+ <button
+ class="{S.button('secondary').replace(
+ 'not-disabled:bg-[#0000]',
+ 'not-disabled:bg-[#2E2E3299]'
+ )} flex-1 text-nowrap p-3"
+ onclick={() => setSearch('Hamburg Hbf')}>Hamburg Hbf</button
+ >
+ <button
+ class="{S.button('secondary').replace(
+ 'not-disabled:bg-[#0000]',
+ 'not-disabled:bg-[#2E2E3299]'
+ )} flex-1 text-nowrap p-3"
+ onclick={() => setSearch('Bern')}>Bern</button
+ >
+ </span>
+ </p>
+ {/if}
+ </div>
+ </div>
+{/if}