diff options
Diffstat (limited to 'src/lib/Timetable.svelte')
-rw-r--r-- | src/lib/Timetable.svelte | 424 |
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} |