diff options
author | 2025-07-21 22:53:11 +0200 | |
---|---|---|
committer | 2025-07-21 22:53:11 +0200 | |
commit | a723e56b4ce392d2b11d28f2745279aa825a2ee1 (patch) | |
tree | dc69ea161c9b52e84c3969fbb9562b780f74586b /src | |
download | fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.gz fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.bz2 fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.lz fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.zip |
feat: initial commit
Diffstat (limited to 'src')
-rw-r--r-- | src/app.css | 24 | ||||
-rw-r--r-- | src/app.d.ts | 13 | ||||
-rw-r--r-- | src/app.html | 13 | ||||
-rw-r--r-- | src/hooks.server.ts | 12 | ||||
-rw-r--r-- | src/hooks.ts | 3 | ||||
-rw-r--r-- | src/lib/Page.svelte | 32 | ||||
-rw-r--r-- | src/lib/Timetable.svelte | 424 | ||||
-rw-r--r-- | src/lib/Titlebar.svelte | 55 | ||||
-rw-r--r-- | src/lib/aliases.ts | 36 | ||||
-rw-r--r-- | src/lib/assets/LineGlyph.svelte | 193 | ||||
-rw-r--r-- | src/lib/assets/LineGlyphSrc.svg | 233 | ||||
-rw-r--r-- | src/lib/assets/Pictogram.svelte | 11 | ||||
-rw-r--r-- | src/lib/index.ts | 51 | ||||
-rw-r--r-- | src/lib/motis-api.ts | 57 | ||||
-rw-r--r-- | src/lib/motis-types.ts | 496 | ||||
-rw-r--r-- | src/routes/(app)/+layout.svelte | 10 | ||||
-rw-r--r-- | src/routes/(app)/+server.ts | 5 | ||||
-rw-r--r-- | src/routes/(app)/fahrplan/+page.svelte | 524 | ||||
-rw-r--r-- | src/routes/+layout.svelte | 7 | ||||
-rw-r--r-- | src/routes/_lang/+page.svelte | 17 | ||||
-rw-r--r-- | src/routes/train-ico/[type]/[[line]]/+page.svelte | 13 | ||||
-rw-r--r-- | src/routes/train-ico/[type]/[[line]]/+page.ts | 1 | ||||
-rw-r--r-- | src/routes/train-ico/[type]/[[line]]/.svg/+server.ts | 23 |
23 files changed, 2253 insertions, 0 deletions
diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..2d40759 --- /dev/null +++ b/src/app.css @@ -0,0 +1,24 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'Adwaita Sans', Inter, system-ui, -apple-system, + BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; + --font-sbb-typo: SBB, 'Helvetica_Neue', Helvetica, Inter, var(--font-sans), + system-ui, sans-serif; +} + +@keyframes loadingbar { + 0% { + width: 0; + left: 0; + } + 50% { + width: 100%; + left: 0; + } + 100% { + width: 0; + left: 100%; + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..48ea59c --- /dev/null +++ b/src/app.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="%paraglide.lang%" class="bg-[#101012] text-white"> + <head> + <meta charset="utf-8" /> + <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..5182210 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,12 @@ +import type { Handle } from '@sveltejs/kit'; +import { paraglideMiddleware } from '$lib/paraglide/server'; + +const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { + event.request = request; + + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) + }); +}); + +export const handle: Handle = handleParaglide; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..e75600b --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,3 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; + +export const reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/src/lib/Page.svelte b/src/lib/Page.svelte new file mode 100644 index 0000000..92cd92a --- /dev/null +++ b/src/lib/Page.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import { S } from '$lib'; + import type { Snippet } from 'svelte'; + + let { + children, + sidepanel, + title, + description = 'No Description Specified', + class: classes, + }: { + children: Snippet; + sidepanel?: Snippet; + title: string; + description?: string; + class?: string; + } = $props(); +</script> + +<svelte:head> + <title>{title}</title> + <meta name="description" content={description} /> +</svelte:head> + +<div class="{S.window} {S.shadow_large} relative max-w-[100%]"> + {#if sidepanel} + <div class={S.window_sidepanel}> + {@render sidepanel()} + </div> + {/if} + {@render children()} +</div> 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} diff --git a/src/lib/Titlebar.svelte b/src/lib/Titlebar.svelte new file mode 100644 index 0000000..35cfd0d --- /dev/null +++ b/src/lib/Titlebar.svelte @@ -0,0 +1,55 @@ +<script lang="ts"> + import { S } from '$lib'; + import type { Snippet } from 'svelte'; + let { children }: { children?: Snippet } = $props(); +</script> + +<div class="rounded-tr-xl {S.window_topbar}"> + <!-- <div class="flex gap-2 w-max"> + <button + onclick={() => history.back()} + onkeypress={(e) => + e.key === 'Return' || e.key === 'Enter' + ? e.currentTarget.click() + : void 0} + aria-label="Back" + class={S.button('secondary')} + ><svg + height="16px" + viewBox="0 0 16 16" + width="16px" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="m 12 2 c 0 -0.265625 -0.105469 -0.519531 -0.292969 -0.707031 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 l -6 6 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 6 6 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 c 0.1875 -0.1875 0.292969 -0.441406 0.292969 -0.707031 s -0.105469 -0.519531 -0.292969 -0.707031 l -5.292969 -5.292969 l 5.292969 -5.292969 c 0.1875 -0.1875 0.292969 -0.441406 0.292969 -0.707031 z m 0 0" + fill="currentColor" + /> + </svg></button + > + <button + onclick={() => history.forward()} + onkeypress={(e) => + e.key === 'Return' || e.key === 'Enter' + ? e.currentTarget.click() + : void 0} + aria-label="Forward" + class={S.button('secondary')} + ><svg + height="16px" + viewBox="0 0 16 16" + width="16px" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="m 4 2 c 0 -0.265625 0.105469 -0.519531 0.292969 -0.707031 c 0.390625 -0.390625 1.023437 -0.390625 1.414062 0 l 6 6 c 0.1875 0.1875 0.292969 0.441406 0.292969 0.707031 s -0.105469 0.519531 -0.292969 0.707031 l -6 6 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 c -0.1875 -0.1875 -0.292969 -0.441406 -0.292969 -0.707031 s 0.105469 -0.519531 0.292969 -0.707031 l 5.292969 -5.292969 l -5.292969 -5.292969 c -0.1875 -0.1875 -0.292969 -0.441406 -0.292969 -0.707031 z m 0 0" + fill="currentColor" + /> + </svg></button + > + </div> --> + <div + class="flex-1 rounded-tl-xl min-h-8 flex items-center justify-center gap-2" + > + {@render children?.()} + </div> +</div> diff --git a/src/lib/aliases.ts b/src/lib/aliases.ts new file mode 100644 index 0000000..4fc08ec --- /dev/null +++ b/src/lib/aliases.ts @@ -0,0 +1,36 @@ +import { m } from './paraglide/messages'; + +export const placeNameMap = new Map<string, string>(); +for (const [v1, v2] of [ + ['Freiburg(Brsg)', 'Freiburg(Breisgau) Hbf'], + ['Freiburg(Breisgau)', 'Freiburg(Breisgau) Hbf'], + ['Freiburg(Breisgau) Hbf', 'Freiburg(Breisgau) Hbf'], + ['Freiburg (Breisgau)', 'Freiburg(Breisgau) Hbf'], + ['Freiburg (Breisgau) Hbf', 'Freiburg(Breisgau) Hbf'], + ['Freiburg im Breisgau', 'Freiburg(Breisgau) Hbf'], + ['Freiburg im Breisgau Hbf', 'Freiburg(Breisgau) Hbf'], + ['Freiburg im Breisgau Hauptbahnhof', 'Freiburg(Breisgau) Hbf'], + ['Freiburg (D)', 'Freiburg(Breisgau) Hbf'], + ['Freiburg (D), Busbahnhof', 'Freiburg(Breisgau) Hbf'], + ['Freiburg Hauptbahnhof', 'Freiburg(Breisgau) Hbf'], + ['S+U Berlin Hauptbahnhof', 'Berlin Hbf'], + ['Berlin Hauptbahnhof', 'Berlin Hbf'], +]) + placeNameMap.set(v1.toLowerCase(), v2); +export const operators = new Map<string, string>(); +operators.set('Schweizerische Bundesbahnen SBB', m.operator_sbb()); +operators.set('SBB', m.operator_sbb()); + +operators.set('SZU', m.operator_szu()); +operators.set('Sihltal-Zürich-Uetliberg-Bahn', m.operator_szu()); + +operators.set('Verkehrsbetriebe Zürich', m.operator_vbz()); // buses +operators.set('Verkehrsbetriebe Zürich INFO+', m.operator_vbz()); // trams + +operators.set('BLS AG (bls)', m.operator_bls()); + +operators.set('Städtische Verkehrsbetriebe Bern', m.operator_bernmobil()); + +operators.set('Regionalverkehr Bern-Solothurn', m.operator_rbs()); + +operators.set('Verkehrsbetriebe Glattal', m.operator_vbg()); diff --git a/src/lib/assets/LineGlyph.svelte b/src/lib/assets/LineGlyph.svelte new file mode 100644 index 0000000..f5df523 --- /dev/null +++ b/src/lib/assets/LineGlyph.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import { dev } from '$app/environment'; + + const fernverkehrIconMap = { + IR: 'M 2.43946,1.11125 H 3.51261 L 2.18493,4.18041 H 1.10728 Z m 4.43442,2.16058 c 0.097,-3.3e-4 0.18477,-0.0576 0.2241,-0.14631 L 7.96899,1.11125 H 4.2291 L 2.90142,4.18041 H 3.96981 L 4.97523,1.88118 H 6.56088 L 6.24311,2.6154 4.8604,2.6104 5.97615,4.18043 H 7.19244 L 6.36588,3.27185 Z', + IC: 'M 2.4406,1.11203 H 3.51401 L 2.18633,4.1812 H 1.10869 Z m 1.78011,0 H 8.02939 L 7.63516,2.02352 H 4.91525 L 4.37497,3.26971 H 7.09621 L 6.70198,4.1812 H 2.89304 Z', + EC: 'm 2.1507979,4.1626896 a 0.238125,0.238125 0 0 0 0.094721,0.017727 H 5.5890583 L 4.5100875,3.2525229 H 2.4299333 l 0.4741334,-0.4699 h 2.0984104 l 0.4743979,-0.4699 H 3.3779354 L 3.8401625,1.8370021 h 2.098675 L 6.6558583,1.11125 H 3.3485667 A 0.43127083,0.43127083 0 0 0 3.1231417,1.164696 L 3.0644047,1.212321 2.9929667,1.271852 1.1911542,3.0860999 a 0.21166667,0.21166667 0 0 0 -0.083344,0.1664229 0.16933333,0.16933333 0 0 0 0.065617,0.1367896 l 0.1180041,0.1010708 0.6757459,0.5413375 0.1068917,0.089165 c 0.020373,0.02196 0.047625,0.036513 0.076994,0.041804 m 4.0433625,0.00582 0.088371,0.011906 H 9.44245 L 10.367169,3.2525229 H 6.5315042 L 7.9245354,1.8370021 H 11.291623 L 12.003087,1.11125 H 7.2255062 l -1.920875,1.9629437 q -0.054504,0.050006 -0.097631,0.1098021 a 0.14816667,0.14816667 0 0 0 -0.015081,0.074348 0.17197917,0.17197917 0 0 0 0.04736,0.125148 l 0.089429,0.071438 0.6402917,0.5529791 0.082815,0.077258 q 0.044979,0.033338 0.094456,0.059531 z', + TGV: 'M 6.1364817,1.762125 A 1.8520833,1.8520833 0 0 0 5.9634437,1.669785 1.8520833,1.8520833 0 0 0 5.7247897,1.588558 1.5610417,1.5610417 0 0 0 5.3085997,1.54252 q -0.543718,0 -0.889264,0.3603625 -0.346075,0.3603625 -0.346075,0.9186334 0,0.434975 0.245269,0.681302 0.245268,0.2460625 0.683418,0.2460625 c 0.0799,0.0037 0.160073,-0.00688 0.236538,-0.030692 l 0.170656,-0.8133288 h -0.648229 l 0.100542,-0.4833937 h 1.239837 l -0.350308,1.6748125 q -0.08784,0.030427 -0.157692,0.050271 -0.07038,0.019844 -0.236537,0.052917 a 2.38125,2.38125 0 0 1 -0.442384,0.032808 q -0.723106,0 -1.101989,-0.3537479 -0.378884,-0.3537479 -0.378884,-1.0218209 0,-0.8262937 0.50165,-1.3123333 0.50165,-0.4855104 1.351492,-0.4855104 0.26035,-0.00132 0.516731,0.043921 a 3.4395833,3.4395833 0 0 1 0.486304,0.127529 z M 1.2125854,1.1115146 H 3.5737267 L 3.4731857,1.5951729 H 2.60985 l -0.5564187,2.58445 H 1.4141979 L 1.9703521,1.5954375 H 1.1075458 Z m 6.1198123,0 h -0.613304 l 0.402961,3.0681083 h 0.731572 l 1.708415,-3.0681083 h -0.665956 l -1.278996,2.3868062 z', + ICE: 'M 4.9887187,1.7227021 A 1.1641667,1.1641667 0 0 0 4.6950312,1.5909396 1.3758333,1.3758333 0 0 0 4.3053,1.5425208 q -0.5167312,0 -0.8453438,0.3386667 -0.3286125,0.3381375 -0.3286125,0.8744479 0,0.4397375 0.2452688,0.7164917 a 0.809625,0.809625 0 0 0 0.635,0.2770187 c 0.1008062,5.292e-4 0.2010833,-0.0127 0.2981854,-0.039687 0.117475,-0.037042 0.2307167,-0.087048 0.3373438,-0.149225 l -0.096573,0.5889625 q -0.061383,0.013229 -0.1706562,0.03519 -0.1098021,0.02196 -0.2169583,0.034925 a 2.38125,2.38125 0 0 1 -0.2868084,0.013229 q -0.6482291,0 -1.0162646,-0.3823229 -0.3680354,-0.382323 -0.3680354,-1.063625 0,-0.7871354 0.4815417,-1.2573 Q 3.4554583,1.0591271 4.270375,1.0591271 a 2.4870833,2.4870833 0 0 1 0.4489979,0.032808 q 0.1730375,0.033073 0.2365375,0.052917 0.0635,0.019844 0.1860021,0.0635 z M 1.7515417,1.1117792 H 2.3910396 L 1.7470437,4.1798875 H 1.1075458 Z m 5.6816625,0 H 5.6327146 L 4.9887187,4.1798875 h 1.82245 L 6.9162083,3.6962292 H 5.7070625 L 5.8869792,2.8347458 H 6.9598646 L 7.0564375,2.3513521 H 5.9875208 L 6.1452125,1.5954375 h 1.1959167 z', + // RJ: 'M 6.093,15.802 H 3.703 L 6.133,4.2 h 5.152 q 1.329,0 2.07,0.273 0.741,0.273 1.194,1.001 0.456,0.729 0.456,1.765 0,1.48 -0.887,2.442 -0.885,0.961 -2.683,1.19 0.46,0.412 0.863,1.085 0.8,1.361 1.78,3.846 H 11.514 Q 11.206,14.821 10.304,12.739 9.812,11.615 9.259,11.228 8.919,10.998 8.072,10.998 H 7.098 Z m 1.37,-6.545 h 1.266 q 1.923,0 2.552,-0.23 0.63,-0.228 0.985,-0.72 0.357,-0.49 0.356,-1.028 0,-0.635 -0.514,-0.95 -0.317,-0.19 -1.37,-0.19 H 8.112 Z M 22.375,4.2 h 2.342 l -1.464,7.028 q -0.585,2.793 -1.535,3.783 -0.95,0.99 -2.92,0.989 -1.67,0 -2.501,-0.768 -0.831,-0.767 -0.831,-2.09 0,-0.276 0.04,-0.616 l 2.207,-0.238 a 4.4,4.4 0 0 0 -0.047,0.578 q 0,0.555 0.316,0.847 0.317,0.293 0.974,0.293 0.918,0 1.29,-0.539 0.276,-0.411 0.673,-2.318 z', + // RJX: '', + // RX: '', + // NJ: '', + // OGV: '', + // PE: '', + IRE: '', + BEX: '', + GEX: '', + EN: '', + EXT: '', + CNL: '', + ICN: '', + }; + let { + kind: routeShortName, + type: _type, + line: _line, + currentColor: _currentColor, + nightIsFilled = true, + }: { + /** i.e. RE69, TER, etc... */ + kind: string; + type?: keyof typeof fernverkehrIconMap | string | null; + line?: string; + nightIsFilled?: boolean; + } & { + currentColor?: string; + } = $props(); + + let shortNumberIndex = $derived( + routeShortName.split('').findIndex((char) => char.match(/[0-9]/)) + ); + let line = $derived( + _line ?? + (_line === undefined + ? shortNumberIndex !== -1 + ? routeShortName.substring(shortNumberIndex).trim() + : '' + : _line) + ); + let lineType = $derived( + _type ?? + (_type === undefined + ? shortNumberIndex !== -1 + ? routeShortName.substring(0, shortNumberIndex).trim() + : routeShortName + : _type) + ); + let isRE = $derived( + typeof routeShortName === 'string' && + routeShortName.toUpperCase().startsWith('RE') && + routeShortName.charAt(2).toUpperCase() !== 'N' + ), + isFernverkehr = $derived( + (lineType ?? '').toUpperCase() in fernverkehrIconMap + ), + isNightRendering = + routeShortName.toUpperCase().startsWith('SN') || + routeShortName.toUpperCase().startsWith('REN') || + routeShortName.startsWith('IRN'); + let currentColor = $derived( + _currentColor ?? (isRE ? '#eb0000' : 'currentColor') + ); +</script> + +<!-- svelte-ignore attribute_illegal_colon --> +<svg + width="15.61mm" + height="5.2919998mm" + viewBox="0 0 15.61 5.2919998" + version="1.1" + id="svg1" + data-kind={routeShortName} + data-type={lineType} + data-line={line} + data-debug={dev + ? JSON.stringify({ + shortNumberIndex, + line, + type: lineType, + kind: routeShortName, + _line: _line ?? [`undef, ${_line}`], + _type: _type ?? [`undef, ${_type}`], + }) + : undefined} +> + <defs id="defs1"> + <clipPath id="a"> + <path fill="#fff" d="M0 0h59v20H0z" id="path3" /> + </clipPath> + </defs> + {#if isFernverkehr} + <g id="layer4" data-inkscape-label="Fernverkehr" style="display:inline"> + <path + fill="#eb0000" + fill-rule="evenodd" + d="M 0.52917,0 A 0.52916667,0.52916667 0 0 0 0,0.52916 V 4.7625 A 0.52916667,0.52916667 0 0 0 0.52917,5.29166 H 15.08125 A 0.52916667,0.52916667 0 0 0 15.61042,4.7625 V 0.52916 A 0.52916667,0.52916667 0 0 0 15.08125,0 Z" + clip-rule="evenodd" + id="path1" + style="display:inline;stroke-width:0.264583" + data-inkscape-label="Background" + /> + <g + data-inkscape-label="Glyphs" + data-inkscape-groupmode="layer" + id="layer1" + > + {#if lineType && lineType.toUpperCase() in fernverkehrIconMap} + <path + fill="#ffffff" + d={fernverkehrIconMap[ + lineType.toUpperCase() as keyof typeof fernverkehrIconMap + ]} + id="path2" + style="stroke-width:0.264583" + data-sodipodi-nodetypes="ccccccccccccccccccc" + data-inkscape-label="IR Glyph" + /> + {/if} + </g> + {#if line !== undefined && line !== ''} + <text + xml:space="preserve" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.23333px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;stroke-width:0.0264583" + x="9.5329876" + y="4.1801271" + id="text1-9" + data-inkscape-label="Number" + ><tspan + id="tspan1-8" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.28625px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;text-anchor:start;fill:#ffffff;stroke-width:0.0264583" + x="9.5329876" + y="4.1801271" + data-sodipodi-role="line">{line}</tspan + ></text + > + {/if} + </g> + {:else} + <g + clip-path="url(#a)" + id="g2" + transform="scale(0.26458333)" + data-inkscape-label="Regioverkehr" + > + <title id="title1">A</title> + <path + stroke="#000000" + d="M 2,0.5 H 57 A 1.5,1.5 0 0 1 58.5,2 V 18 A 1.5,1.5 0 0 1 57,19.5 H 2 A 1.5,1.5 0 0 1 0.5,18 V 2 A 1.5,1.5 0 0 1 2,0.5 Z" + id="path1-8" + style="display:inline;fill:{nightIsFilled && isNightRendering + ? '#000' + : 'none'};stroke:{isNightRendering + ? nightIsFilled + ? '#000' + : '#FFDE15' + : currentColor};stroke-opacity:1" + data-inkscape-label="Background" + /> + <text + xml:space="preserve" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.32594px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;direction:ltr;text-anchor:start;display:inline;fill:{isNightRendering + ? '#FFDE15' + : currentColor};fill-opacity:1;stroke:{/*isNightRendering + ? nightIsFilled + '#FFDE15' + : currentColor*/ 'none'};stroke-width:0.0264583;stroke-opacity:1" + x="0.80801982" + y="4.2193737" + id="text1" + data-inkscape-label="Kind" + transform="scale(3.7795276)" + ><tspan + data-sodipodi-role="line" + id="tspan1" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.32594px;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.0264583" + x="0.80801982" + y="4.2193737">{routeShortName}</tspan + ></text + > + </g> + {/if} +</svg> diff --git a/src/lib/assets/LineGlyphSrc.svg b/src/lib/assets/LineGlyphSrc.svg new file mode 100644 index 0000000..8d7bc59 --- /dev/null +++ b/src/lib/assets/LineGlyphSrc.svg @@ -0,0 +1,233 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="15.61mm" + height="5.2919998mm" + viewBox="0 0 15.61 5.2919998" + version="1.1" + id="svg1" + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" + sodipodi:docname="LineGlyphSrc.svg" + xml:space="preserve" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview + id="namedview1" + pagecolor="#505050" + bordercolor="#ffffff" + borderopacity="1" + inkscape:showpageshadow="0" + inkscape:pageopacity="0" + inkscape:pagecheckerboard="1" + inkscape:deskcolor="#505050" + inkscape:document-units="mm" + inkscape:zoom="16.635742" + inkscape:cx="20.077253" + inkscape:cy="1.5929557" + inkscape:window-width="1920" + inkscape:window-height="1014" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="layer1" /><defs + id="defs1"><clipPath + id="a"><path + fill="#fff" + d="M0 0h59v20H0z" + id="path3" /></clipPath><clipPath + id="a-6"><path + fill="#fff" + d="M0 0h59v20H0z" + id="path3-8" /></clipPath></defs><g + inkscape:groupmode="layer" + id="layer4" + inkscape:label="Fernverkehr" + style="display:inline" + transform="scale(0.99997309,1)"><path + fill="#eb0000" + fill-rule="evenodd" + d="M 0.52917,0 A 0.52916667,0.52916667 0 0 0 0,0.52916 V 4.7625 A 0.52916667,0.52916667 0 0 0 0.52917,5.29166 H 15.08125 A 0.52916667,0.52916667 0 0 0 15.61042,4.7625 V 0.52916 A 0.52916667,0.52916667 0 0 0 15.08125,0 Z" + clip-rule="evenodd" + id="path1" + style="display:inline;stroke-width:0.264583" + inkscape:label="Background" /><g + inkscape:label="Glyphs" + inkscape:groupmode="layer" + id="layer1" + style="display:inline"><path + fill="#ffffff" + d="M 2.43946,1.11125 H 3.51261 L 2.18493,4.18041 H 1.10728 Z m 4.43442,2.16058 c 0.097,-3.3e-4 0.18477,-0.0576 0.2241,-0.14631 L 7.96899,1.11125 H 4.2291 L 2.90142,4.18041 H 3.96981 L 4.97523,1.88118 H 6.56088 L 6.24311,2.6154 4.8604,2.6104 5.97615,4.18043 H 7.19244 L 6.36588,3.27185 Z" + id="path2" + style="display:none;stroke-width:0.264583" + sodipodi:nodetypes="ccccccccccccccccccc" + inkscape:label="IR Glyph" /><path + fill="#ffffff" + d="M 2.4406,1.11203 H 3.51401 L 2.18633,4.1812 H 1.10869 Z m 1.78011,0 H 8.02939 L 7.63516,2.02352 H 4.91525 L 4.37497,3.26971 H 7.09621 L 6.70198,4.1812 H 2.89304 Z" + id="path2-7" + style="display:none;stroke-width:0.264583" + sodipodi:nodetypes="cccccccccccccc" + inkscape:label="IC Glyph" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 2.1507979,4.1626896 a 0.238125,0.238125 0 0 0 0.094721,0.017727 H 5.5890583 L 4.5100875,3.2525229 H 2.4299333 l 0.4741334,-0.4699 h 2.0984104 l 0.4743979,-0.4699 H 3.3779354 L 3.8401625,1.8370021 h 2.098675 L 6.6558583,1.11125 H 3.3485667 A 0.43127083,0.43127083 0 0 0 3.1231417,1.164696 L 3.0644047,1.212321 2.9929667,1.271852 1.1911542,3.0860999 a 0.21166667,0.21166667 0 0 0 -0.083344,0.1664229 0.16933333,0.16933333 0 0 0 0.065617,0.1367896 l 0.1180041,0.1010708 0.6757459,0.5413375 0.1068917,0.089165 c 0.020373,0.02196 0.047625,0.036513 0.076994,0.041804 m 4.0433625,0.00582 0.088371,0.011906 H 9.44245 L 10.367169,3.2525229 H 6.5315042 L 7.9245354,1.8370021 H 11.291623 L 12.003087,1.11125 H 7.2255062 l -1.920875,1.9629437 q -0.054504,0.050006 -0.097631,0.1098021 a 0.14816667,0.14816667 0 0 0 -0.015081,0.074348 0.17197917,0.17197917 0 0 0 0.04736,0.125148 l 0.089429,0.071438 0.6402917,0.5529791 0.082815,0.077258 q 0.044979,0.033338 0.094456,0.059531 z" + clip-rule="evenodd" + id="path2-8" + style="display:inline;stroke-width:0.264583" + inkscape:label="EC Glyph" /><path + fill="#ffffff" + fill-rule="evenodd" + d="M 6.1364817,1.762125 A 1.8520833,1.8520833 0 0 0 5.9634437,1.669785 1.8520833,1.8520833 0 0 0 5.7247897,1.588558 1.5610417,1.5610417 0 0 0 5.3085997,1.54252 q -0.543718,0 -0.889264,0.3603625 -0.346075,0.3603625 -0.346075,0.9186334 0,0.434975 0.245269,0.681302 0.245268,0.2460625 0.683418,0.2460625 c 0.0799,0.0037 0.160073,-0.00688 0.236538,-0.030692 l 0.170656,-0.8133288 h -0.648229 l 0.100542,-0.4833937 h 1.239837 l -0.350308,1.6748125 q -0.08784,0.030427 -0.157692,0.050271 -0.07038,0.019844 -0.236537,0.052917 a 2.38125,2.38125 0 0 1 -0.442384,0.032808 q -0.723106,0 -1.101989,-0.3537479 -0.378884,-0.3537479 -0.378884,-1.0218209 0,-0.8262937 0.50165,-1.3123333 0.50165,-0.4855104 1.351492,-0.4855104 0.26035,-0.00132 0.516731,0.043921 a 3.4395833,3.4395833 0 0 1 0.486304,0.127529 z M 1.2125854,1.1115146 H 3.5737267 L 3.4731857,1.5951729 H 2.60985 l -0.5564187,2.58445 H 1.4141979 L 1.9703521,1.5954375 H 1.1075458 Z m 6.1198123,0 h -0.613304 l 0.402961,3.0681083 h 0.731572 l 1.708415,-3.0681083 h -0.665956 l -1.278996,2.3868062 z" + clip-rule="evenodd" + id="path2-81" + style="display:none;stroke-width:0.264583" + inkscape:label="TGV Glyph" /><path + fill="#ffffff" + fill-rule="evenodd" + d="M 4.9887187,1.7227021 A 1.1641667,1.1641667 0 0 0 4.6950312,1.5909396 1.3758333,1.3758333 0 0 0 4.3053,1.5425208 q -0.5167312,0 -0.8453438,0.3386667 -0.3286125,0.3381375 -0.3286125,0.8744479 0,0.4397375 0.2452688,0.7164917 a 0.809625,0.809625 0 0 0 0.635,0.2770187 c 0.1008062,5.292e-4 0.2010833,-0.0127 0.2981854,-0.039687 0.117475,-0.037042 0.2307167,-0.087048 0.3373438,-0.149225 l -0.096573,0.5889625 q -0.061383,0.013229 -0.1706562,0.03519 -0.1098021,0.02196 -0.2169583,0.034925 a 2.38125,2.38125 0 0 1 -0.2868084,0.013229 q -0.6482291,0 -1.0162646,-0.3823229 -0.3680354,-0.382323 -0.3680354,-1.063625 0,-0.7871354 0.4815417,-1.2573 Q 3.4554583,1.0591271 4.270375,1.0591271 a 2.4870833,2.4870833 0 0 1 0.4489979,0.032808 q 0.1730375,0.033073 0.2365375,0.052917 0.0635,0.019844 0.1860021,0.0635 z M 1.7515417,1.1117792 H 2.3910396 L 1.7470437,4.1798875 H 1.1075458 Z m 5.6816625,0 H 5.6327146 L 4.9887187,4.1798875 h 1.82245 L 6.9162083,3.6962292 H 5.7070625 L 5.8869792,2.8347458 H 6.9598646 L 7.0564375,2.3513521 H 5.9875208 L 6.1452125,1.5954375 h 1.1959167 z" + clip-rule="evenodd" + id="path2-73" + style="display:none;stroke-width:0.264583" + inkscape:label="ICE Glyph" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 14.415,11.156 a 0.27,0.27 0 0 1 0.225,-0.15 q 0.15,0 0.15,0.282 v 0.037 l -0.9,4.55 q -0.075,0.206 -0.243,0.206 a 0.165,0.165 0 0 1 -0.188,-0.188 l -0.018,-0.188 q -0.056,-0.507 -0.488,-0.507 h -0.112 l -0.094,0.019 -0.112,0.037 -0.45,0.188 a 7,7 0 0 1 -2.829,0.564 q -2.322,0 -3.746,-1.39 Q 4.186,13.224 4.186,10.95 a 6.66,6.66 0 0 1 2.042,-4.87 6.62,6.62 0 0 1 4.852,-2.048 6.25,6.25 0 0 1 2.735,0.64 l 0.73,0.337 q 0.165,0.063 0.338,0.076 0.375,0 0.637,-0.49 L 15.707,4.22 15.782,4.145 15.914,4.107 a 0.17,0.17 0 0 1 0.168,0.15 l 0.019,0.038 V 4.37 l -1.05,4.512 -0.037,0.244 -0.093,0.15 A 0.4,0.4 0 0 1 14.752,9.352 L 14.658,9.314 14.602,9.239 V 8.581 A 4.6,4.6 0 0 0 13.665,5.667 q -0.936,-1.203 -2.304,-1.203 -1.854,0 -3.222,2.2 -1.368,2.199 -1.368,5.17 0,3.758 2.623,3.759 1.854,0 2.885,-1.185 a 16,16 0 0 0 1.63,-2.18 l 0.168,-0.338 0.244,-0.546 0.037,-0.056 0.02,-0.056 z m 3.597,1.297 q -0.152,0.65 -0.169,1.316 c -0.01,0.39 0.098,0.773 0.31,1.1 a 0.9,0.9 0 0 0 0.758,0.46 l 0.206,0.02 h 0.056 q 0.188,0.056 0.188,0.206 a 0.6,0.6 0 0 1 -0.094,0.188 l -0.131,0.056 H 15.07 a 0.165,0.165 0 0 1 -0.187,-0.187 l 0.019,-0.095 a 0.44,0.44 0 0 1 0.262,-0.132 l 0.169,-0.037 a 2.4,2.4 0 0 0 1.405,-0.874 q 0.543,-0.686 0.805,-1.964 L 18.949,5.667 18.724,5.329 A 1.42,1.42 0 0 0 17.656,4.652 h -0.075 q -0.337,0 -0.337,-0.225 0,-0.226 0.412,-0.226 h 3.297 l 4.403,8.027 1.086,-5.076 0.056,-0.282 q 0.042,-0.195 0.057,-0.394 0.018,-0.226 0.018,-0.414 0,-1.165 -1.03,-1.39 L 25.318,4.633 25.206,4.577 25.15,4.427 q 0,-0.226 0.206,-0.226 h 3.915 a 0.16,0.16 0 0 1 0.169,0.094 l 0.018,0.094 a 0.226,0.226 0 0 1 -0.206,0.244 l -0.168,0.038 c -0.37,0.018 -0.72,0.164 -0.993,0.414 A 2.7,2.7 0 0 0 27.323,6.081 L 27.173,6.438 27.042,6.833 25,16.025 24.98,16.044 v 0.037 a 0.3,0.3 0 0 1 -0.205,0.113 0.2,0.2 0 0 1 -0.169,-0.075 7,7 0 0 1 -0.3,-0.526 L 19.323,6.307 Z m 9.61,3.159 q 0,0.187 0.263,0.187 h 8.917 l 1.537,-4.135 0.075,-0.245 q 0,-0.169 -0.188,-0.169 l -0.169,0.075 -0.112,0.207 -0.056,0.132 a 6.8,6.8 0 0 1 -2.183,2.585 q -1.49,1.1 -2.8,1.1 -0.956,0 -0.956,-0.79 0.015,-0.279 0.056,-0.555 0.056,-0.423 0.113,-0.667 l 1.61,-7.557 q 0.225,-1.11 0.994,-1.109 h 0.187 l 0.225,-0.019 q 0.337,0 0.337,-0.225 V 4.389 q 0,-0.188 -0.15,-0.188 H 30.49 q -0.43,0 -0.431,0.226 0,0.188 0.356,0.263 h 0.056 q 0.712,0.17 0.712,0.808 0,0.075 -0.028,0.31 -0.028,0.236 -0.103,0.705 l -1.555,7.425 q -0.28,1.222 -1.274,1.354 l -0.356,0.056 a 0.247,0.247 0 0 0 -0.244,0.263" + clip-rule="evenodd" + id="path2-650" + inkscape:label="CNL Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + d="M 3.669,15.8 6.104,4.132 h 8.651 L 14.35,6.082 H 8.102 l -0.557,2.65 h 6.04 l -0.405,1.95 H 7.139 L 6.406,13.85 h 6.79 l -0.414,1.95 z m 13.009,0 h -3.104 l 5.15,-5.905 -2.898,-5.763 h 2.508 l 1.138,2.189 q 0.056,0.12 0.78,1.616 0.048,0.08 0.103,0.214 A 82,82 0 0 1 21.987,6.21 l 1.838,-2.078 h 3.017 l -5.34,6.145 2.873,5.523 h -2.707 l -0.875,-1.759 q -0.685,-1.392 -0.907,-1.998 -0.31,0.47 -1.6,1.942 z m 14.204,0 h -2.404 l 2.037,-9.718 h -3.422 l 0.406,-1.95 h 9.193 l -0.406,1.95 H 32.91 Z" + id="path2-02" + inkscape:label="EXT Glyph" + style="fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 8.287,15.8 h 5.288 l 3.428,-3.508 H 9.138 l 1.77,-1.774 h 7.933 L 20.656,8.74 h -7.933 l 1.748,-1.799 h 7.935 L 25.116,4.2 H 12.588 a 1.4,1.4 0 0 0 -0.807,0.2 l -0.246,0.182 -0.27,0.223 -6.834,6.858 c -0.15,0.176 -0.237,0.398 -0.246,0.63 a 0.7,0.7 0 0 0 0.203,0.517 l 0.426,0.382 2.576,2.048 0.381,0.335 0.316,0.158 q 0.051,0.003 0.099,0.024 l 0.075,0.032 z M 33.115,12.675 V 4.2 H 27.268 L 15.726,15.8 h 5.358 L 29.552,7.301 V 15.8 h 5.849 L 46.963,4.2 h -5.4 z" + clip-rule="evenodd" + id="path2-5" + style="fill:none" + transform="scale(0.26458333)" + inkscape:label="EN Glyph" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 9.856,9.605 h 5.173 l -1.041,4.977 a 9,9 0 0 1 -2.244,1.005 9.1,9.1 0 0 1 -2.708,0.411 q -2.145,0 -3.289,-0.973 -1.56,-1.329 -1.561,-3.853 a 8.1,8.1 0 0 1 0.67,-3.244 6.6,6.6 0 0 1 2.287,-2.896 5.96,5.96 0 0 1 3.486,-1.03 q 2.003,0 3.213,0.935 1.21,0.934 1.605,2.722 L 13.231,7.912 A 2.8,2.8 0 0 0 12.289,6.432 2.6,2.6 0 0 0 10.652,5.934 3.805,3.805 0 0 0 7.032,8.394 6.8,6.8 0 0 0 6.488,11.14 q 0,1.464 0.663,2.164 0.663,0.7 1.94,0.7 a 6,6 0 0 0 1.57,-0.217 q 0.72,-0.187 1.387,-0.518 l 0.355,-1.725 h -2.95 z m 5.602,6.196 2.413,-11.6 h 8.572 l -0.402,1.938 h -6.19 l -0.552,2.635 h 5.985 l -0.402,1.939 h -5.986 l -0.725,3.149 h 6.727 l -0.41,1.939 z m 9.096,0 h 3.076 l 1.593,-1.805 q 0.841,-0.924 1.585,-1.93 0.22,0.6 0.898,1.986 l 0.868,1.749 h 2.681 L 32.408,10.309 37.7,4.201 H 34.71 L 32.89,6.266 A 82,82 0 0 0 31.274,8.196 2,2 0 0 0 31.171,7.983 L 30.398,6.377 29.27,4.2 h -2.484 l 2.87,5.728 z" + clip-rule="evenodd" + id="path2-45" + inkscape:label="GEX Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="M 4.186,15.8 6.606,4.2 h 3.952 q 0.9,-0.02 1.798,0.08 c 0.508,0.058 0.999,0.22 1.443,0.474 0.383,0.225 0.694,0.554 0.899,0.95 0.207,0.406 0.313,0.857 0.307,1.313 a 2.7,2.7 0 0 1 -0.536,1.673 3.27,3.27 0 0 1 -1.656,1.073 2.6,2.6 0 0 1 1.392,0.874 c 0.327,0.4 0.504,0.903 0.5,1.42 a 3.66,3.66 0 0 1 -0.59,1.983 3.33,3.33 0 0 1 -1.633,1.349 Q 11.441,15.8 9.635,15.8 Z M 8.003,8.884 H 9.816 A 6.3,6.3 0 0 0 11.575,8.71 1.47,1.47 0 0 0 12.647,7.294 C 12.657,7.028 12.567,6.768 12.395,6.566 A 1.26,1.26 0 0 0 11.678,6.186 10,10 0 0 0 10.424,6.139 H 8.578 Z m 1.246,5.049 H 6.954 l 0.63,-3.03 h 2.84 q 0.993,0 1.42,0.375 c 0.277,0.237 0.433,0.587 0.425,0.953 a 1.56,1.56 0 0 1 -0.296,0.902 1.64,1.64 0 0 1 -0.788,0.614 q -0.493,0.186 -1.936,0.186 M 15.34,15.8 17.753,4.2 h 8.572 l -0.402,1.939 h -6.19 L 19.18,8.774 h 5.986 l -0.402,1.938 h -5.986 l -0.725,3.15 h 6.726 l -0.41,1.938 z m 9.096,0 h 3.076 l 1.593,-1.804 q 0.841,-0.924 1.584,-1.93 0.221,0.6 0.9,1.985 l 0.867,1.749 h 2.681 L 32.29,10.309 37.582,4.2 H 34.593 L 32.771,6.265 A 82,82 0 0 0 31.155,8.195 2,2 0 0 0 31.052,7.982 L 30.279,6.376 29.153,4.2 h -2.484 l 2.87,5.729 z" + clip-rule="evenodd" + id="path2-4" + inkscape:label="BEX Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + d="M 4.28,16 6.707,4.483 H 9.116 L 6.69,16 Z m 4.659,0 2.409,-11.517 h 2.722 c 2.129,0 4.587,-0.033 4.587,2.723 0,1.98 -1.485,3.036 -3.366,3.069 v 0.032 c 0.627,0.034 1.023,0.43 1.172,1.057 L 17.568,16 H 15.143 L 14.582,13.294 C 14.318,12.074 14.384,11.215 13.13,11.215 H 12.255 L 11.25,16 Z M 13.311,6.298 12.668,9.4 h 0.841 c 1.238,0 2.74,-0.38 2.74,-1.897 0,-1.205 -1.04,-1.205 -1.98,-1.205 z M 19.19,16 21.616,4.483 h 6.782 l -0.347,1.815 h -4.504 l -0.594,2.838 h 4.026 l -0.363,1.815 h -4.043 l -0.676,3.234 h 4.554 L 26.055,16 Z" + id="path2-87" + inkscape:label="IRE Glyph" + style="fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 7.002,9.378 h 0.745 q 1.913,0 2.554,-0.24 A 2.1,2.1 0 0 0 11.31,8.369 q 0.369,-0.528 0.368,-1.16 0,-0.426 -0.188,-0.697 A 1.07,1.07 0 0 0 10.962,6.115 Q 10.622,5.991 9.444,5.991 H 7.714 Z M 5.664,15.801 H 3.246 L 5.705,4.06 h 4.772 q 1.273,0 2.014,0.3 0.741,0.3 1.173,0.993 0.432,0.693 0.433,1.654 0,0.89 -0.345,1.73 -0.345,0.84 -0.844,1.345 a 3.6,3.6 0 0 1 -1.077,0.765 q -0.578,0.26 -1.554,0.396 -0.57,0.08 -2.13,0.08 H 6.601 Z M 13.602,15.8 16.052,4.061 h 8.705 l -0.408,1.962 h -6.286 l -0.56,2.667 h 6.077 l -0.408,1.962 h -6.078 l -0.737,3.187 h 6.83 l -0.416,1.962 z" + clip-rule="evenodd" + id="path2-805" + inkscape:label="PE Glyph" + style="fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + d="m 4.116,11.252 q 0,-1.028 0.308,-2.168 0.404,-1.525 1.227,-2.65 A 5.96,5.96 0 0 1 7.739,4.663 q 1.258,-0.657 2.863,-0.657 2.152,0 3.473,1.337 1.329,1.336 1.329,3.544 a 7.8,7.8 0 0 1 -0.862,3.551 q -0.863,1.716 -2.342,2.642 -1.479,0.926 -3.346,0.926 -1.621,0 -2.72,-0.736 -1.1,-0.735 -1.56,-1.82 A 5.6,5.6 0 0 1 4.116,11.252 m 2.357,-0.048 q 0,1.195 0.728,2.002 0.728,0.807 1.914,0.806 0.965,0 1.851,-0.633 0.894,-0.64 1.472,-1.93 0.585,-1.297 0.585,-2.523 0,-1.368 -0.736,-2.144 Q 11.552,6 10.412,6 8.665,6 7.566,7.63 A 6.3,6.3 0 0 0 6.474,11.205 M 21.942,9.606 h 5.189 l -1.044,4.976 q -0.918,0.593 -2.254,1.004 a 9.1,9.1 0 0 1 -2.714,0.412 q -2.152,0 -3.298,-0.973 -1.566,-1.329 -1.566,-3.852 0,-1.693 0.672,-3.244 0.807,-1.867 2.294,-2.895 1.487,-1.028 3.496,-1.028 2.009,0 3.22,0.933 1.218,0.934 1.613,2.721 l -2.222,0.254 q -0.294,-0.98 -0.95,-1.48 -0.648,-0.498 -1.637,-0.498 -1.155,0 -2.12,0.601 -0.965,0.6 -1.51,1.86 -0.547,1.257 -0.547,2.744 0,1.464 0.665,2.167 0.664,0.696 1.946,0.696 0.767,0 1.574,-0.213 a 7.3,7.3 0 0 0 1.392,-0.522 l 0.356,-1.725 H 21.539 Z M 33.408,15.8 H 30.861 L 28.781,4.204 h 2.38 l 1.472,8.772 4.801,-8.772 H 39.8 Z" + id="path2-91" + inkscape:label="OGV Glyph" + style="fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="m 12.966,15.78 h -2.23 L 7.594,7.964 5.96,15.78 H 3.73 L 6.158,4.148 h 2.238 l 3.15,7.784 1.626,-7.784 h 2.222 z M 22.435,4.148 h 2.349 l -1.468,7.046 q -0.588,2.8 -1.54,3.793 -0.951,0.99 -2.927,0.991 -1.675,0 -2.508,-0.77 -0.833,-0.768 -0.833,-2.094 0,-0.277 0.04,-0.619 l 2.214,-0.238 a 4.4,4.4 0 0 0 -0.048,0.58 q 0,0.555 0.317,0.848 0.318,0.294 0.976,0.294 0.921,0 1.293,-0.54 0.278,-0.413 0.675,-2.325 z" + clip-rule="evenodd" + id="path2-9" + inkscape:label="NJ Glyph" + style="fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + d="M 9.204,4.2 4.186,15.8 H 8.42 l 1.905,-4.609 1.68,-4.091 h 8.962 l -1.21,2.788 h -7.438 a 66,66 0 0 0 1.557,1.596 238,238 0 0 0 2.879,2.799 q 0.739,0.707 1.613,1.517 h 10.777 l 1.926,-1.484 2.129,-1.64 1.389,3.124 h 6.228 L 38.891,12.63 37.166,9.753 45.612,4.2 h -6.43 L 35.597,6.875 34.297,4.2 h -6.071 l 3.204,5.575 -8.447,5.575 q -0.627,-0.495 -1.49,-1.202 -0.863,-0.709 -2.229,-1.765 h 0.874 q 0.739,0 1.199,-0.011 a 9,9 0 0 0 0.66,-0.034 1.7,1.7 0 0 0 0.673,-0.157 c 0.24,-0.232 0.424,-0.517 0.537,-0.832 L 26.232,4.2 Z" + id="path2-0" + inkscape:label="RX Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="M 6.093,15.802 H 3.703 L 6.133,4.2 h 5.152 q 1.329,0 2.07,0.273 0.741,0.273 1.194,1.001 0.456,0.729 0.456,1.765 0,1.48 -0.887,2.442 -0.885,0.961 -2.683,1.19 0.46,0.412 0.863,1.085 0.8,1.361 1.78,3.846 H 11.514 Q 11.206,14.821 10.304,12.739 9.812,11.615 9.259,11.228 8.919,10.998 8.072,10.998 H 7.098 Z m 1.37,-6.545 h 1.266 q 1.923,0 2.552,-0.23 0.63,-0.228 0.985,-0.72 0.357,-0.49 0.356,-1.028 0,-0.635 -0.514,-0.95 -0.317,-0.19 -1.37,-0.19 H 8.112 Z M 22.375,4.2 h 2.342 l -1.464,7.028 q -0.585,2.793 -1.535,3.783 -0.95,0.99 -2.92,0.989 -1.67,0 -2.501,-0.768 -0.831,-0.767 -0.831,-2.09 0,-0.276 0.04,-0.616 l 2.207,-0.238 a 4.4,4.4 0 0 0 -0.047,0.578 q 0,0.555 0.316,0.847 0.317,0.293 0.974,0.293 0.918,0 1.29,-0.539 0.276,-0.411 0.673,-2.318 z m 4.538,11.602 H 23.826 L 28.946,9.93 26.066,4.2 h 2.493 l 1.132,2.176 q 0.055,0.12 0.775,1.607 0.048,0.08 0.103,0.214 A 82,82 0 0 1 32.192,6.266 L 34.02,4.2 h 3 l -5.311,6.11 2.857,5.492 h -2.691 l -0.87,-1.749 q -0.681,-1.384 -0.903,-1.986 -0.309,0.466 -1.59,1.93 z" + clip-rule="evenodd" + id="path2-80" + inkscape:label="RJX Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /><path + fill="#ffffff" + fill-rule="evenodd" + d="M 6.093,15.802 H 3.703 L 6.133,4.2 h 5.152 q 1.329,0 2.07,0.273 0.741,0.273 1.194,1.001 0.456,0.729 0.456,1.765 0,1.48 -0.887,2.442 -0.885,0.961 -2.683,1.19 0.46,0.412 0.863,1.085 0.8,1.361 1.78,3.846 H 11.514 Q 11.206,14.821 10.304,12.739 9.812,11.615 9.259,11.228 8.919,10.998 8.072,10.998 H 7.098 Z m 1.37,-6.545 h 1.266 q 1.923,0 2.552,-0.23 0.63,-0.228 0.985,-0.72 0.357,-0.49 0.356,-1.028 0,-0.635 -0.514,-0.95 -0.317,-0.19 -1.37,-0.19 H 8.112 Z M 22.375,4.2 h 2.342 l -1.464,7.028 q -0.585,2.793 -1.535,3.783 -0.95,0.99 -2.92,0.989 -1.67,0 -2.501,-0.768 -0.831,-0.767 -0.831,-2.09 0,-0.276 0.04,-0.616 l 2.207,-0.238 a 4.4,4.4 0 0 0 -0.047,0.578 q 0,0.555 0.316,0.847 0.317,0.293 0.974,0.293 0.918,0 1.29,-0.539 0.276,-0.411 0.673,-2.318 z" + clip-rule="evenodd" + id="path2-65" + inkscape:label="RJ Glyph" + style="display:inline;fill:none" + transform="scale(0.26458333)" /></g><text + xml:space="preserve" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.23333px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;direction:ltr;text-anchor:start;display:none;fill:#ffffff;stroke-width:0.0264583" + x="9.5329876" + y="4.1801271" + id="text1-9" + inkscape:label="Number"><tspan + id="tspan1-8" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.28625px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;text-anchor:start;fill:#ffffff;stroke-width:0.0264583" + x="0" + y="0" + sodipodi:role="line">37</tspan></text></g><g + clip-path="url(#a)" + id="g2" + transform="scale(0.26458333)" + inkscape:label="Regioverkehr" + style="display:none"><title + id="title1">A</title><path + stroke="#000000" + d="M 2,0.5 H 57 A 1.5,1.5 0 0 1 58.5,2 V 18 A 1.5,1.5 0 0 1 57,19.5 H 2 A 1.5,1.5 0 0 1 0.5,18 V 2 A 1.5,1.5 0 0 1 2,0.5 Z" + id="path1-8" + style="display:inline;fill:none;stroke:#00e1ff;stroke-opacity:1" + inkscape:label="Background" /><text + xml:space="preserve" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.32594px;line-height:0;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;direction:ltr;text-anchor:start;display:inline;fill:#00e1ff;fill-opacity:1;stroke:#00e1ff;stroke-width:0.0264583;stroke-opacity:1" + x="0.80801982" + y="4.2193737" + id="text1" + inkscape:label="Kind" + transform="scale(3.7795276)"><tspan + sodipodi:role="line" + id="tspan1" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.32594px;font-family:SBB;-inkscape-font-specification:'SBB, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.0264583" + x="0.80801982" + y="4.2193737">RE37</tspan></text></g><g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="References" + style="display:none"><path + d="m 193.67641,104.14923 v 3.0599 h -0.60907 v -2.00289 h -0.71755 v -0.44371 c 0.12276,-7.2e-4 0.24499,-0.0162 0.36407,-0.046 0.0934,-0.0264 0.18071,-0.0704 0.2577,-0.12964 0.11254,-0.0864 0.1883,-0.23231 0.22728,-0.43762 z m 0.87207,0.96917 c -0.001,-0.22429 0.0632,-0.44403 0.18547,-0.63209 0.18927,-0.27341 0.47361,-0.41011 0.85302,-0.41011 0.2921,0 0.52784,0.0746 0.70723,0.22384 0.18917,0.15271 0.29638,0.38483 0.28998,0.62786 0.004,0.14073 -0.0362,0.27916 -0.11377,0.39661 -0.0728,0.1089 -0.18183,0.18855 -0.30771,0.22489 0.15611,0.0326 0.29475,0.12224 0.38894,0.25136 0.0979,0.13705 0.1479,0.30241 0.14287,0.47095 0.008,0.28545 -0.11509,0.55885 -0.33337,0.74295 -0.20602,0.17851 -0.46619,0.26776 -0.78052,0.26776 -0.34043,0 -0.61049,-0.0983 -0.81016,-0.29501 -0.19949,-0.19685 -0.29924,-0.46266 -0.29924,-0.79745 v -0.0397 h 0.58605 c 0,0.18821 0.0445,0.33364 0.13362,0.4363 0.0963,0.10583 0.23495,0.16219 0.37756,0.15372 0.16263,0 0.28777,-0.0481 0.37544,-0.14446 0.0799,-0.0886 0.12303,-0.20479 0.12092,-0.32438 0.006,-0.13659 -0.051,-0.26837 -0.15452,-0.35772 -0.0847,-0.0727 -0.21537,-0.10901 -0.39211,-0.10901 -0.0494,0 -0.0988,0.003 -0.14817,0.008 v -0.44979 c 0.0363,0.003 0.0712,0.004 0.10451,0.004 0.35031,0 0.52546,-0.13044 0.52546,-0.39132 0.004,-0.1047 -0.0378,-0.20594 -0.11456,-0.27728 -0.0833,-0.073 -0.19172,-0.11098 -0.30242,-0.10584 -0.12511,-0.007 -0.24689,0.042 -0.33179,0.13415 -0.0778,0.0891 -0.1195,0.21951 -0.12515,0.39132 z" + style="display:none;fill:#ffffff;stroke-width:0.264583" + id="path1-3" + sodipodi:nodetypes="ccccccccccccscccccccscscccccccsccscccccc" + inkscape:label="IR13 Number" /><path + d="m 192.16493,105.11918 c -0.001,-0.22432 0.0634,-0.44406 0.18573,-0.63209 0.18927,-0.27341 0.47361,-0.41011 0.85302,-0.41011 0.2921,0 0.52784,0.0746 0.70723,0.22384 0.18917,0.15271 0.29638,0.38483 0.28998,0.62786 0.004,0.14073 -0.0362,0.27917 -0.11377,0.39662 -0.0728,0.1089 -0.18183,0.18855 -0.30771,0.22489 0.15611,0.0326 0.29475,0.12224 0.38894,0.25136 0.0979,0.13705 0.1479,0.30241 0.14287,0.47095 0.008,0.28545 -0.11509,0.55885 -0.33337,0.74295 -0.20602,0.17851 -0.46619,0.26776 -0.78052,0.26776 -0.34043,0 -0.61048,-0.0983 -0.81015,-0.29501 -0.1995,-0.19685 -0.29925,-0.46266 -0.29925,-0.79745 v -0.0397 h 0.58605 c 0,0.18821 0.0445,0.33364 0.13335,0.4363 0.0963,0.10583 0.23522,0.16219 0.37756,0.15372 0.16263,0 0.28787,-0.0481 0.37571,-0.14446 0.0796,-0.0886 0.12303,-0.20479 0.12092,-0.32438 0.006,-0.13659 -0.051,-0.26837 -0.15452,-0.35772 -0.0847,-0.0727 -0.21537,-0.10901 -0.39211,-0.10901 -0.0494,0 -0.0988,0.003 -0.14817,0.008 v -0.44979 c 0.0363,0.003 0.0712,0.004 0.10451,0.004 0.35031,0 0.52546,-0.13053 0.52546,-0.39159 0.004,-0.10461 -0.0379,-0.20574 -0.11456,-0.27702 -0.0833,-0.073 -0.19172,-0.11098 -0.30242,-0.10584 -0.12511,-0.007 -0.24689,0.042 -0.33179,0.13415 -0.0778,0.0891 -0.1195,0.21951 -0.12515,0.39132 z" + style="display:none;fill:#ffffff;stroke-width:0.264583" + id="path1-37" + inkscape:label="IC3 Number" /><path + fill="#000000" + d="M 12.894,4.224 V 6.4 H 9.316 v 9.604 H 6.746 V 6.399 H 3.186 V 4.224 Z m 10.119,0 V 6.4 h -6.268 v 2.514 h 5.744 v 2.022 h -5.744 v 2.893 h 6.38 v 2.175 h -8.95 V 4.224 Z m 1.804,0 h 5.704 q 1.692,0 2.595,0.532 0.749,0.435 1.144,1.16 0.395,0.725 0.395,1.636 0,1.03 -0.5,1.796 -0.492,0.765 -1.378,1.096 0.782,0.194 1.225,0.87 0.282,0.428 0.379,0.894 0.096,0.467 0.25,2.135 0.112,1.225 0.346,1.523 l 0.113,0.137 H 32.407 A 2,2 0 0 1 32.205,15.367 Q 32.149,14.98 32.052,13.577 31.956,12.329 31.44,11.854 30.932,11.371 29.7,11.37 h -2.312 v 4.633 h -2.57 z m 2.57,2.022 v 3.24 h 2.458 q 1.184,0 1.668,-0.34 0.612,-0.427 0.612,-1.288 0,-0.838 -0.524,-1.225 Q 31.077,6.246 29.95,6.246 Z" + id="path2-6" + style="display:none;fill:#00e1ff;fill-opacity:1" + transform="scale(0.26458333)" + inkscape:label="TER Text" /></g></svg> diff --git a/src/lib/assets/Pictogram.svelte b/src/lib/assets/Pictogram.svelte new file mode 100644 index 0000000..fe97de6 --- /dev/null +++ b/src/lib/assets/Pictogram.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { base } from '$app/paths'; + + let { which }: { which: string } = $props(); +</script> + +<img + src="{base}/pictograms/{which}.svg" + alt={which} + style="height: 5.2919998mm;width: 5.2919998mm;" +/> diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..a961354 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,51 @@ +// place files you want to import through the `$lib` alias in this folder. +export type ButtonColours = { + fg: string; + fgDisabled: string; + bg: string; + bgActive: string; + bgHover: string; +}; +export const S = { + card_bg: 'bg-[#ffffff]/20' as const, + shadow_large: 'shadow-[0_5px_25px_-3px_#00000077]' as const, + window: + 'outline-[1.5px] outline-[#313135] rounded-xl flex flex-row items-stretch justify-stretch max-h-full' as const, + window_sidepanel: 'bg-[#28282C] rounded-l-xl p-2 pt-0 min-w-48' as const, + window_content: 'bg-[#1D1D20] rounded-r-xl p-2 pt-0 flex-1' as const, + window_content_fullscreen: + 'bg-[#1D1D20] rounded-xl p-2 pt-0 flex-1 max-w-[100%]' as const, + window_topbar: + 'box-border min-h-[3.5rem] px-1 py-2 font-bold flex items-center justify-center gap-2 sticky top-0 bg-inherit z-50' as const, + button(colours: 'primary' | 'secondary' | ButtonColours) { + switch (colours) { + case 'primary': + colours = { + fg: '#dedede', + fgDisabled: '#2e3436', + bg: '#3584E4', + bgActive: '#2A6AB8', + bgHover: '#4990E7', + }; + break; + case 'secondary': + colours = { + fg: '#dedede', + fgDisabled: '#2e3436', + bg: '#0000', + bgActive: '#dedede22', + bgHover: '#dedede11', + }; + break; + } + colours = colours as ButtonColours; + return `transition-all disabled:grayscale disabled:text-[${colours.fgDisabled}] not-disabled:text-[${colours.fg}] not-disabled:bg-[${colours.bg}] not-disabled:hover:bg-[${colours.bgHover}] not-disabled:active:bg-[${colours.bgActive}] flex items-center justify-center p-2 rounded-xl outline-2 outline-[#0000] not-disabled:focus:outline-[#4C82C999]`; + }, + input: + 'transition-all bg-[#2E2E32] not-disabled:not-focus:not-active:hover:bg-[#dedede11] p-2 outline-2 outline-[#0000] not-disabled:focus:outline-[#4C82C999]' as const, + heading: [ + // + 'text-2xl font-bold', + 'text-lg', + ] as const, +}; diff --git a/src/lib/motis-api.ts b/src/lib/motis-api.ts new file mode 100644 index 0000000..1d43eea --- /dev/null +++ b/src/lib/motis-api.ts @@ -0,0 +1,57 @@ +import type { StoptimesResponse } from './motis-types'; +export class MotisAPI { + backend = 'https://api.transitous.org'; + fetch: typeof fetch = (url, init) => + fetch( + `${this.backend}${this.backend.endsWith('/') ? '' : '/'}${ + typeof url === 'string' + ? url.startsWith('/') + ? url.substring(1) + : url + : url instanceof URL + ? url.pathname + url.search + : url + }`, + init + ); + async getStopTimes( + id: string, + abortSignal?: AbortSignal, + arrivals = false, + limit = 128, + time: Date | undefined = arrivals + ? new Date( + // '2025-07-19T02:03:11Z' + Date.now() - 1000 * 60 + ) + : undefined + ) { + const res = await this.fetch( + `/api/v1/stoptimes?stopId=${encodeURIComponent( + id + )}&n=${encodeURIComponent(limit.toString())}&arriveBy=${ + arrivals ? 'true' : 'false' + }&withScheduledSkippedStops=${ + localStorage.getItem('with-scheduled-skipped-stops') ?? true + }&radius=${localStorage.getItem('radius') ?? 350}${ + time ? `&time=${time.toISOString()}` : '' + }`, + { + signal: abortSignal, + } + ); + if (res.status !== 200) + throw new Error( + `Stop Times: Expected 200 OK, got ${res.status} ${ + res.statusText + } (${await res + .text() + .catch((e) => `Could not get response body: ${e}`)})` + ); + const json = (await res.json()) as StoptimesResponse; + // TODO: validate + return json; + } +} +export const motis = new MotisAPI(); +export default motis; diff --git a/src/lib/motis-types.ts b/src/lib/motis-types.ts new file mode 100644 index 0000000..2f05229 --- /dev/null +++ b/src/lib/motis-types.ts @@ -0,0 +1,496 @@ +export type Area = { + /** Name of the area */ + name: string; + /** {@link https://wiki.openstreetmap.org/wiki/Key:admin_level OpenStreetMap `admin_level`} of the area */ + adminLevel: number; + /** Whether this area was matched by the input text */ + matched: boolean; + /** Set for the first area after the `default` area that distinguishes areas if the match is ambiguous regarding (`default` area + place name / street [+ house number]). */ + unique: boolean; + /** Whether this area should be displayed as default area (area with admin level closest 7) */ + default: boolean; +}; +export type Match = { + /** location type */ + type: 'ADDRESS' | 'PLACE' | 'STOP'; + /** list of non-overlapping tokens that were matched */ + tokens: number[][]; + /** name of the location (transit stop / PoI / address) */ + name: string; + /** unique id of the location */ + id: string; + /** latitude */ + lat: number; + /** longitude */ + lon: number; + /** level according to OpenStreetMap (at the moment only for public transport) */ + level?: number; + /** street name */ + street?: string; + /** house number */ + houseNumber?: string; + /** zip code */ + zip?: string; + /** list of areas */ + areas: Area[]; +}; +export enum Mode { + // street // + Walk = 'WALK', + Bike = 'BIKE', + /** Experimental. Expect unannounced breaking changes (without version bumps) for all parameters and returned structs. */ + Rental = 'RENTAL', + Car = 'CAR', + /** Experimental. Expect unannounced breaking changes (without version bumps) for all parameters and returned structs. */ + CarParking = 'CAR_PARKING', + /** Experimental. Expect unannounced breaking changes (without version bumps) for all parameters and returned structs. */ + CarDropoff = 'CAR_DROPOFF', + /** on-demand taxis from the Prima+ÖV Project */ + ODM = 'ODM', + /** flexible transports */ + Flex = 'FLEX', + + // transit // + + /** translates to `RAIL,TRAM,BUS,FERRY,AIRPLANE,COACH,CABLE_CAR,FUNICULAR,AREAL_LIFT,OTHER` */ + Transit = 'TRANSIT', + /** trams */ + Tram = 'TRAM', + /** subway trains */ + Subway = 'SUBWAY', + /** ferries */ + Ferry = 'FERRY', + /** airline flights */ + Airplane = 'AIRPLANE', + /** metro trains */ + Metro = 'METRO', + /** short distance buses (does not include `COACH`) */ + Bus = 'BUS', + /** long distance buses (does not include `BUS`) */ + Coach = 'COACH', + /** translates to `HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,REGIONAL_FAST_RAIL,METRO,SUBWAY` */ + Rail = 'RAIL', + /** long distance high speed trains (e.g. TGV, ICE) */ + HighspeedRail = 'HIGHSPEED_RAIL', + /** long distance inter city trains */ + LongDistanceRail = 'LONG_DISTANCE', + /** long distance night trains */ + NightRail = 'NIGHT_RAIL', + /** regional express routes that skip low traffic stops to be faster */ + RegionalFastRail = 'REGIONAL_FAST_RAIL', + /** regional train */ + RegionalRail = 'REGIONAL_RAIL', + /** Cable tram. Used for street-level rail cars where the cable runs beneath the vehicle (e.g., cable car in San Francisco). */ + CableTram = 'CABLE_CAR', + /** Funicular. Any rail system designed for steep inclines. */ + Funicular = 'FUNICULAR', + /** Aerial lift, suspended cable car (e.g., gondola lift, aerial tramway). Cable transport where cabins, cars, gondolas or open chairs are suspended by means of one or more cables. */ + AerialLift = 'AREAL_LIFT', + Other = 'OTHER', +} + +/** + * - `NORMAL` - latitude / longitude coordinate or address + * - `BIKESHARE` - bike sharing station + * - `TRANSIT` - transit stop + */ +export type VertexType = 'NORMAL' | 'BIKESHARE' | 'TRANSIT'; +/** + * - `NORMAL` - entry/exit is possible normally + * - `NOT_ALLOWED` - entry/exit is not allowed + */ +export type PickupDropoffType = 'NORMAL' | 'NOT_ALLOWED'; + +/** Cause of this alert. */ +export type AlertCause = + | 'UNKNOWN_CAUSE' + | 'OTHER_CAUSE' + | 'TECHNICAL_PROBLEM' + | 'STRIKE' + | 'DEMONSTRATION' + | 'ACCIDENT' + | 'HOLIDAY' + | 'WEATHER' + | 'MAINTENANCE' + | 'CONSTRUCTION' + | 'POLICE_ACTIVITY' + | 'MEDICAL_EMERGENCY'; +/** The effect of this problem on the affected entity. */ +export type AlertEffect = + | 'NO_SERVICE' + | 'REDUCED_SERVICE' + | 'SIGNIFICANT_DELAYS' + | 'DETOUR' + | 'ADDITIONAL_SERVICE' + | 'MODIFIED_SERVICE' + | 'OTHER_EFFECT' + | 'UNKNOWN_EFFECT' + | 'STOP_MOVED' + | 'NO_EFFECT' + | 'ACCESSIBILITY_ISSUE'; +/** The severity of the alert. */ +export type AlertSeverityLevel = + | 'UNKNOWN_SEVERITY' + | 'INFO' + | 'WARNING' + | 'SEVERE'; +/** + * A time interval. + * The interval is considered active at time t if t is greater than or equal to the start time and less than the end time. + */ +export type TimeRange = { + /** + * If missing, the interval starts at minus infinity. + * If a TimeRange is provided, either start or end must be provided - both fields cannot be empty. + */ + start?: string; + /** + * If missing, the interval ends at plus infinity. + * If a TimeRange is provided, either start or end must be provided - both fields cannot be empty. + */ + end?: string; +}; +/** + * An alert, indicating some sort of incident in the public transit network. + */ +export type Alert = { + /** + * Time when the alert should be shown to the user. + * If missing, the alert will be shown as long as it appears in the feed. + * If multiple ranges are given, the alert will be shown during all of them. + */ + communicationPeriod?: Array<TimeRange>; + + /** + * Time when the services are affected by the disruption mentioned in the alert. + */ + impactPeriod?: Array<TimeRange>; + + cause?: AlertCause; + + /** + * * Description of the cause of the alert that allows for agency-specific language; + * more specific than the Cause. + * + */ + causeDetail?: string; + + effect?: AlertEffect; + + /** + * * Description of the effect of the alert that allows for agency-specific language; + * more specific than the Effect. + * + */ + effectDetail?: string; + + /** + * * The URL which provides additional information about the alert. + */ + url?: string; + + /** + * * Header for the alert. This plain-text string will be highlighted, for example in boldface. + * + */ + headerText: string; + + /** + * * Description for the alert. + * This plain-text string will be formatted as the body of the alert (or shown on an explicit "expand" request by the user). + * The information in the description should add to the information of the header. + * + */ + descriptionText: string; + + /** + * * Text containing the alert's header to be used for text-to-speech implementations. + * This field is the text-to-speech version of header_text. + * It should contain the same information as headerText but formatted such that it can read as text-to-speech + * (for example, abbreviations removed, numbers spelled out, etc.) + * + */ + ttsHeaderText?: string; + + /** + * * Text containing a description for the alert to be used for text-to-speech implementations. + * This field is the text-to-speech version of description_text. + * It should contain the same information as description_text but formatted such that it can be read as text-to-speech + * (for example, abbreviations removed, numbers spelled out, etc.) + * + */ + ttsDescriptionText?: string; + + /** + * * Severity of the alert. + */ + severityLevel?: AlertSeverityLevel; + + /** + * * String containing an URL linking to an image. + */ + imageUrl?: string; + + /** + * * IANA media type as to specify the type of image to be displayed. The type must start with "image/" + * + */ + imageMediaType?: string; + + /** + * * Text describing the appearance of the linked image in the image field + * (e.g., in case the image can't be displayed or the user can't see the image for accessibility reasons). + * See the HTML spec for alt image text. + * + */ + imageAlternativeText?: string; +}; + +export type Place = { + /** + * name of the transit stop / PoI / address + */ + name: string; + + /** + * The ID of the stop. This is often something that users don't care about. + */ + stopId?: string; + + /** + * latitude + */ + lat: number; + + /** + * longitude + */ + lon: number; + + /** + * level according to OpenStreetMap + */ + level: number; + + /** + * arrival time + */ + arrival?: string; + + /** + * departure time + */ + departure?: string; + + /** + * scheduled arrival time + */ + scheduledArrival?: string; + + /** + * scheduled departure time + */ + scheduledDeparture?: string; + + /** + * scheduled track from the static schedule timetable dataset + */ + scheduledTrack?: string; + + /** + * The current track/platform information, updated with real-time updates if available. + * Can be missing if neither real-time updates nor the schedule timetable contains track information. + * + */ + track?: string; + + /** + * description of the location that provides more detailed information + */ + description?: string; + + vertexType?: VertexType; + + /** + * Type of pickup. It could be disallowed due to schedule, skipped stops or cancellations. + */ + pickupType?: PickupDropoffType; + + /** + * Type of dropoff. It could be disallowed due to schedule, skipped stops or cancellations. + */ + dropoffType?: PickupDropoffType; + + /** + * Whether this stop is cancelled due to the realtime situation. + */ + cancelled?: boolean; + + /** + * Alerts for this stop. + */ + alerts?: Array<Alert>; + + /** + * for `FLEX` transports, the flex location area or location group name + */ + flex?: string; + + /** + * for `FLEX` transports, the flex location area ID or location group ID + */ + flexId?: string; + + /** + * Time that on-demand service becomes available + */ + flexStartPickupDropOffWindow?: string; + + /** + * Time that on-demand service ends + */ + flexEndPickupDropOffWindow?: string; +}; +/** + * departure or arrival event at a stop + */ +export type StopTime = { + /** + * information about the stop place and time + */ + place: Place; + /** + * Transport mode for this leg + */ + mode: Mode; + /** + * Whether there is real-time data about this leg + */ + realTime: boolean; + /** + * For transit legs, the headsign of the bus or train being used. + * For non-transit legs, null + * + */ + headsign: string; + agencyId: string; + agencyName: string; + agencyUrl: string; + routeColor?: string; + routeTextColor?: string; + tripId: string; + routeShortName: string; + /** + * Type of pickup (for departures) or dropoff (for arrivals), may be disallowed either due to schedule, skipped stops or cancellations + */ + pickupDropoffType: PickupDropoffType; + /** + * Whether the departure/arrival is cancelled due to the realtime situation (either because the stop is skipped or because the entire trip is cancelled). + */ + cancelled: boolean; + /** + * Whether the entire trip is cancelled due to the realtime situation. + */ + tripCancelled: boolean; + /** + * Filename and line number where this trip is from + */ + source: string; +}; +export type StoptimesResponse = { + /** + * list of stop times + */ + stopTimes: Array<StopTime>; + /** + * metadata of the requested stop + */ + place: Place; + /** + * Use the cursor to get the previous page of results. Insert the cursor into the request and post it to get the previous page. + * The previous page is a set of stop times BEFORE the first stop time in the result. + * + */ + previousPageCursor: string; + /** + * Use the cursor to get the next page of results. Insert the cursor into the request and post it to get the next page. + * The next page is a set of stop times AFTER the last stop time in this result. + * + */ + nextPageCursor: string; +}; +// export type StoptimesData = { +// query: { +// /** +// * Optional. Default is `false`. +// * +// * - `arriveBy=true`: the parameters `date` and `time` refer to the arrival time +// * - `arriveBy=false`: the parameters `date` and `time` refer to the departure time +// * +// */ +// arriveBy?: boolean; +// /** +// * This parameter will be ignored in case `pageCursor` is set. +// * +// * Optional. Default is +// * - `LATER` for `arriveBy=false` +// * - `EARLIER` for `arriveBy=true` +// * +// * The response will contain the next `n` arrivals / departures +// * in case `EARLIER` is selected and the previous `n` +// * arrivals / departures if `LATER` is selected. +// * +// */ +// direction?: 'EARLIER' | 'LATER'; +// /** +// * Optional. Default is `false`. +// * +// * If set to `true`, only stations that are phyiscally in the radius are considered. +// * If set to `false`, additionally to the stations in the radius, equivalences with the same name and children are considered. +// * +// */ +// exactRadius?: boolean; +// /** +// * Optional. Default is all transit modes. +// * +// * Only return arrivals/departures of the given modes. +// * +// */ +// mode?: Array<Mode>; +// /** +// * the number of events +// */ +// n: number; +// /** +// * Use the cursor to go to the next "page" of stop times. +// * Copy the cursor from the last response and keep the original request as is. +// * This will enable you to search for stop times in the next or previous time-window. +// * +// */ +// pageCursor?: string; +// /** +// * Optional. Radius in meters. +// * +// * Default is that only stop times of the parent of the stop itself +// * and all stops with the same name (+ their child stops) are returned. +// * +// * If set, all stops at parent stations and their child stops in the specified radius +// * are returned. +// * +// */ +// radius?: number; +// /** +// * stop id of the stop to retrieve departures/arrivals for +// */ +// stopId: string; +// /** +// * Optional. Defaults to the current time. +// * +// */ +// time?: string; +// /** +// * Optional. Include stoptimes where passengers can not alight/board according to schedule. +// */ +// withScheduledSkippedStops?: boolean; +// }; +// }; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..f560b02 --- /dev/null +++ b/src/routes/(app)/+layout.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import { S } from '$lib'; + let { children } = $props(); +</script> + +<div + class="approot bg-[#101012] text-white w-screen min-h-screen font-sans p-2 md:p-8" +> + {@render children()} +</div> diff --git a/src/routes/(app)/+server.ts b/src/routes/(app)/+server.ts new file mode 100644 index 0000000..904739f --- /dev/null +++ b/src/routes/(app)/+server.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export const GET = () => { + throw redirect(307, '/fahrplan/'); +}; diff --git a/src/routes/(app)/fahrplan/+page.svelte b/src/routes/(app)/fahrplan/+page.svelte new file mode 100644 index 0000000..6d36513 --- /dev/null +++ b/src/routes/(app)/fahrplan/+page.svelte @@ -0,0 +1,524 @@ +<script lang="ts"> + import { S } from '$lib'; + import Page from '$lib/Page.svelte'; + import Titlebar from '$lib/Titlebar.svelte'; + import { onDestroy, onMount } from 'svelte'; + import type { Match, StoptimesResponse } from '$lib/motis-types'; + import { pushState } from '$app/navigation'; + import { base } from '$app/paths'; + import Timetable from '$lib/Timetable.svelte'; + import { page } from '$app/state'; + import { m } from '$lib/paraglide/messages'; + import motis from '$lib/motis-api'; + import { placeNameMap } from '$lib/aliases'; + + const normaliseGermanUmlauts = (n: string) => { + return n + .replace(/ü/gu, 'ue') + .replace(/Ü/gu, 'UE') + .replace(/ä/gu, 'ae') + .replace(/Ä/gu, 'AE') + .replace(/ö/gu, 'oe') + .replace(/ß/gu, 'ss'); + }; + const normalisePlaceName = (n: string) => + placeNameMap.has(n.toLowerCase()) ? placeNameMap.get(n.toLowerCase())! : n; + const arePlacenamesEqual = (n1: string, n2: string) => + normalisePlaceName( + normaliseGermanUmlauts(n1).toUpperCase() + ).toUpperCase() === + normalisePlaceName(normaliseGermanUmlauts(n2)).toUpperCase(); + + let searchQuery = $state(''); + let searchSuggestionIdx = $state(0); + let _lastSearchQuery = $state(''); + let _lastSearchQueryAt = $state(0); + let _searchQueryAbortController: AbortController | null = null; + let _searchResults = $state([] as Match[]); + let searchResults = $derived.by(() => { + const filtered = _searchResults + .filter( + (v, i, a) => + v.type === 'STOP' || + v.type === 'ADDRESS' || + a.findIndex((v2) => arePlacenamesEqual(v.name, v2.name)) === i + ) + .filter((v) => v); + filtered.sort((a, b) => { + const typeScores: Record<typeof a.type, number> = { + PLACE: 0, // POI + ADDRESS: 15, // Physical Address, Street + STOP: 30, // OV Stop + }; + const aScore = typeScores[a.type]; + const bScore = typeScores[b.type]; + return bScore - aScore; + }); + return filtered; + }); + let searchField: HTMLInputElement | null = null; + let searchFieldFocused = $state(false); + let objectId = $state(null as null | string); + let isArrivals = $state(false); + let objectIdName = $state(''); + + const handleNavigationTo = (newUrl: URL) => { + console.debug('Navigated to ', newUrl); + + const newObjIdName = newUrl.searchParams.get('placename'); + const newObjId = newUrl.searchParams.get('placeid'); + + if (newObjId === null || newObjId === '') { + if (newObjIdName === null || newObjIdName === '') { + searchQuery = ''; + objectIdName = ''; + objectId = null; + refetchResultsLoop(true); + } else { + searchQuery = newObjIdName; + objectIdName = newObjIdName; + objectId = null; + refetchResultsLoop(true); + search(newObjIdName); + + searchFieldFocused = true; + if (searchField) searchField.focus(); + } + } else { + if (newObjIdName !== objectIdName) + (objectIdName = newObjIdName ?? ''), (searchQuery = newObjIdName ?? ''); + if (newObjId !== objectId) { + objectId = newObjId; + refetchResultsLoop(true); + } + } + }; + $effect(() => { + void page.url; // listen to this + handleNavigationTo(new URL(location.href)); // and run this (page.url isnt always accurate for some reason) + }); + + let fetchDelay = $state(10000); + let now = $state(0); + let lastFetchAt = $state(0); + let nextFetchAt = $derived(lastFetchAt + fetchDelay); + let progressKind = $state('dead' as 'dead' | 'waiting' | 'fetch'); + let hqProgressIndicator = $state(false); + let stopTimes = $state(null as null | StoptimesResponse); + let hasQueriedResults = $state(false); + let renderedObjectIdName = $derived( + normalisePlaceName(objectIdName || (stopTimes?.place?.name ?? '')) + ); + + const search = async (query: string) => { + if (placeNameMap.has(query.toLowerCase())) + query = placeNameMap.get(query.toLowerCase()) ?? query; + if (query === '') { + _lastSearchQueryAt = performance.now(); + _searchQueryAbortController?.abort('Search Cancelled'); + _searchResults = []; + return; + } + console.debug('Querying', query); + await new Promise((rs) => setTimeout(rs, 50)); + for (let i = 0; i < 2; i++) + if (_lastSearchQueryAt + 150 > performance.now()) + await new Promise((rs) => + setTimeout(rs, _lastSearchQueryAt + 150 - performance.now()) + ); + else break; + if (query !== searchQuery) return; // typed again, new func call + if (query === _lastSearchQuery) return; // results are up-to-date, ignore + _searchQueryAbortController?.abort('Newer Search Started'); // we raced it + _searchQueryAbortController = new AbortController(); + _lastSearchQueryAt = performance.now(); + const response = await motis + .fetch( + `/api/v1/geocode?text=${encodeURIComponent(query)}${ + localStorage.getItem('geocode-query-options') ?? // e.g. &type=STOP + '&type=STOP' + }&language=${m.lang_short()}`, + { + signal: _searchQueryAbortController.signal, + } + ) + .catch((e) => { + _searchQueryAbortController = null; + throw e; + }); + if (response.status !== 200) + throw new Error( + `Got non-200 status code ${response.status} - ${await response.text().catch((e) => `Failed to get text: ${e}`)}` + ); + _lastSearchQueryAt = performance.now(); + _searchQueryAbortController = null; + if (searchQuery === query) { + // still nothing typed + _lastSearchQuery = query; + // FIXME: validate this + _searchResults = await response.json(); + searchSuggestionIdx = 0; + } + }; + $effect(() => { + if (searchQuery) + console.debug('searchQuery changed to', searchQuery, '- searching...'); + + search(searchQuery); + }); + + let debounce = false; + let stackLen = 0; + let destroyed = false; + const stackOverflowPreventionQueue = [] as (() => void)[]; + let resultsLoopCancel: AbortController | null = null; + const refetchResultsLoop = async (reset = false) => { + if (destroyed) return; + if (debounce && !reset) return; + if (reset) resultsLoopCancel?.abort(), (hasQueriedResults = false); + debounce = true; + await (async () => { + if (objectId === null) { + progressKind = 'dead'; + lastFetchAt = 0; + stopTimes = null; + } else { + if (lastFetchAt === 0 || reset) { + lastFetchAt = performance.now() - fetchDelay; + } + if (performance.now() >= nextFetchAt) { + progressKind = 'fetch'; + try { + resultsLoopCancel = new AbortController(); + const json = await motis.getStopTimes( + objectId, + resultsLoopCancel.signal, + isArrivals + ); + hasQueriedResults = true; + if (!json?.stopTimes) { + stopTimes = null; + throw new Error( + `No stopTimes on object (got response ${JSON.stringify(json)})` + ); + } + // json.stopTimes = json.stopTimes.map((v) => ({ + // ...v, + // cancelled: Math.random() > 0.5, + // })); + stopTimes = json; + lastFetchAt = performance.now(); + } catch (error) { + console.warn('Failed to update timetable: ', error); + await new Promise((rs) => setTimeout(rs, 5000)); + } + progressKind = 'waiting'; + } + // update now every 75ms for smoother animation + if (performance.now() > now + 75) now = performance.now(); + if (hqProgressIndicator) + requestAnimationFrame(() => { + if (stackLen > 1000) { + stackLen = 0; + stackOverflowPreventionQueue.push(() => { + refetchResultsLoop(false); + }); + } else { + refetchResultsLoop(false); + stackLen++; + } + }); + else + setTimeout(() => { + if (stackLen > 1000) { + stackLen = 0; + stackOverflowPreventionQueue.push(() => { + refetchResultsLoop(false); + }); + } else { + refetchResultsLoop(false); + stackLen++; + } + }, 10); + } + })().catch((e) => console.error(e)); + debounce = false; + }; + onMount( + () => ((globalThis as any)['refetchResultsLoop'] = refetchResultsLoop) + ); + $effect(() => { + if ( + stopTimes?.place?.name?.length && + searchQuery === '' && + searchFieldFocused === false + ) + searchQuery = stopTimes.place.name; + }); + let storageUpdateInterval: ReturnType<typeof setInterval> | undefined = + undefined; + let stackOverflowPreventionQueueInterval: + | ReturnType<typeof setInterval> + | undefined = undefined; + let mountedAt = 0; + onMount(() => { + destroyed = false; + const urlPlaceId = new URL(location.href).searchParams.get('placeid'); + if (urlPlaceId) objectId = urlPlaceId; + const urlSearchQuery = new URL(location.href).searchParams.get('placename'); + if (urlSearchQuery) { + objectIdName = urlSearchQuery; + searchQuery = urlSearchQuery; + } + + const storageUpdate = () => { + const oldFetchDelay = fetchDelay; + const lsFetchDelay = localStorage.getItem('fetch-delay-ms'); + fetchDelay = Math.floor( + parseFloat(lsFetchDelay ?? fetchDelay.toString()) + ); + if (isNaN(fetchDelay)) fetchDelay = oldFetchDelay; + if (fetchDelay !== oldFetchDelay || lsFetchDelay === null) + localStorage.setItem('fetch-delay-ms', fetchDelay.toString()); + + const newHqProgressIndicator = localStorage.getItem( + 'use-high-refresh-rate-progress-indicator' + ); + if ( + newHqProgressIndicator === 'false' || + newHqProgressIndicator === 'true' || + newHqProgressIndicator === '0' || + newHqProgressIndicator === '1' + ) + hqProgressIndicator = + newHqProgressIndicator === 'true' || newHqProgressIndicator === '1'; + else + localStorage.setItem( + 'use-high-refresh-rate-progress-indicator', + hqProgressIndicator.toString() + ); + + const newMotisBackend = localStorage.getItem('motis-backend'); + try { + if (!newMotisBackend) throw ''; + motis.backend = new URL(newMotisBackend).href; + } catch (error) { + localStorage.setItem('motis-backend', motis.backend.toString()); + } + }; + storageUpdateInterval = setInterval(storageUpdate, 30000); + storageUpdate(); + if (objectId) setTimeout(() => refetchResultsLoop(false), 10); + mountedAt = performance.now(); + isArrivals = location.search.includes('arrivals'); + + stackOverflowPreventionQueueInterval = setInterval(() => { + stackOverflowPreventionQueue.shift()?.(); + }, 100); + }); + onDestroy(() => clearInterval(storageUpdateInterval)); + onDestroy(() => (destroyed = true)); + const pushHistory = () => { + if ( + objectId && + objectIdName && + mountedAt && + performance.now() > mountedAt + 1000 + ) { + const newUrl = new URL(location.href); + newUrl.pathname = base + `/fahrplan`; + newUrl.searchParams.set('placename', objectIdName); + newUrl.searchParams.set('placeid', objectId); + if (newUrl.href !== location.href) { + console.debug('Pushing', newUrl, 'to history'); + pushState(newUrl, { + objectId, + objectIdName, + }); + } + } + }; +</script> + +<svelte:head> + <noscript> + <meta http-equiv="refresh" content="0; url=/fahrplan.html/{objectId}" /> + </noscript> +</svelte:head> + +<Page + title="{renderedObjectIdName ? `${renderedObjectIdName} - ` : ''}Fahrplan" + class="p-8" +> + <!-- {#snippet sidepanel()} + <div class="{S.window_topbar} rounded-tl-xl">Fahrplan</div> + <div class="my-2"></div> + {/snippet} --> + <div class={S.window_content_fullscreen}> + <Titlebar> + <div class="ctr relative flex-1 flex items-stretch justify-stretch"> + <input + type="text" + class="{S.input} rounded-xl w-full text-center font-medium" + bind:value={searchQuery} + bind:this={searchField} + onfocus={() => (searchFieldFocused = true)} + onblur={() => { + searchFieldFocused = false; + if (renderedObjectIdName) searchQuery = renderedObjectIdName; + }} + onkeydown={(e) => { + const resultIdx = Math.max( + Math.min(searchSuggestionIdx, searchResults.length - 1), + 0 + ); + let dir = 1; + switch (e.key) { + case 'Enter': + case 'Return': { + const result = searchResults[resultIdx]; + if (result) { + objectId = result.id; + objectIdName = result.name; + searchQuery = result.name; + e.currentTarget.blur(); + pushHistory(); + setTimeout(() => refetchResultsLoop(true), 10); + } + break; + } + case 'ArrowUp': + dir = -1; + // fall through + case 'ArrowDown': { + if (searchResults.length !== 0) { + e.preventDefault(); + searchSuggestionIdx = + dir === -1 && resultIdx % 1 !== 0 + ? Math.floor(resultIdx) + : Math.floor(resultIdx + dir); + if (searchSuggestionIdx < 0) + searchSuggestionIdx = searchResults.length - 1; + else if (searchSuggestionIdx >= searchResults.length) + searchSuggestionIdx = 0; + } + break; + } + } + }} + /> + <div + class="absolute top-[calc(100%_+_1rem)] left-0 w-full h-max flex items-center justify-center z-20" + > + {#if searchFieldFocused && searchResults.length !== 0} + <div + class="w-xl bg-[#27272BAA] backdrop-blur-3xl p-4 rounded-2xl flex flex-col gap-2" + > + {#each searchResults as result, idx} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- svelte-ignore a11y_click_events_have_key_events --> + <div + class={{ + 'w-full p-4 rounded-xl cursor-pointer': true, + 'bg-[#3b3b41]': idx === searchSuggestionIdx, + }} + onmouseenter={() => { + searchSuggestionIdx = idx; + }} + onmouseleave={() => { + searchSuggestionIdx = idx + 0.000001; + }} + onmousedown={(e) => { + e.preventDefault(); // prevent early unfocusing + }} + onclick={(e) => { + objectId = result.id; + objectIdName = result.name; + searchQuery = result.name; + pushHistory(); + if (searchField) { + setTimeout(() => { + searchField?.blur(); + }, 10); + searchField.value = result.name; + setTimeout(() => refetchResultsLoop(true), 10); + } + }} + > + {result.name} + <span class="opacity-50 font-normal ml-1" + >{[ + result.areas.find((v) => v.default)?.name, + result.type.charAt(0).toUpperCase() + + result.type.substring(1).toLowerCase(), + ] + .filter((v) => v) + .join(', ')}</span + > + </div> + {/each} + </div> + {/if} + </div> + </div> + </Titlebar> + <div class="w-full h-[2px] rounded-[2px] relative"> + {#if progressKind === 'fetch'} + <div + class="h-[2px] rounded-[2px] bg-white/50 w-full left-0 absolute" + style="animation:loadingbar infinite;animation-play-state:playing;animation-duration:2s;animation-timing-function:ease-in-out;" + ></div> + {:else if progressKind === 'waiting'} + <div + class="h-[2px] rounded-[2px] bg-white/20 transition-[all_100ms]" + style="width:{Math.min( + ((now - lastFetchAt) * 100) / fetchDelay, + 100 + )}%" + ></div> + {/if} + </div> + <div class="p-3"> + <div class="flex flex-col gap-2 max-w-[100%]"> + <Timetable + {stopTimes} + {isArrivals} + placeName={renderedObjectIdName} + placeId={objectId} + isResultsPage={objectId !== null && hasQueriedResults} + setSearch={(q) => { + searchQuery = q; + searchField?.focus(); + }} + /> + </div> + </div> + </div> +</Page> + +<!-- svelte-ignore a11y_consider_explicit_label --> +<button + class="fixed bottom-4 right-4 {S.button('secondary') + .replace('not-disabled:bg-[#0000]', 'not-disabled:bg-[#2E2E3299]') + .replace( + /rounded-[0-9a-z]+/, + 'rounded-full' + )} p-4 backdrop-blur-3xl flex sm:hidden" + onclick={() => { + searchField?.focus(); + }} +> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + fill="none" + viewBox="0 0 24 24" + ><path + fill="#fff" + fill-rule="evenodd" + d="M3 10.5a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0M9.5 3a7.5 7.5 0 1 0 5.022 13.07l6.151 5.308.654-.757-6.107-5.27A7.5 7.5 0 0 0 9.5 3" + clip-rule="evenodd" + ></path></svg + > +</button> diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..3153e95 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import '../app.css'; + + let { children } = $props(); +</script> + +{@render children()} diff --git a/src/routes/_lang/+page.svelte b/src/routes/_lang/+page.svelte new file mode 100644 index 0000000..619f97f --- /dev/null +++ b/src/routes/_lang/+page.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { setLocale } from '$lib/paraglide/runtime'; + import { page } from '$app/state'; + import { goto } from '$app/navigation'; + import { m } from '$lib/paraglide/messages.js'; + import Page from '$lib/Page.svelte'; +</script> + +<Page title="Lang"> + <h1>{m.locale_page_informational_message()}</h1> + <div> + <button onclick={() => setLocale('en-gb')}>en-gb</button> + <button onclick={() => setLocale('de-ch')}>de-ch</button> + </div> + + <small>this page is temporary</small> +</Page> diff --git a/src/routes/train-ico/[type]/[[line]]/+page.svelte b/src/routes/train-ico/[type]/[[line]]/+page.svelte new file mode 100644 index 0000000..915f57b --- /dev/null +++ b/src/routes/train-ico/[type]/[[line]]/+page.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { PageProps } from './$types'; + import LineGlyph from '$lib/assets/LineGlyph.svelte'; + + let { data }: PageProps = $props(); + let { params } = $derived(data); +</script> + +<LineGlyph + kind="{params.type}{params.line}" + type={params.type} + line={params.line} +/> diff --git a/src/routes/train-ico/[type]/[[line]]/+page.ts b/src/routes/train-ico/[type]/[[line]]/+page.ts new file mode 100644 index 0000000..3eeed27 --- /dev/null +++ b/src/routes/train-ico/[type]/[[line]]/+page.ts @@ -0,0 +1 @@ +export const load = ({ params }) => ({ params }); diff --git a/src/routes/train-ico/[type]/[[line]]/.svg/+server.ts b/src/routes/train-ico/[type]/[[line]]/.svg/+server.ts new file mode 100644 index 0000000..0120348 --- /dev/null +++ b/src/routes/train-ico/[type]/[[line]]/.svg/+server.ts @@ -0,0 +1,23 @@ +import LineGlyph from '$lib/assets/LineGlyph.svelte'; +import { render } from 'svelte/server'; +import { optimize } from 'svgo'; +export const GET = ({ params }) => { + return new Response( + optimize(`<?xml version="1.0" encoding="UTF-8" standalone="no"?> +${render(LineGlyph, { + props: { + kind: `${params.type}${params.line}`, + type: `${params.type}`, + line: params.line, + }, +}).body.replace( + '<svg', + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"' +)}`).data, + { + headers: { + 'Content-Type': 'image/svg+xml', + }, + } + ); +}; |