trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s

This commit is contained in:
2026-10-25 00:11:50 +00:00
parent 8c0fc74283
commit 5f940e8196
28 changed files with 2630 additions and 0 deletions

View File

@@ -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:

View File

@@ -54,6 +54,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
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:

View File

@@ -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);

View File

@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
'/trackers/books',
'/trackers/cars',
'/trackers/planes',
'/trackers/trains',
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc'

377
src/lib/server/trains.ts Normal file
View File

@@ -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 = <T extends string>(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<number>`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<Overview> {
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<number>`count(*)::int`,
built: sql<number>`coalesce(sum(${trainTypes.numberBuilt}) filter (where ${trainTypes.familyId} is null), 0)::bigint`,
photos: sql<number>`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;
}

View File

@@ -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',

View File

@@ -122,6 +122,19 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'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',

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import { fmtCompact, type MakerTileData } from './shared.js';
let { maker, compact = false }: { maker: MakerTileData; compact?: boolean } = $props();
</script>
<a
href={maker.href ?? `/trackers/trains/${maker.slug}`}
class={['tile', { compact }]}
title={maker.name}
>
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
<span class="name">{maker.name}</span>
{#if !compact}
<span class="meta"
>{maker.types}
{maker.types === 1 ? 'type' : 'types'}{#if maker.built}
· {fmtCompact(maker.built)} built{/if}</span
>
{/if}
</a>
<style>
.tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
height: 100%;
padding: 0.7rem 0.6rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
text-align: center;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.tile:hover,
.tile:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.mark {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 16 / 9;
}
.compact .mark {
aspect-ratio: 2 / 1;
}
.name {
max-width: 100%;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .name {
font-size: 0.75rem;
font-weight: 500;
color: var(--site-muted);
}
.meta {
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import {
brandHue,
categoryEmoji,
categoryLabel,
dexLabel,
flag,
fmtCompact,
fmtKw,
monogram,
thumbAt,
thumbSrcset,
tractionLabel,
type TrainCard
} from './shared.js';
let { train, eager = false }: { train: TrainCard; eager?: boolean } = $props();
const href = $derived(`/trackers/trains/${train.makerSlug}/${train.slug}`);
const showTraction = $derived(
train.traction &&
!['steam', 'diesel', 'electric'].includes(train.category) &&
train.traction !== 'none'
);
</script>
<a {href} class="card">
<div class="media" style:--hue={brandHue(train.makerName)}>
{#if train.photo}
<img
src={thumbAt(train.photo, 330)}
srcset={thumbSrcset(train.photo)}
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
alt={train.fullName}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
width="330"
height="206"
/>
{:else}
<span class="placeholder" aria-hidden="true">{monogram(train.makerName)}</span>
{/if}
<span class="badge"><BrandBadge name={train.makerName} logo={train.makerLogo} size="sm" /></span
>
<span class="dex">{dexLabel(train.dex)}</span>
{#if train.country && flag(train.country)}<span class="icao" title={train.country}
>{flag(train.country)}</span
>{/if}
</div>
<div class="min-w-0">
<p class="brand">{train.makerName}</p>
<h3 class="name">{train.name}</h3>
<p class="meta">
{train.firstBuilt ? `built from ${train.firstBuilt}` : '—'}{#if train.inService === true}
· <span class="flying">in service</span>{:else if train.status === 'preserved'}
· preserved{:else if train.inService === false}
· retired{/if}
</p>
</div>
<div class="chips">
<span class="chip"
>{categoryEmoji[train.category] ?? ''} {categoryLabel[train.category] ?? train.category}</span
>
{#if showTraction && train.traction}<span class="chip"
>{tractionLabel[train.traction] ?? train.traction}</span
>{/if}
</div>
<dl class="nums">
<div title="Number built (Wikipedia / Wikidata)">
<dt>built</dt>
<dd>{train.numberBuilt ? fmtCompact(train.numberBuilt) : '—'}</dd>
</div>
<div title="Maximum speed">
<dt>top speed</dt>
<dd>{train.maxSpeedKmh ? `${train.maxSpeedKmh} km/h` : '—'}</dd>
</div>
<div title="Power output">
<dt>power</dt>
<dd>{train.powerKw ? fmtKw(train.powerKw) : '—'}</dd>
</div>
</dl>
</a>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.6rem;
height: 100%;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 0.9rem;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.card:hover,
.card:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.media {
position: relative;
margin: -0.9rem -0.9rem 0.1rem;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 0.85rem 0.85rem 0 0;
background: radial-gradient(circle at 30% 20%, hsl(var(--hue) 35% 22%), hsl(var(--hue) 30% 9%));
}
.media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.card:hover .media img {
transform: scale(1.04);
}
.placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3rem;
font-weight: 800;
letter-spacing: 0.05em;
color: hsl(var(--hue) 50% 60% / 0.25);
}
.badge {
position: absolute;
left: 0.6rem;
bottom: 0.6rem;
}
.dex,
.icao {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.72rem;
color: #fff;
background: rgb(0 0 0 / 0.55);
border-radius: 999px;
padding: 0.05rem 0.45rem;
backdrop-filter: blur(4px);
}
.icao {
top: auto;
bottom: 0.6rem;
font-family: inherit;
font-size: 0.85rem;
}
.brand {
font-size: 0.75rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.meta {
margin-top: 0.1rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.flying {
color: #4ade80;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
min-height: 1.3rem;
}
.chip {
font-size: 0.7rem;
line-height: 1;
padding: 0.28rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-text);
white-space: nowrap;
}
.nums {
margin-top: auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.6rem;
}
.nums dt {
font-size: 0.62rem;
color: var(--site-muted);
white-space: nowrap;
}
.nums dd {
font-size: 0.82rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>

243
src/lib/trains/shared.ts Normal file
View File

@@ -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<string, string> = {
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<string, string> = {
steam: '🚂',
diesel: '🛤️',
electric: '⚡',
'high-speed': '🚄',
'multiple unit': '🚆',
metro: '🚇',
tram: '🚋',
carriage: '🚃',
other: '🚉'
};
/** One beginner-friendly line per category. */
export const categoryHint: Record<string, string> = {
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<string, string> = {
steam: 'steam',
diesel: 'diesel',
electric: 'electric',
'bi-mode': 'bi-mode',
battery: 'battery',
hydrogen: 'hydrogen',
'gas turbine': 'gas turbine',
none: 'unpowered'
};
export const tractionHint: Record<string, string> = {
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<string, string> = {
'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<string, string> = {
'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.';