aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
authorLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-21 22:53:11 +0200
committerLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-21 22:53:11 +0200
commita723e56b4ce392d2b11d28f2745279aa825a2ee1 (patch)
treedc69ea161c9b52e84c3969fbb9562b780f74586b /src/lib
downloadfahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.gz
fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.bz2
fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.tar.lz
fahrplan-a723e56b4ce392d2b11d28f2745279aa825a2ee1.zip

feat: initial commit

Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/Page.svelte32
-rw-r--r--src/lib/Timetable.svelte424
-rw-r--r--src/lib/Titlebar.svelte55
-rw-r--r--src/lib/aliases.ts36
-rw-r--r--src/lib/assets/LineGlyph.svelte193
-rw-r--r--src/lib/assets/LineGlyphSrc.svg233
-rw-r--r--src/lib/assets/Pictogram.svelte11
-rw-r--r--src/lib/index.ts51
-rw-r--r--src/lib/motis-api.ts57
-rw-r--r--src/lib/motis-types.ts496
10 files changed, 1588 insertions, 0 deletions
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