From 5f940e8196be10386832ae6df42d4dabb1bf4a03 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 00:11:50 +0000 Subject: [PATCH] trains: pokedex pages (builders, operators, countries, detail, random) and registrations --- src/lib/projects.ts | 8 + src/lib/seo.ts | 5 + src/lib/server/owner.test.ts | 2 + src/lib/server/owner.ts | 1 + src/lib/server/trains.ts | 377 +++++++++++++ src/lib/title.ts | 1 + src/lib/tracker-icons.ts | 13 + src/lib/trains/MakerTile.svelte | 75 +++ src/lib/trains/TrainCard.svelte | 212 ++++++++ src/lib/trains/shared.ts | 243 +++++++++ src/routes/sitemap.xml/+server.ts | 4 + src/routes/trackers/trains/+page.server.ts | 34 ++ src/routes/trackers/trains/+page.svelte | 352 ++++++++++++ .../trackers/trains/[maker]/+page.server.ts | 35 ++ .../trackers/trains/[maker]/+page.svelte | 151 ++++++ .../trains/[maker]/[type]/+page.server.ts | 50 ++ .../trains/[maker]/[type]/+page.svelte | 502 ++++++++++++++++++ .../trackers/trains/countries/+page.server.ts | 14 + .../trackers/trains/countries/+page.svelte | 117 ++++ .../countries/[country]/+page.server.ts | 17 + .../trains/countries/[country]/+page.svelte | 91 ++++ .../trackers/trains/makers/+page.server.ts | 20 + .../trackers/trains/makers/+page.svelte | 49 ++ .../trackers/trains/operators/+page.server.ts | 20 + .../trackers/trains/operators/+page.svelte | 49 ++ .../operators/[operator]/+page.server.ts | 31 ++ .../trains/operators/[operator]/+page.svelte | 148 ++++++ src/routes/trackers/trains/random/+server.ts | 9 + 28 files changed, 2630 insertions(+) create mode 100644 src/lib/server/trains.ts create mode 100644 src/lib/trains/MakerTile.svelte create mode 100644 src/lib/trains/TrainCard.svelte create mode 100644 src/lib/trains/shared.ts create mode 100644 src/routes/trackers/trains/+page.server.ts create mode 100644 src/routes/trackers/trains/+page.svelte create mode 100644 src/routes/trackers/trains/[maker]/+page.server.ts create mode 100644 src/routes/trackers/trains/[maker]/+page.svelte create mode 100644 src/routes/trackers/trains/[maker]/[type]/+page.server.ts create mode 100644 src/routes/trackers/trains/[maker]/[type]/+page.svelte create mode 100644 src/routes/trackers/trains/countries/+page.server.ts create mode 100644 src/routes/trackers/trains/countries/+page.svelte create mode 100644 src/routes/trackers/trains/countries/[country]/+page.server.ts create mode 100644 src/routes/trackers/trains/countries/[country]/+page.svelte create mode 100644 src/routes/trackers/trains/makers/+page.server.ts create mode 100644 src/routes/trackers/trains/makers/+page.svelte create mode 100644 src/routes/trackers/trains/operators/+page.server.ts create mode 100644 src/routes/trackers/trains/operators/+page.svelte create mode 100644 src/routes/trackers/trains/operators/[operator]/+page.server.ts create mode 100644 src/routes/trackers/trains/operators/[operator]/+page.svelte create mode 100644 src/routes/trackers/trains/random/+server.ts diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 444faa6..d161c2d 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -143,6 +143,14 @@ const allTrackers = [ external: false, private: false }, + { + name: 'trains', + subtitle: + 'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams', + href: '/trackers/trains', + external: false, + private: false + }, { name: 'wildlife', subtitle: diff --git a/src/lib/seo.ts b/src/lib/seo.ts index d89512e..6b315d7 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -54,6 +54,11 @@ const publicPages: Record = { description: 'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.' }, + '/trackers/trains': { + title: 'Train pokedex', + description: + 'Search thousands of train types from around the world and through history: steam, diesel and electric locomotives, high-speed trains, multiple units, metros and trams, with specs, operators and photos.' + }, '/trackers/wildlife': { title: 'Wildlife pokedex', description: diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts index cc714f0..5a69928 100644 --- a/src/lib/server/owner.test.ts +++ b/src/lib/server/owner.test.ts @@ -43,6 +43,8 @@ describe('owner gate', () => { expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false); expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true); expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false); + expect(isOwnerWritePath('/trackers/trains/siemens/virm')).toBe(true); + expect(isOwnerOnlyPath('/trackers/trains/siemens/virm')).toBe(false); expect(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true); expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false); expect(isOwnerWritePath('/trackers/pc')).toBe(true); diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts index 1b7e7f6..d819da2 100644 --- a/src/lib/server/owner.ts +++ b/src/lib/server/owner.ts @@ -50,6 +50,7 @@ export const ownerWritePrefixes = [ '/trackers/books', '/trackers/cars', '/trackers/planes', + '/trackers/trains', '/trackers/wildlife', '/trackers/music', '/trackers/pc' diff --git a/src/lib/server/trains.ts b/src/lib/server/trains.ts new file mode 100644 index 0000000..f6f5d62 --- /dev/null +++ b/src/lib/server/trains.ts @@ -0,0 +1,377 @@ +// Train pokedex queries (/trackers/trains). Public, read-only; data is written only by +// scripts/trains-import.ts and scripts/trains-photos.ts. +import { db } from '#lib/server/db/index.js'; +import { + trainMakers, + trainOperators, + trainPhotos, + trainTypeOperators, + trainTypes +} from '#lib/server/db/schema/index.js'; +import { and, arrayContains, asc, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm'; +import { + CATEGORIES, + ERAS, + SORTS, + TRACTIONS, + countrySlug, + type TrainCard, + type TrainEra, + type TrainSort +} from '#lib/trains/shared.js'; + +export const PAGE_SIZE = 60; + +export type TrainFilters = { + q: string; + maker: string; + category: string; + traction: string; + country: string; + era: TrainEra | ''; + service: '' | 'yes' | 'no'; + sort: TrainSort; + page: number; +}; + +const pick = (value: string | null, allowed: readonly T[]): T | '' => + allowed.includes(value as T) ? (value as T) : ''; + +export function parseFilters(params: URLSearchParams): TrainFilters { + const sort = params.get('sort'); + return { + q: (params.get('q') ?? '').trim().slice(0, 80), + maker: (params.get('maker') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 80), + category: pick(params.get('category'), CATEGORIES), + traction: pick(params.get('traction'), TRACTIONS), + country: (params.get('country') ?? '').trim().slice(0, 80), + era: pick(params.get('era'), Object.keys(ERAS) as TrainEra[]), + service: pick(params.get('service'), ['yes', 'no'] as const), + sort: sort && sort in SORTS ? (sort as TrainSort) : 'built', + page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000) + }; +} + +const cardColumns = { + dex: trainTypes.dex, + slug: trainTypes.slug, + name: trainTypes.name, + fullName: trainTypes.fullName, + makerSlug: trainMakers.slug, + makerName: trainMakers.name, + makerLogo: trainMakers.logo, + category: trainTypes.category, + traction: trainTypes.traction, + country: trainTypes.country, + firstBuilt: trainTypes.firstBuilt, + inService: trainTypes.inService, + status: trainTypes.status, + numberBuilt: trainTypes.numberBuilt, + maxSpeedKmh: trainTypes.maxSpeedKmh, + powerKw: trainTypes.powerKw, + photo: trainTypes.photo +}; + +const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); +const order = (sort: TrainSort) => + ({ + built: [sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.dex)], + dex: [asc(trainTypes.dex)], + name: [asc(trainTypes.fullName)], + newest: [sql`${trainTypes.firstBuilt} desc nulls last`, asc(trainTypes.dex)], + oldest: [sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.dex)], + fastest: [sql`${trainTypes.maxSpeedKmh} desc nulls last`, asc(trainTypes.dex)], + power: [sql`${trainTypes.powerKw} desc nulls last`, asc(trainTypes.dex)] + })[sort]; + +export async function searchTrains( + f: TrainFilters +): Promise<{ trains: TrainCard[]; total: number }> { + const where: SQL[] = []; + for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6)) { + // "virm" / "1700" / "ice3" also match "ice 3" via the compact name stored in search. + const compact = word.replace(/[^a-z0-9]/g, ''); + const like = ilike(trainTypes.search, `%${escapeLike(word)}%`); + where.push( + compact && compact !== word ? or(like, ilike(trainTypes.search, `%${compact}%`))! : like + ); + } + if (f.maker) where.push(eq(trainMakers.slug, f.maker)); + if (f.category) where.push(eq(trainTypes.category, f.category)); + if (f.traction) where.push(eq(trainTypes.traction, f.traction)); + if (f.country) where.push(arrayContains(trainTypes.countries, [f.country])); + if (f.era) { + const era = ERAS[f.era]; + if (era.from != null) where.push(gte(trainTypes.firstBuilt, era.from)); + if (era.to != null) where.push(lte(trainTypes.firstBuilt, era.to)); + } + if (f.service === 'yes') where.push(eq(trainTypes.inService, true)); + if (f.service === 'no') where.push(eq(trainTypes.inService, false)); + const condition = where.length ? and(...where) : undefined; + const [trains, [{ total }]] = await Promise.all([ + db + .select(cardColumns) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(condition) + .orderBy(...order(f.sort)) + .limit(PAGE_SIZE) + .offset((f.page - 1) * PAGE_SIZE), + db + .select({ total: sql`count(*)::int` }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(condition) + ]); + return { trains, total }; +} + +export type MakerTile = { + slug: string; + name: string; + types: number; + built: number; + country: string | null; + logo: string | null; +}; +export type OperatorTile = { + slug: string; + name: string; + types: number; + country: string | null; + logo: string | null; +}; +export type CountryTile = { slug: string; name: string; types: number; photo: string | null }; +type Overview = { + /** Alphabetical (for the filter); `byBuilt` has the same makers, most built first. */ + makers: MakerTile[]; + byBuilt: MakerTile[]; + operators: OperatorTile[]; + countries: CountryTile[]; + stats: { + makers: number; + operators: number; + countries: number; + types: number; + built: number; + photos: number; + }; +}; +let overviewCache: { at: number; value: Overview } | null = null; + +/** Maker/operator/country lists and headline counts; cached for 10 minutes. */ +export async function trainOverview(): Promise { + if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value; + const [makers, operators, countries, [counts]] = await Promise.all([ + db + .select({ + slug: trainMakers.slug, + name: trainMakers.name, + types: trainMakers.typeCount, + built: trainMakers.builtTotal, + country: trainMakers.country, + logo: trainMakers.logo + }) + .from(trainMakers) + .orderBy(asc(trainMakers.name)), + db + .select({ + slug: trainOperators.slug, + name: trainOperators.name, + types: trainOperators.typeCount, + country: trainOperators.country, + logo: trainOperators.logo + }) + .from(trainOperators) + .where(sql`${trainOperators.typeCount} > 0`) + .orderBy(desc(trainOperators.typeCount), asc(trainOperators.name)), + db.execute(sql` + select c as name, count(*)::int as types, + (array_agg(t.photo order by t.number_built desc nulls last) filter (where t.photo is not null))[1] as photo + from trains.types t, unnest(t.countries) c group by c order by types desc, c`), + db + .select({ + types: sql`count(*)::int`, + built: sql`coalesce(sum(${trainTypes.numberBuilt}) filter (where ${trainTypes.familyId} is null), 0)::bigint`, + photos: sql`coalesce(sum(${trainTypes.photoCount}), 0)::int` + }) + .from(trainTypes) + ]); + const countryRows = [...countries] as { name: string; types: number; photo: string | null }[]; + const value = { + makers, + byBuilt: [...makers].sort( + (a, b) => b.built - a.built || b.types - a.types || a.name.localeCompare(b.name) + ), + operators, + countries: countryRows.map((c) => ({ ...c, slug: countrySlug(c.name) })), + stats: { + makers: makers.length, + operators: operators.length, + countries: countryRows.length, + types: counts.types, + built: Number(counts.built), + photos: counts.photos + } + }; + if (value.stats.types) overviewCache = { at: Date.now(), value }; + return value; +} + +const heroFor = (where: SQL, photo: string) => + db + .select({ + file: trainPhotos.file, + author: trainPhotos.author, + license: trainPhotos.license, + licenseUrl: trainPhotos.licenseUrl, + type: trainTypes.name + }) + .from(trainPhotos) + .innerJoin(trainTypes, eq(trainTypes.id, trainPhotos.typeId)) + .where(and(where, eq(trainPhotos.thumb, photo))) + .limit(1); + +export async function getMaker(slug: string) { + const [maker] = await db.select().from(trainMakers).where(eq(trainMakers.slug, slug)).limit(1); + if (!maker) return null; + const types = await db + .select(cardColumns) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(eq(trainTypes.makerId, maker.id)) + .orderBy(sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.fullName)); + const [hero] = maker.photo ? await heroFor(eq(trainTypes.makerId, maker.id), maker.photo) : []; + return { maker, types, hero: hero ?? null }; +} + +export async function getOperator(slug: string) { + const [operator] = await db + .select() + .from(trainOperators) + .where(eq(trainOperators.slug, slug)) + .limit(1); + if (!operator) return null; + const types = await db + .select(cardColumns) + .from(trainTypeOperators) + .innerJoin(trainTypes, eq(trainTypes.id, trainTypeOperators.typeId)) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(eq(trainTypeOperators.operatorId, operator.id)) + .orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName)); + const [hero] = operator.photo + ? await heroFor( + sql`${trainTypes.id} in (select type_id from trains.type_operators where operator_id = ${operator.id})`, + operator.photo + ) + : []; + return { operator, types, hero: hero ?? null }; +} + +export async function getCountry(slug: string) { + const { countries } = await trainOverview(); + const country = countries.find((c) => c.slug === slug); + if (!country) return null; + const types = await db + .select(cardColumns) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(arrayContains(trainTypes.countries, [country.name])) + .orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName)) + .limit(1500); + return { country, types }; +} + +export async function getType(makerSlug: string, typeSlug: string) { + const [row] = await db + .select({ type: trainTypes, maker: trainMakers }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(and(eq(trainMakers.slug, makerSlug), eq(trainTypes.slug, typeSlug))) + .limit(1); + if (!row) return null; + const t = row.type; + const [neighbours, photos, family, operators] = await Promise.all([ + db + .select({ + dex: trainTypes.dex, + slug: trainTypes.slug, + name: trainTypes.fullName, + makerSlug: trainMakers.slug + }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where( + sql`${trainTypes.dex} in ( + (select max(dex) from trains.types where dex < ${t.dex}), + (select min(dex) from trains.types where dex > ${t.dex}))` + ), + db + .select({ + file: trainPhotos.file, + kind: trainPhotos.kind, + caption: trainPhotos.caption, + thumb: trainPhotos.thumb, + width: trainPhotos.width, + height: trainPhotos.height, + author: trainPhotos.author, + license: trainPhotos.license, + licenseUrl: trainPhotos.licenseUrl + }) + .from(trainPhotos) + .where(eq(trainPhotos.typeId, t.id)) + .orderBy(asc(trainPhotos.position)), + // Parent (if any) and direct variants. + db + .select({ ...cardColumns, id: trainTypes.id }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where( + t.familyId != null + ? or(eq(trainTypes.familyId, t.id), eq(trainTypes.id, t.familyId)) + : eq(trainTypes.familyId, t.id) + ) + .orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName)), + db + .select({ + slug: trainOperators.slug, + name: trainOperators.name, + logo: trainOperators.logo, + country: trainOperators.country + }) + .from(trainTypeOperators) + .innerJoin(trainOperators, eq(trainOperators.id, trainTypeOperators.operatorId)) + .where(eq(trainTypeOperators.typeId, t.id)) + .orderBy(asc(trainTypeOperators.position)) + ]); + const parent = family.find((f) => f.id === t.familyId) ?? null; + return { + ...row, + photos, + parent, + operators, + variants: family.filter((f) => f.id !== t.familyId), + prev: neighbours.find((n) => n.dex < t.dex) ?? null, + next: neighbours.find((n) => n.dex > t.dex) ?? null + }; +} + +/** A random type with a photo, favouring ones built in numbers. */ +export async function randomTrain() { + const [row] = await db + .select({ slug: trainTypes.slug, makerSlug: trainMakers.slug }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .where(sql`${trainTypes.photo} is not null and coalesce(${trainTypes.numberBuilt}, 0) >= 5`) + .orderBy(sql`random()`) + .limit(1); + if (row) return row; + // Before the photo pass has run: any type at all. + const [any] = await db + .select({ slug: trainTypes.slug, makerSlug: trainMakers.slug }) + .from(trainTypes) + .innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId)) + .orderBy(sql`random()`) + .limit(1); + return any ?? null; +} diff --git a/src/lib/title.ts b/src/lib/title.ts index f2b394c..38c39c0 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -21,6 +21,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str '/trackers/books': 'Books', '/trackers/cars': 'Cars', '/trackers/planes': 'Planes', + '/trackers/trains': 'Trains', '/trackers/wildlife': 'Wildlife', '/trackers/pc': 'PC setup', '/blog': 'Blog', diff --git a/src/lib/tracker-icons.ts b/src/lib/tracker-icons.ts index 79457e9..264720f 100644 --- a/src/lib/tracker-icons.ts +++ b/src/lib/tracker-icons.ts @@ -122,6 +122,19 @@ export const trackerIcons: Record = { 'M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z' ] }, + trains: { + from: '#fdba74', + to: '#c2410c', + ink: WHITE, + paths: [ + 'M8 3.1V7a4 4 0 0 0 8 0V3.1', + 'm9 15-1-1', + 'm15 15 1-1', + 'M9 19c-2.8 0-5-2.2-5-5v-4a8 8 0 0 1 16 0v4c0 2.8-2.2 5-5 5Z', + 'm8 19-2 3', + 'm16 19 2 3' + ] + }, wildlife: { from: '#86efac', to: '#15803d', diff --git a/src/lib/trains/MakerTile.svelte b/src/lib/trains/MakerTile.svelte new file mode 100644 index 0000000..b7b1992 --- /dev/null +++ b/src/lib/trains/MakerTile.svelte @@ -0,0 +1,75 @@ + + + + + {maker.name} + {#if !compact} + {maker.types} + {maker.types === 1 ? 'type' : 'types'}{#if maker.built} + · {fmtCompact(maker.built)} built{/if} + {/if} + + + diff --git a/src/lib/trains/TrainCard.svelte b/src/lib/trains/TrainCard.svelte new file mode 100644 index 0000000..4c57742 --- /dev/null +++ b/src/lib/trains/TrainCard.svelte @@ -0,0 +1,212 @@ + + + +
+ {#if train.photo} + {train.fullName} + {:else} + + {/if} + + {dexLabel(train.dex)} + {#if train.country && flag(train.country)}{flag(train.country)}{/if} +
+
+

{train.makerName}

+

{train.name}

+

+ {train.firstBuilt ? `built from ${train.firstBuilt}` : '—'}{#if train.inService === true} + · in service{:else if train.status === 'preserved'} + · preserved{:else if train.inService === false} + · retired{/if} +

+
+
+ {categoryEmoji[train.category] ?? ''} {categoryLabel[train.category] ?? train.category} + {#if showTraction && train.traction}{tractionLabel[train.traction] ?? train.traction}{/if} +
+
+
+
built
+
{train.numberBuilt ? fmtCompact(train.numberBuilt) : '—'}
+
+
+
top speed
+
{train.maxSpeedKmh ? `${train.maxSpeedKmh} km/h` : '—'}
+
+
+
power
+
{train.powerKw ? fmtKw(train.powerKw) : '—'}
+
+
+
+ + diff --git a/src/lib/trains/shared.ts b/src/lib/trains/shared.ts new file mode 100644 index 0000000..69d0e94 --- /dev/null +++ b/src/lib/trains/shared.ts @@ -0,0 +1,243 @@ +// Shared (client + server) labels and helpers for the train pokedex (/trackers/trains). +export { + dexLabel, + monogram, + brandHue, + fmtCompact, + fmtNumber, + thumbAt, + thumbSrcset, + commonsPage +} from '#lib/cars/shared.js'; +export type { CarPhoto as TrainPhoto } from '#lib/cars/shared.js'; + +export const CATEGORIES = [ + 'steam', + 'diesel', + 'electric', + 'high-speed', + 'multiple unit', + 'metro', + 'tram', + 'carriage', + 'other' +] as const; +export const TRACTIONS = [ + 'steam', + 'diesel', + 'electric', + 'bi-mode', + 'battery', + 'hydrogen', + 'gas turbine', + 'none' +] as const; +export const ERAS = { + pioneer: { label: 'pioneers (–1869)', from: null, to: 1869 }, + steam: { label: 'age of steam (1870–1919)', from: 1870, to: 1919 }, + interwar: { label: 'interwar (1920–1945)', from: 1920, to: 1945 }, + modern: { label: 'modernisation (1946–1979)', from: 1946, to: 1979 }, + late: { label: 'late 20th century (1980–1999)', from: 1980, to: 1999 }, + new: { label: '21st century (2000–)', from: 2000, to: null } +} as const; +export type TrainEra = keyof typeof ERAS; +export const SORTS = { + built: 'most built', + dex: 'dex #', + name: 'name', + newest: 'newest', + oldest: 'oldest', + fastest: 'fastest', + power: 'most powerful' +} as const; +export type TrainSort = keyof typeof SORTS; + +export const categoryLabel: Record = { + steam: 'steam locomotive', + diesel: 'diesel locomotive', + electric: 'electric locomotive', + 'high-speed': 'high-speed train', + 'multiple unit': 'multiple unit', + metro: 'metro', + tram: 'tram / light rail', + carriage: 'carriage / wagon', + other: 'other' +}; +export const categoryEmoji: Record = { + steam: '🚂', + diesel: '🛤️', + electric: '⚡', + 'high-speed': '🚄', + 'multiple unit': '🚆', + metro: '🚇', + tram: '🚋', + carriage: '🚃', + other: '🚉' +}; +/** One beginner-friendly line per category. */ +export const categoryHint: Record = { + steam: 'Burns coal, wood or oil to boil water; the steam drives pistons that turn the wheels.', + diesel: + 'A diesel engine on board drives the wheels, usually through a generator (diesel-electric).', + electric: 'Draws power from overhead wires or a third rail and pulls separate carriages.', + 'high-speed': 'Built for 200–350 km/h on dedicated lines, like the TGV, ICE or Shinkansen.', + 'multiple unit': + 'A self-propelled train: motors are spread under the passenger cars, so no separate locomotive.', + metro: 'Urban rapid transit: frequent, high-capacity trains, often underground.', + tram: 'Street-running or light-rail vehicles that share or skirt the road.', + carriage: 'Unpowered passenger coaches or freight wagons, pulled by a locomotive.', + other: 'Railcars, inspection vehicles and other rolling stock.' +}; +export const tractionLabel: Record = { + steam: 'steam', + diesel: 'diesel', + electric: 'electric', + 'bi-mode': 'bi-mode', + battery: 'battery', + hydrogen: 'hydrogen', + 'gas turbine': 'gas turbine', + none: 'unpowered' +}; +export const tractionHint: Record = { + steam: 'Steam engine: fire, boiler and pistons.', + diesel: 'Diesel engine on board, mechanical, hydraulic or electric transmission.', + electric: 'Electric motors fed from overhead wires or a third rail.', + 'bi-mode': 'Runs electric under wires and on its own diesel engine elsewhere.', + battery: 'Electric motors fed from on-board batteries (charged under wires or at stations).', + hydrogen: 'Hydrogen fuel cells make electricity on board.', + 'gas turbine': 'A gas turbine (jet-style engine) generates the power.', + none: 'No motor of its own: hauled by a locomotive.' +}; + +const FLAGS: Record = { + 'United States': '🇺🇸', + 'United Kingdom': '🇬🇧', + France: '🇫🇷', + Germany: '🇩🇪', + 'East Germany': '🇩🇪', + 'Soviet Union': '🇷🇺', + Russia: '🇷🇺', + Italy: '🇮🇹', + Japan: '🇯🇵', + Canada: '🇨🇦', + Brazil: '🇧🇷', + China: '🇨🇳', + Netherlands: '🇳🇱', + Belgium: '🇧🇪', + Luxembourg: '🇱🇺', + Sweden: '🇸🇪', + Spain: '🇪🇸', + Portugal: '🇵🇹', + Czechoslovakia: '🇨🇿', + 'Czech Republic': '🇨🇿', + Slovakia: '🇸🇰', + Poland: '🇵🇱', + Switzerland: '🇨🇭', + Austria: '🇦🇹', + 'Austria-Hungary': '🇦🇹', + Australia: '🇦🇺', + Ukraine: '🇺🇦', + India: '🇮🇳', + Pakistan: '🇵🇰', + Bangladesh: '🇧🇩', + 'Sri Lanka': '🇱🇰', + Israel: '🇮🇱', + Romania: '🇷🇴', + Bulgaria: '🇧🇬', + Greece: '🇬🇷', + Indonesia: '🇮🇩', + Malaysia: '🇲🇾', + Singapore: '🇸🇬', + Thailand: '🇹🇭', + Vietnam: '🇻🇳', + Philippines: '🇵🇭', + Argentina: '🇦🇷', + Chile: '🇨🇱', + Mexico: '🇲🇽', + 'South Korea': '🇰🇷', + 'North Korea': '🇰🇵', + Turkey: '🇹🇷', + 'New Zealand': '🇳🇿', + Finland: '🇫🇮', + Norway: '🇳🇴', + Denmark: '🇩🇰', + Ireland: '🇮🇪', + Slovenia: '🇸🇮', + Croatia: '🇭🇷', + Serbia: '🇷🇸', + Yugoslavia: '🇷🇸', + Hungary: '🇭🇺', + 'South Africa': '🇿🇦', + Egypt: '🇪🇬', + Kenya: '🇰🇪', + Nigeria: '🇳🇬', + Iran: '🇮🇷', + 'Saudi Arabia': '🇸🇦', + Taiwan: '🇹🇼', + 'Hong Kong': '🇭🇰', + Kazakhstan: '🇰🇿', + Belarus: '🇧🇾', + Latvia: '🇱🇻', + Lithuania: '🇱🇹', + Estonia: '🇪🇪', + Morocco: '🇲🇦', + Algeria: '🇩🇿', + Tunisia: '🇹🇳', + Peru: '🇵🇪', + Colombia: '🇨🇴', + Uruguay: '🇺🇾', + Cuba: '🇨🇺' +}; +export const flag = (country: string | null) => (country ? (FLAGS[country] ?? '') : ''); +export const countrySlug = (c: string) => + c + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + +export const fmtKw = (kw: number) => + kw >= 1000 + ? `${Math.round(kw / 100) / 10} MW` + : `${new Intl.NumberFormat('en-GB').format(kw)} kW`; +export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`; +export const fmtT = (t: number) => `${t >= 100 ? Math.round(t) : Math.round(t * 10) / 10} t`; + +export type TrainCard = { + dex: number; + slug: string; + name: string; + fullName: string; + makerSlug: string; + makerName: string; + makerLogo: string | null; + category: string; + traction: string | null; + country: string | null; + firstBuilt: number | null; + inService: boolean | null; + status: string | null; + numberBuilt: number | null; + maxSpeedKmh: number | null; + powerKw: number | null; + photo: string | null; +}; + +export type MakerTileData = { + slug: string; + name: string; + types: number; + built?: number; + logo: string | null; + href?: string; +}; + +export const statusLabel: Record = { + 'in service': 'in service', + preserved: 'preserved', + retired: 'retired' +}; + +export const LOGO_NOTE = + 'Logos come from Wikimedia Commons. Most are public domain as simple text or shapes, but they remain trademarks of their owners and are shown only to identify the maker or operator.'; diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 55833d4..98e77c7 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -18,6 +18,10 @@ const staticPaths = [ '/trackers/cars/brands', '/trackers/planes', '/trackers/planes/makers', + '/trackers/trains', + '/trackers/trains/makers', + '/trackers/trains/operators', + '/trackers/trains/countries', '/trackers/wildlife', '/trackers/wildlife/groups', '/trackers/music', diff --git a/src/routes/trackers/trains/+page.server.ts b/src/routes/trackers/trains/+page.server.ts new file mode 100644 index 0000000..89e0ea1 --- /dev/null +++ b/src/routes/trackers/trains/+page.server.ts @@ -0,0 +1,34 @@ +import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const filters = parseFilters(url.searchParams); + const [{ trains, total }, overview] = await Promise.all([searchTrains(filters), trainOverview()]); + return { + filters, + trains, + total, + pageSize: PAGE_SIZE, + makers: overview.makers + .filter((m) => m.types >= 2 || m.slug === filters.maker) + .map(({ slug, name, types }) => ({ slug, name, types })), + countries: overview.countries + .filter((c) => c.types >= 3 || c.name === filters.country) + .map(({ name, types }) => ({ name, types })), + topMakers: overview.byBuilt + .slice(0, 16) + .map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })), + topOperators: overview.operators.slice(0, 16).map(({ slug, name, types, logo }) => ({ + slug, + name, + types, + logo, + href: `/trackers/trains/operators/${slug}` + })), + stats: overview.stats, + seo: { + title: 'Train pokedex', + description: `Search ${overview.stats.types.toLocaleString('en-GB')} train types from ${overview.stats.makers.toLocaleString('en-GB')} builders: steam, diesel and electric locomotives, high-speed trains, multiple units, metros and trams, with specs and photos.` + } + }; +}; diff --git a/src/routes/trackers/trains/+page.svelte b/src/routes/trackers/trains/+page.svelte new file mode 100644 index 0000000..e45a89f --- /dev/null +++ b/src/routes/trackers/trains/+page.svelte @@ -0,0 +1,352 @@ + + +
+
+
+

trains

+

+ A train pokedex: steam, diesel and electric locomotives, high-speed trains, multiple units, + metros and trams from all over the world and every era, from Dutch Sprinters to the + Shinkansen. Tap one to see what it is, who built it and where it runs. +

+
+ random train +
+ +
+
+
train types
+
{fmtNumber(data.stats.types)}
+
+
+
builders
+
{fmtNumber(data.stats.makers)}
+
+
+
operators
+
{fmtNumber(data.stats.operators)}
+
+
+
countries
+
{fmtNumber(data.stats.countries)}
+
+
+
vehicles built
+
{fmtCompact(data.stats.built)}
+
+
+
photos
+
{fmtNumber(data.stats.photos)}
+
+
+ + {#if !filtering && f.page === 1} +
+ +
    + {#each data.topMakers as maker (maker.slug)} +
  • + {/each} +
+
+
+ +
    + {#each data.topOperators as maker (maker.slug)} +
  • + {/each} +
+
+ {/if} + +
+ +
+ + + {#each CATEGORIES as x (x)}{/each} + + + + {#each TRACTIONS as x (x)}{/each} + + + + {#each data.makers as m (m.slug)}{/each} + + + + {#each data.countries as c (c.name)}{/each} + + + + {#each Object.entries(ERAS) as [key, era] (key)}{/each} + + + + + + + + {#each Object.entries(SORTS) as [key, label] (key)}{/each} + + +
+
+ +
+ {#each quick as chip (chip.label)} + {chip.label} + {/each} + {#if filtering} + clear filters + {/if} +
+ +

+ {fmtNumber(data.total)} + {data.total === 1 ? 'type' : 'types'}{#if pages > 1} + · page {f.page} of {pages}{/if} +

+ + {#if data.trains.length} +
    + {#each data.trains as train, i (train.dex)} +
  • + {/each} +
+ {:else} +
+

No trains match that.

+

+ Try fewer words or clear the filters. +

+
+ {/if} + + {#if pages > 1} + + {/if} + +
+ Data: Wikidata (CC0) and the + English Wikipedia locomotive, train and + tram infoboxes (CC BY-SA 4.0). Classes and types only, no individual locomotives. Era is by + first built. Blank means unknown. Photos from Wikimedia Commons, credited on each train's page. + {LOGO_NOTE} +
+
+ + diff --git a/src/routes/trackers/trains/[maker]/+page.server.ts b/src/routes/trackers/trains/[maker]/+page.server.ts new file mode 100644 index 0000000..ecb9cff --- /dev/null +++ b/src/routes/trackers/trains/[maker]/+page.server.ts @@ -0,0 +1,35 @@ +import { error } from '@sveltejs/kit'; +import { getMaker } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const found = await getMaker(params.maker); + if (!found) error(404, 'No such builder in the train pokedex'); + const { maker, types, hero } = found; + return { + maker: { + slug: maker.slug, + name: maker.name, + fullName: maker.fullName, + country: maker.country, + typeCount: maker.typeCount, + builtTotal: maker.builtTotal, + firstYear: maker.firstYear, + lastYear: maker.lastYear, + wikidata: maker.wikidata, + wikipedia: maker.wikipedia, + logo: maker.logo, + logoFile: maker.logoFile, + logoAuthor: maker.logoAuthor, + logoLicense: maker.logoLicense, + photo: maker.photo, + sources: maker.sources + }, + hero, + types, + seo: { + title: `${maker.name} trains`, + description: `All ${maker.typeCount} ${maker.name} train types in Stan's train pokedex${maker.country ? ` (${maker.country})` : ''}, with specs and photos.` + } + }; +}; diff --git a/src/routes/trackers/trains/[maker]/+page.svelte b/src/routes/trackers/trains/[maker]/+page.svelte new file mode 100644 index 0000000..206045b --- /dev/null +++ b/src/routes/trackers/trains/[maker]/+page.svelte @@ -0,0 +1,151 @@ + + +
+ all trains + {#if b.photo} +
+ {b.name} {data.hero?.type ?? ''} + {#if data.hero} +
+ {b.name} + {data.hero.type} · + {data.hero.author ?? 'Wikimedia Commons'}{#if data.hero.license} + · {#if data.hero.licenseUrl}{data.hero.license}{:else}{data.hero.license}{/if}{/if} +
+ {/if} +
+ {/if} +
+ {#if b.logo} + + {:else} + + {/if} +
+

{b.name}

+ {#if b.fullName && b.fullName !== b.name}

+ {b.fullName} +

{/if} +

+ {#if b.country}{flag(b.country)} {b.country} · + {/if}{fmtNumber(b.typeCount)} + {b.typeCount === 1 ? 'type' : 'types'}{#if b.firstYear} + · built {b.firstYear}{#if b.lastYear && b.lastYear !== b.firstYear}–{b.lastYear}{/if}{/if}{#if b.builtTotal} + · {fmtCompact(b.builtTotal)} built{/if} +

+
+
+ +
    + {#each data.types as train, i (train.dex)} +
  • + {/each} +
+

+ Sorted by number built. Sources: {b.sources.join(', ')}.{#if b.wikipedia} + · {b.name} on Wikipedia{/if}{#if b.logoFile} + · Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo} + {LOGO_NOTE}{/if} + All builders. +

+
+ + diff --git a/src/routes/trackers/trains/[maker]/[type]/+page.server.ts b/src/routes/trackers/trains/[maker]/[type]/+page.server.ts new file mode 100644 index 0000000..de41841 --- /dev/null +++ b/src/routes/trackers/trains/[maker]/[type]/+page.server.ts @@ -0,0 +1,50 @@ +import { error } from '@sveltejs/kit'; +import { getType } from '#lib/server/trains.js'; +import type { trainTypes } from '#lib/server/db/schema/index.js'; +import type { PageServerLoad } from './$types'; + +type Type = typeof trainTypes.$inferSelect; + +// Drop internal ids and the search blob before the row goes to the browser. +function publicType(t: Type) { + const out: Partial = { ...t }; + delete out.id; + delete out.makerId; + delete out.familyId; + delete out.search; + delete out.createdAt; + delete out.updatedAt; + delete out.photosCheckedAt; + return { + ...(out as Omit< + Type, + 'id' | 'makerId' | 'familyId' | 'search' | 'createdAt' | 'updatedAt' | 'photosCheckedAt' + >), + updatedAt: t.updatedAt?.toISOString() ?? null + }; +} + +export const load: PageServerLoad = async ({ params }) => { + const found = await getType(params.maker, params.type); + if (!found) error(404, 'No such train in the pokedex'); + const { type, maker, photos, parent, variants, operators, prev, next } = found; + const strip = (row: T): Omit => { + const rest: Partial = { ...row }; + delete rest.id; + return rest as Omit; + }; + return { + train: publicType(type), + maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo }, + photos, + operators, + parent: parent ? strip(parent) : null, + variants: variants.map(strip), + prev, + next, + seo: { + title: type.fullName, + description: `${type.fullName}${type.firstBuilt ? ` (built from ${type.firstBuilt})` : ''}${type.description ? `, ${type.description}` : ''}: specs, operators and photos.` + } + }; +}; diff --git a/src/routes/trackers/trains/[maker]/[type]/+page.svelte b/src/routes/trackers/trains/[maker]/[type]/+page.svelte new file mode 100644 index 0000000..b2da06a --- /dev/null +++ b/src/routes/trackers/trains/[maker]/[type]/+page.svelte @@ -0,0 +1,502 @@ + + +
+ + +
+
+ +
+

+ {dexLabel(p.dex)}{#if p.nlName} + · {p.nlName}{/if} +

+

+ {#if p.fullName.startsWith(data.maker.name)}{data.maker.name} + {p.fullName.slice(data.maker.name.length).trim()}{:else}{p.fullName}{/if} +

+

+ {#if p.country}{flag(p.country)} {p.country} · + {/if}built by + {data.maker.name}{#if p.firstBuilt} + · from {p.firstBuilt}{/if}{#if p.description} + · {p.description}{/if} +

+
+
+
+ {categoryEmoji[p.category] ?? ''} {categoryLabel[p.category] ?? p.category} + {#if p.traction}{tractionLabel[p.traction] ?? p.traction}{/if} + {#if p.inService === true}✓ in service{:else if p.status === 'preserved'}preserved{:else if p.inService === false}retired{/if} +
+
+ + {#if data.photos.length} + + {/if} + + {#if p.summary} +
+

what is it?

+

{p.summary}

+
+ {/if} + + {#if data.operators.length} +
+

who runs it

+
+ {#each data.operators as o (o.slug)} + {o.name} + {/each} +
+
+ {/if} + + {#if baseStats.length} +
+

base stats

+
    + {#each baseStats as s (s.label)} +
  • + {s.label} + + {s.value}{s.hint} +
  • + {/each} +
+

+ Bars compare against all trains: 600 km/h (maglev), 1,700 seats; power, weight and number + built on a log scale up to 20 MW, 1,000 t and 20,000 built. +

+
+ {/if} + + {#if fun.length} +
+

fun facts

+
    + {#each fun as f (f.text)}
  • + {f.text} +
  • {/each} +
+

{categoryLabel[p.category]}: {categoryHint[p.category]}

+
+ {/if} + +
+

spec sheet

+
+ {#each facts as [label, value] (label)} +
+
{label}
+
{value || '—'}
+
+ {/each} +
+

From the Wikipedia infobox and Wikidata. Blank means the sources don't say.

+
+ + {#if data.parent || data.variants.length} +
+

family

+ +
+ {/if} + +

+ Sources: Wikidata {p.wikidata}{#if p.wikipedia}, Wikipedia: {p.wikipedia} (CC BY-SA 4.0){/if}.{#if p.updatedAt} + Updated {p.updatedAt.slice(0, 10)}.{/if} +

+
+ + diff --git a/src/routes/trackers/trains/countries/+page.server.ts b/src/routes/trackers/trains/countries/+page.server.ts new file mode 100644 index 0000000..f4a2571 --- /dev/null +++ b/src/routes/trackers/trains/countries/+page.server.ts @@ -0,0 +1,14 @@ +import { trainOverview } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const { countries, stats } = await trainOverview(); + return { + countries, + stats, + seo: { + title: 'Trains by country', + description: `Train types from ${stats.countries} countries in the train pokedex: where they were built and where they run.` + } + }; +}; diff --git a/src/routes/trackers/trains/countries/+page.svelte b/src/routes/trackers/trains/countries/+page.svelte new file mode 100644 index 0000000..b659afa --- /dev/null +++ b/src/routes/trackers/trains/countries/+page.svelte @@ -0,0 +1,117 @@ + + +
+ all trains +
+

countries

+

+ Train types from {fmtNumber(data.countries.length)} countries, counted where they were built and + where they ran. Tap one for its trains, oldest first. +

+
+ + +
+ + diff --git a/src/routes/trackers/trains/countries/[country]/+page.server.ts b/src/routes/trackers/trains/countries/[country]/+page.server.ts new file mode 100644 index 0000000..d9e0b30 --- /dev/null +++ b/src/routes/trackers/trains/countries/[country]/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { getCountry } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const found = await getCountry(params.country); + if (!found) error(404, 'No trains from that country in the train pokedex'); + const { country, types } = found; + return { + country, + types, + seo: { + title: `Trains of ${country.name}`, + description: `${country.types} train types built in or running in ${country.name}: locomotives, multiple units, high-speed trains, metros and trams, with specs and photos.` + } + }; +}; diff --git a/src/routes/trackers/trains/countries/[country]/+page.svelte b/src/routes/trackers/trains/countries/[country]/+page.svelte new file mode 100644 index 0000000..b530e02 --- /dev/null +++ b/src/routes/trackers/trains/countries/[country]/+page.svelte @@ -0,0 +1,91 @@ + + +
+ all countries +
+

+ {flag(data.country.name)} + {data.country.name} +

+

+ {fmtNumber(data.country.types)} train types built in or running in {data.country.name}, oldest + first. + Search and sort them. +

+ +
+ + {#each groups as g, gi (g.cat)} +
+

+ {categoryEmoji[g.cat]} + {categoryLabel[g.cat]} + {g.types.length} +

+
    + {#each g.types as train, i (train.dex)} +
  • + {/each} +
+
+ {/each} +
+ + diff --git a/src/routes/trackers/trains/makers/+page.server.ts b/src/routes/trackers/trains/makers/+page.server.ts new file mode 100644 index 0000000..0194802 --- /dev/null +++ b/src/routes/trackers/trains/makers/+page.server.ts @@ -0,0 +1,20 @@ +import { trainOverview } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const { byBuilt, stats } = await trainOverview(); + return { + makers: byBuilt.map(({ slug, name, types, built, logo }) => ({ + slug, + name, + types, + built, + logo + })), + stats, + seo: { + title: 'Train builders', + description: `All ${stats.makers} train builders in the train pokedex with their logos, most built first.` + } + }; +}; diff --git a/src/routes/trackers/trains/makers/+page.svelte b/src/routes/trackers/trains/makers/+page.svelte new file mode 100644 index 0000000..3be2d28 --- /dev/null +++ b/src/routes/trackers/trains/makers/+page.svelte @@ -0,0 +1,49 @@ + + +
+ all trains +
+

builders

+

+ All {fmtNumber(data.makers.length)} builders in the train pokedex (manufacturers and railway works), + the ones that built the most first. Tap one for its trains. +

+
+ +
    + {#each data.makers as maker (maker.slug)} +
  • + {/each} +
+ +

+ {withLogo} of {data.makers.length} builders have a logo; the rest get a coloured monogram. + {LOGO_NOTE} Each builder page credits its logo's author and licence. +

+
+ + diff --git a/src/routes/trackers/trains/operators/+page.server.ts b/src/routes/trackers/trains/operators/+page.server.ts new file mode 100644 index 0000000..716f90d --- /dev/null +++ b/src/routes/trackers/trains/operators/+page.server.ts @@ -0,0 +1,20 @@ +import { trainOverview } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const { operators, stats } = await trainOverview(); + return { + makers: operators.map(({ slug, name, types, logo }) => ({ + slug, + name, + types, + logo, + href: `/trackers/trains/operators/${slug}` + })), + stats, + seo: { + title: 'Train operators', + description: `All ${stats.operators} railway operators in the train pokedex with their logos, the ones with the most train types first.` + } + }; +}; diff --git a/src/routes/trackers/trains/operators/+page.svelte b/src/routes/trackers/trains/operators/+page.svelte new file mode 100644 index 0000000..8289e57 --- /dev/null +++ b/src/routes/trackers/trains/operators/+page.svelte @@ -0,0 +1,49 @@ + + +
+ all trains +
+

operators

+

+ All {fmtNumber(data.makers.length)} railway companies, transit agencies and other operators in the + train pokedex, the ones running the most types first. Tap one for its fleet history. +

+
+ +
    + {#each data.makers as maker (maker.slug)} +
  • + {/each} +
+ +

+ {withLogo} of {data.makers.length} operators have a logo; the rest get a coloured monogram. + {LOGO_NOTE} Each operator page credits its logo's author and licence. +

+
+ + diff --git a/src/routes/trackers/trains/operators/[operator]/+page.server.ts b/src/routes/trackers/trains/operators/[operator]/+page.server.ts new file mode 100644 index 0000000..cc39927 --- /dev/null +++ b/src/routes/trackers/trains/operators/[operator]/+page.server.ts @@ -0,0 +1,31 @@ +import { error } from '@sveltejs/kit'; +import { getOperator } from '#lib/server/trains.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const found = await getOperator(params.operator); + if (!found) error(404, 'No such operator in the train pokedex'); + const { operator, types, hero } = found; + return { + operator: { + slug: operator.slug, + name: operator.name, + fullName: operator.fullName, + country: operator.country, + typeCount: types.length, + wikidata: operator.wikidata, + wikipedia: operator.wikipedia, + logo: operator.logo, + logoFile: operator.logoFile, + logoAuthor: operator.logoAuthor, + logoLicense: operator.logoLicense, + photo: operator.photo + }, + hero, + types, + seo: { + title: `${operator.name} trains`, + description: `The ${types.length} train types run by ${operator.name}${operator.country ? ` (${operator.country})` : ''} in Stan's train pokedex, with specs and photos.` + } + }; +}; diff --git a/src/routes/trackers/trains/operators/[operator]/+page.svelte b/src/routes/trackers/trains/operators/[operator]/+page.svelte new file mode 100644 index 0000000..7c708af --- /dev/null +++ b/src/routes/trackers/trains/operators/[operator]/+page.svelte @@ -0,0 +1,148 @@ + + +
+ all trains + {#if b.photo} +
+ {b.name} {data.hero?.type ?? ''} + {#if data.hero} +
+ {b.name} + {data.hero.type} · + {data.hero.author ?? 'Wikimedia Commons'}{#if data.hero.license} + · {#if data.hero.licenseUrl}{data.hero.license}{:else}{data.hero.license}{/if}{/if} +
+ {/if} +
+ {/if} +
+ {#if b.logo} + + {:else} + + {/if} +
+

{b.name}

+ {#if b.fullName && b.fullName !== b.name}

+ {b.fullName} +

{/if} +

+ {#if b.country}{flag(b.country)} {b.country} · + {/if}{fmtNumber(b.typeCount)} + {b.typeCount === 1 ? 'type' : 'types'} +

+
+
+ +
    + {#each data.types as train, i (train.dex)} +
  • + {/each} +
+

+ Oldest first. Sources: Wikipedia, Wikidata.{#if b.wikipedia} + · {b.name} on Wikipedia{/if}{#if b.logoFile} + · Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo} + {LOGO_NOTE}{/if} + All operators. +

+
+ + diff --git a/src/routes/trackers/trains/random/+server.ts b/src/routes/trackers/trains/random/+server.ts new file mode 100644 index 0000000..d498dc0 --- /dev/null +++ b/src/routes/trackers/trains/random/+server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import { randomTrain } from '#lib/server/trains.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ setHeaders }) => { + const train = await randomTrain(); + setHeaders({ 'cache-control': 'no-store' }); + redirect(303, train ? `/trackers/trains/${train.makerSlug}/${train.slug}` : '/trackers/trains'); +};