aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/(app)/+layout.svelte10
-rw-r--r--src/routes/(app)/+server.ts5
-rw-r--r--src/routes/(app)/fahrplan/+page.svelte524
-rw-r--r--src/routes/+layout.svelte7
-rw-r--r--src/routes/_lang/+page.svelte17
-rw-r--r--src/routes/train-ico/[type]/[[line]]/+page.svelte13
-rw-r--r--src/routes/train-ico/[type]/[[line]]/+page.ts1
-rw-r--r--src/routes/train-ico/[type]/[[line]]/.svg/+server.ts23
8 files changed, 600 insertions, 0 deletions
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',
+ },
+ }
+ );
+};