diff options
Diffstat (limited to 'src/routes')
-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 |
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', + }, + } + ); +}; |