From a723e56b4ce392d2b11d28f2745279aa825a2ee1 Mon Sep 17 00:00:00 2001 From: memdmp Date: Mon, 21 Jul 2025 22:53:11 +0200 Subject: feat: initial commit --- src/app.css | 24 + src/app.d.ts | 13 + src/app.html | 13 + src/hooks.server.ts | 12 + src/hooks.ts | 3 + src/lib/Page.svelte | 32 ++ src/lib/Timetable.svelte | 424 +++++++++++++++++ src/lib/Titlebar.svelte | 55 +++ src/lib/aliases.ts | 36 ++ src/lib/assets/LineGlyph.svelte | 193 ++++++++ src/lib/assets/LineGlyphSrc.svg | 233 +++++++++ src/lib/assets/Pictogram.svelte | 11 + src/lib/index.ts | 51 ++ src/lib/motis-api.ts | 57 +++ src/lib/motis-types.ts | 496 +++++++++++++++++++ src/routes/(app)/+layout.svelte | 10 + src/routes/(app)/+server.ts | 5 + src/routes/(app)/fahrplan/+page.svelte | 524 +++++++++++++++++++++ src/routes/+layout.svelte | 7 + src/routes/_lang/+page.svelte | 17 + src/routes/train-ico/[type]/[[line]]/+page.svelte | 13 + src/routes/train-ico/[type]/[[line]]/+page.ts | 1 + .../train-ico/[type]/[[line]]/.svg/+server.ts | 23 + 23 files changed, 2253 insertions(+) create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/hooks.server.ts create mode 100644 src/hooks.ts create mode 100644 src/lib/Page.svelte create mode 100644 src/lib/Timetable.svelte create mode 100644 src/lib/Titlebar.svelte create mode 100644 src/lib/aliases.ts create mode 100644 src/lib/assets/LineGlyph.svelte create mode 100644 src/lib/assets/LineGlyphSrc.svg create mode 100644 src/lib/assets/Pictogram.svelte create mode 100644 src/lib/index.ts create mode 100644 src/lib/motis-api.ts create mode 100644 src/lib/motis-types.ts create mode 100644 src/routes/(app)/+layout.svelte create mode 100644 src/routes/(app)/+server.ts create mode 100644 src/routes/(app)/fahrplan/+page.svelte create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/_lang/+page.svelte create mode 100644 src/routes/train-ico/[type]/[[line]]/+page.svelte create mode 100644 src/routes/train-ico/[type]/[[line]]/+page.ts create mode 100644 src/routes/train-ico/[type]/[[line]]/.svg/+server.ts (limited to 'src') 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 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + 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 @@ + + + + {title} + + + +
+ {#if sidepanel} +
+ {@render sidepanel()} +
+ {/if} + {@render children()} +
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 @@ + + +{#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'))} +
+
+
+ {#if 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} + + {:else} + + {routeShortName} + + {/if} + + + {m.to()} + {departure.headsign} + +
+
+ {#if departure.place.scheduledTrack && departure.place.track} + + {#if departure.place.name !== placeName}{`${ + departure.place.name === placeName + ', Bahnhof' + ? placeName + ', Busbahnhof' + : departure.place.name + }, `} + {/if}{m.station_location({ + track: departure.place.track, + mode: departure.mode, + })} + + {:else if departure.place.name !== placeName}{departure.place.name === + placeName + ', Bahnhof' + ? placeName + ', Busbahnhof' + : departure.place.name} + {/if} +
+
+ {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} + + {@render renderLocalTime(new Date(receivedTime * 60 * 1000))} + + {:else} + + {@render renderLocalTime(new Date(expectedTime * 60 * 1000), true)} + + + {@render renderLocalTime(new Date(receivedTime * 60 * 1000), true)} + + {#if isRelativeTime} + + ({@render renderLocalTime(new Date(receivedTime * 60 * 1000))}) + + {/if} + {/if} +
+ {#if notices.length !== 0} +
+ {#each notices as notice} +
+ {#each notice[0] as pictogram}{/each} + + {notice[1]} + +
+ {/each} +
+ {/if} + + {#if departure.agencyName} + {m.operated_by({ + operator: operators.has(departure.agencyName) + ? operators.get(departure.agencyName)! + : departure.agencyName, + })}{#if departure.agencyName === 'DB Fernverkehr AG'} + {' '} + · + {m.line_number_accuracy()}{/if} + {/if} +
+ {/each} +{:else} +
+
+ {#if (placeName || placeId) && isResultsPage} +

No results

+

+ No results have been found for the station {placeName ?? placeId}.
+ Please try again. +

+ {:else if placeId} +

No results

+

+ No results have been found for the station {placeName ?? placeId}.
+ Please try again. +

+ {:else} +

No Station

+

+ Please input a station in the search field above and select a search + result. +

+

+ Examples: + + + + + + +

+ {/if} +
+
+{/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 @@ + + +
+ +
+ {@render children?.()} +
+
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(); +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(); +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 @@ + + + + + + + + + + {#if isFernverkehr} + + + + {#if lineType && lineType.toUpperCase() in fernverkehrIconMap} + + {/if} + + {#if line !== undefined && line !== ''} + {line} + {/if} + + {:else} + + A + + {routeShortName} + + {/if} + 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 @@ + + + + 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 @@ + + +{which} 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; + + /** + * Time when the services are affected by the disruption mentioned in the alert. + */ + impactPeriod?: Array; + + 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; + + /** + * 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; + /** + * 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; +// /** +// * 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 @@ + + +
+ {@render children()} +
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 @@ + + + + + + + + +
+ +
+ (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; + } + } + }} + /> +
+ {#if searchFieldFocused && searchResults.length !== 0} +
+ {#each searchResults as result, idx} + + +
{ + 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} + {[ + result.areas.find((v) => v.default)?.name, + result.type.charAt(0).toUpperCase() + + result.type.substring(1).toLowerCase(), + ] + .filter((v) => v) + .join(', ')} +
+ {/each} +
+ {/if} +
+
+
+
+ {#if progressKind === 'fetch'} +
+ {:else if progressKind === 'waiting'} +
+ {/if} +
+
+
+ { + searchQuery = q; + searchField?.focus(); + }} + /> +
+
+
+
+ + + 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 @@ + + +{@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 @@ + + + +

{m.locale_page_informational_message()}

+
+ + +
+ + this page is temporary +
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 @@ + + + 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(` +${render(LineGlyph, { + props: { + kind: `${params.type}${params.line}`, + type: `${params.type}`, + line: params.line, + }, +}).body.replace( + '