trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
This commit is contained in:
@@ -143,6 +143,14 @@ const allTrackers = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: 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',
|
name: 'wildlife',
|
||||||
subtitle:
|
subtitle:
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.'
|
'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': {
|
'/trackers/wildlife': {
|
||||||
title: 'Wildlife pokedex',
|
title: 'Wildlife pokedex',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
|
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(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
|
|||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
|
'/trackers/trains',
|
||||||
'/trackers/wildlife',
|
'/trackers/wildlife',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
'/trackers/pc'
|
'/trackers/pc'
|
||||||
|
|||||||
377
src/lib/server/trains.ts
Normal file
377
src/lib/server/trains.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/trackers/books': 'Books',
|
'/trackers/books': 'Books',
|
||||||
'/trackers/cars': 'Cars',
|
'/trackers/cars': 'Cars',
|
||||||
'/trackers/planes': 'Planes',
|
'/trackers/planes': 'Planes',
|
||||||
|
'/trackers/trains': 'Trains',
|
||||||
'/trackers/wildlife': 'Wildlife',
|
'/trackers/wildlife': 'Wildlife',
|
||||||
'/trackers/pc': 'PC setup',
|
'/trackers/pc': 'PC setup',
|
||||||
'/blog': 'Blog',
|
'/blog': 'Blog',
|
||||||
|
|||||||
@@ -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'
|
'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: {
|
wildlife: {
|
||||||
from: '#86efac',
|
from: '#86efac',
|
||||||
to: '#15803d',
|
to: '#15803d',
|
||||||
|
|||||||
75
src/lib/trains/MakerTile.svelte
Normal file
75
src/lib/trains/MakerTile.svelte
Normal 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>
|
||||||
212
src/lib/trains/TrainCard.svelte
Normal file
212
src/lib/trains/TrainCard.svelte
Normal 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
243
src/lib/trains/shared.ts
Normal 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.';
|
||||||
@@ -18,6 +18,10 @@ const staticPaths = [
|
|||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
'/trackers/planes/makers',
|
'/trackers/planes/makers',
|
||||||
|
'/trackers/trains',
|
||||||
|
'/trackers/trains/makers',
|
||||||
|
'/trackers/trains/operators',
|
||||||
|
'/trackers/trains/countries',
|
||||||
'/trackers/wildlife',
|
'/trackers/wildlife',
|
||||||
'/trackers/wildlife/groups',
|
'/trackers/wildlife/groups',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
|
|||||||
34
src/routes/trackers/trains/+page.server.ts
Normal file
34
src/routes/trackers/trains/+page.server.ts
Normal file
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
352
src/routes/trackers/trains/+page.svelte
Normal file
352
src/routes/trackers/trains/+page.svelte
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import MakerTile from '#lib/trains/MakerTile.svelte';
|
||||||
|
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
categoryEmoji,
|
||||||
|
categoryLabel,
|
||||||
|
ERAS,
|
||||||
|
flag,
|
||||||
|
fmtCompact,
|
||||||
|
fmtNumber,
|
||||||
|
LOGO_NOTE,
|
||||||
|
SORTS,
|
||||||
|
TRACTIONS,
|
||||||
|
tractionLabel
|
||||||
|
} from '#lib/trains/shared.js';
|
||||||
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
const f = $derived(data.filters);
|
||||||
|
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||||
|
const filtering = $derived(
|
||||||
|
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service)
|
||||||
|
);
|
||||||
|
|
||||||
|
let form: HTMLFormElement | undefined = $state();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const submit = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
form?.requestSubmit();
|
||||||
|
};
|
||||||
|
const submitSoon = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(submit, 350);
|
||||||
|
};
|
||||||
|
|
||||||
|
function withParams(changes: Record<string, string | number | null>) {
|
||||||
|
const merged: Record<string, string> = Object.fromEntries(page.url.searchParams);
|
||||||
|
if (!('page' in changes)) delete merged.page;
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
if (value === null || value === '') delete merged[key];
|
||||||
|
else merged[key] = String(value);
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams(merged).toString();
|
||||||
|
return `/trackers/trains${qs ? `?${qs}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quick: { label: string; params: Record<string, string> }[] = [
|
||||||
|
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||||
|
{ label: '🇳🇱 Dutch trains', params: { country: 'Netherlands', sort: 'oldest' } },
|
||||||
|
{ label: '🚄 high-speed', params: { category: 'high-speed', sort: 'fastest' } },
|
||||||
|
{ label: '🚂 steam', params: { category: 'steam', sort: 'built' } },
|
||||||
|
{ label: '⚡ electric locos', params: { category: 'electric', sort: 'built' } },
|
||||||
|
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
|
||||||
|
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
|
||||||
|
{ label: '💪 most powerful', params: { sort: 'power' } },
|
||||||
|
{ label: '🟡 VIRM', params: { q: 'virm' } }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">trains</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/trackers/trains/random" class="pill" data-sveltekit-preload-data="off"
|
||||||
|
><Shuffle size={15} /> random train</a
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="stats mb-8">
|
||||||
|
<div>
|
||||||
|
<dt>train types</dt>
|
||||||
|
<dd>{fmtNumber(data.stats.types)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><a href="/trackers/trains/makers" class="hover:underline">builders</a></dt>
|
||||||
|
<dd>{fmtNumber(data.stats.makers)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><a href="/trackers/trains/operators" class="hover:underline">operators</a></dt>
|
||||||
|
<dd>{fmtNumber(data.stats.operators)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><a href="/trackers/trains/countries" class="hover:underline">countries</a></dt>
|
||||||
|
<dd>{fmtNumber(data.stats.countries)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>vehicles built</dt>
|
||||||
|
<dd>{fmtCompact(data.stats.built)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>photos</dt>
|
||||||
|
<dd>{fmtNumber(data.stats.photos)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if !filtering && f.page === 1}
|
||||||
|
<section class="mb-8" aria-labelledby="makers-heading">
|
||||||
|
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||||
|
<h2 id="makers-heading" class="text-lg font-semibold">builders</h2>
|
||||||
|
<a href="/trackers/trains/makers" class="text-sm text-[var(--site-muted)] hover:underline"
|
||||||
|
>all {fmtNumber(data.stats.makers)} builders →</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ul class="brands">
|
||||||
|
{#each data.topMakers as maker (maker.slug)}
|
||||||
|
<li><MakerTile {maker} compact /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="mb-8" aria-labelledby="operators-heading">
|
||||||
|
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||||
|
<h2 id="operators-heading" class="text-lg font-semibold">operators</h2>
|
||||||
|
<a
|
||||||
|
href="/trackers/trains/operators"
|
||||||
|
class="text-sm text-[var(--site-muted)] hover:underline"
|
||||||
|
>all {fmtNumber(data.stats.operators)} operators →</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ul class="brands">
|
||||||
|
{#each data.topOperators as maker (maker.slug)}
|
||||||
|
<li><MakerTile {maker} compact /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
bind:this={form}
|
||||||
|
method="GET"
|
||||||
|
action="/trackers/trains"
|
||||||
|
class="filters"
|
||||||
|
data-sveltekit-keepfocus
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
data-sveltekit-replacestate
|
||||||
|
>
|
||||||
|
<label class="search">
|
||||||
|
<Search size={16} aria-hidden="true" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value={f.q}
|
||||||
|
oninput={submitSoon}
|
||||||
|
placeholder="Search 'virm', 'sprinter', 'ice 3', 'tgv', 'class 66', 'mallard'…"
|
||||||
|
aria-label="Search trains"
|
||||||
|
autocomplete="off"
|
||||||
|
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="selects">
|
||||||
|
<NativeSelect name="category" aria-label="Kind" value={f.category} onchange={submit}>
|
||||||
|
<option value="">any kind</option>
|
||||||
|
{#each CATEGORIES as x (x)}<option value={x}>{categoryEmoji[x]} {categoryLabel[x]}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="traction" aria-label="Traction" value={f.traction} onchange={submit}>
|
||||||
|
<option value="">any traction</option>
|
||||||
|
{#each TRACTIONS as x (x)}<option value={x}>{tractionLabel[x]}</option>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="maker" aria-label="Builder" value={f.maker} onchange={submit}>
|
||||||
|
<option value="">all builders</option>
|
||||||
|
{#each data.makers as m (m.slug)}<option value={m.slug}>{m.name} ({m.types})</option>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="country" aria-label="Country" value={f.country} onchange={submit}>
|
||||||
|
<option value="">all countries</option>
|
||||||
|
{#each data.countries as c (c.name)}<option value={c.name}
|
||||||
|
>{flag(c.name)} {c.name} ({c.types})</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="era" aria-label="Era (first built)" value={f.era} onchange={submit}>
|
||||||
|
<option value="">any era</option>
|
||||||
|
{#each Object.entries(ERAS) as [key, era] (key)}<option value={key}>{era.label}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="service" aria-label="In service" value={f.service} onchange={submit}>
|
||||||
|
<option value="">running or retired</option>
|
||||||
|
<option value="yes">in service</option>
|
||||||
|
<option value="no">retired / preserved</option>
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
|
||||||
|
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
{#each quick as chip (chip.label)}
|
||||||
|
<a href={withParams({ ...chip.params })} class="quick">{chip.label}</a>
|
||||||
|
{/each}
|
||||||
|
{#if filtering}
|
||||||
|
<a href="/trackers/trains" class="quick clear"><X size={13} /> clear filters</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-4 text-sm text-[var(--site-muted)]" aria-live="polite">
|
||||||
|
{fmtNumber(data.total)}
|
||||||
|
{data.total === 1 ? 'type' : 'types'}{#if pages > 1}
|
||||||
|
· page {f.page} of {pages}{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.trains.length}
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.trains as train, i (train.dex)}
|
||||||
|
<li><TrainCard {train} eager={i < 4} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="text-lg font-medium">No trains match that.</p>
|
||||||
|
<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||||
|
Try fewer words or <a href="/trackers/trains" class="underline">clear the filters</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pages > 1}
|
||||||
|
<nav class="pager" aria-label="Pages">
|
||||||
|
{#if f.page > 1}<a href={withParams({ page: f.page - 1 })} class="pill">← prev</a>{/if}
|
||||||
|
<span class="text-sm text-[var(--site-muted)] tabular-nums">{f.page} / {pages}</span>
|
||||||
|
{#if f.page < pages}<a href={withParams({ page: f.page + 1 })} class="pill">next →</a>{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||||
|
Data: <a href="https://www.wikidata.org" class="underline">Wikidata</a> (CC0) and the
|
||||||
|
<a href="https://en.wikipedia.org" class="underline">English Wikipedia</a> 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}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.brands {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stats > div {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
}
|
||||||
|
.stats dt {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.stats dd {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.selects {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.selects :global(select) {
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
.pill,
|
||||||
|
.quick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--site-surface);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.quick {
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.pill:hover,
|
||||||
|
.quick:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.clear {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
border: 1px dashed var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
src/routes/trackers/trains/[maker]/+page.server.ts
Normal file
35
src/routes/trackers/trains/[maker]/+page.server.ts
Normal file
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
151
src/routes/trackers/trains/[maker]/+page.svelte
Normal file
151
src/routes/trackers/trains/[maker]/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||||
|
import {
|
||||||
|
commonsPage,
|
||||||
|
flag,
|
||||||
|
fmtCompact,
|
||||||
|
fmtNumber,
|
||||||
|
LOGO_NOTE,
|
||||||
|
thumbAt,
|
||||||
|
thumbSrcset
|
||||||
|
} from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const b = $derived(data.maker);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
{#if b.photo}
|
||||||
|
<figure class="hero">
|
||||||
|
<img
|
||||||
|
src={thumbAt(b.photo, 960)}
|
||||||
|
srcset={thumbSrcset(b.photo, [500, 960, 1280])}
|
||||||
|
sizes="(min-width: 1152px) 1104px, 100vw"
|
||||||
|
alt="{b.name} {data.hero?.type ?? ''}"
|
||||||
|
fetchpriority="high"
|
||||||
|
width="1280"
|
||||||
|
height="480"
|
||||||
|
/>
|
||||||
|
{#if data.hero}
|
||||||
|
<figcaption>
|
||||||
|
{b.name}
|
||||||
|
{data.hero.type} ·
|
||||||
|
<a href={commonsPage(data.hero.file)} rel="noopener"
|
||||||
|
>{data.hero.author ?? 'Wikimedia Commons'}</a
|
||||||
|
>{#if data.hero.license}
|
||||||
|
· {#if data.hero.licenseUrl}<a href={data.hero.licenseUrl} rel="noopener license"
|
||||||
|
>{data.hero.license}</a
|
||||||
|
>{:else}{data.hero.license}{/if}{/if}
|
||||||
|
</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
|
<header class="mt-6 mb-8 flex items-center gap-5">
|
||||||
|
{#if b.logo}
|
||||||
|
<img
|
||||||
|
class="logo"
|
||||||
|
src={b.logo}
|
||||||
|
alt="{b.name} logo"
|
||||||
|
title="Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{b.logoLicense
|
||||||
|
? ` (${b.logoLicense})`
|
||||||
|
: ''}"
|
||||||
|
width="72"
|
||||||
|
height="72"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<BrandBadge name={b.name} size="lg" />
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
|
||||||
|
{#if b.fullName && b.fullName !== b.name}<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||||
|
{b.fullName}
|
||||||
|
</p>{/if}
|
||||||
|
<p class="mt-2 text-[var(--site-muted)]">
|
||||||
|
{#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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.types as train, i (train.dex)}
|
||||||
|
<li><TrainCard {train} eager={i < 4} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
Sorted by number built. Sources: {b.sources.join(', ')}.{#if b.wikipedia}
|
||||||
|
· <a href="https://en.wikipedia.org/wiki/{b.wikipedia.replace(/ /g, '_')}" rel="noopener"
|
||||||
|
>{b.name} on Wikipedia</a
|
||||||
|
>{/if}{#if b.logoFile}
|
||||||
|
· Logo: <a href={commonsPage(b.logoFile)} rel="noopener"
|
||||||
|
>{b.logoAuthor ?? 'Wikimedia Commons'}</a
|
||||||
|
>{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
|
||||||
|
{LOGO_NOTE}{/if}
|
||||||
|
<a href="/trackers/trains/makers" class="underline">All builders</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
aspect-ratio: 8 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.hero figcaption {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgb(255 255 255 / 0.85);
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hero figcaption a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
src/routes/trackers/trains/[maker]/[type]/+page.server.ts
Normal file
50
src/routes/trackers/trains/[maker]/[type]/+page.server.ts
Normal file
@@ -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<Type> = { ...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 = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||||
|
const rest: Partial<T> = { ...row };
|
||||||
|
delete rest.id;
|
||||||
|
return rest as Omit<T, 'id'>;
|
||||||
|
};
|
||||||
|
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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
502
src/routes/trackers/trains/[maker]/[type]/+page.svelte
Normal file
502
src/routes/trackers/trains/[maker]/[type]/+page.svelte
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import Gallery from '#lib/cars/Gallery.svelte';
|
||||||
|
import {
|
||||||
|
categoryEmoji,
|
||||||
|
categoryHint,
|
||||||
|
categoryLabel,
|
||||||
|
countrySlug,
|
||||||
|
dexLabel,
|
||||||
|
flag,
|
||||||
|
fmtKw,
|
||||||
|
fmtM,
|
||||||
|
fmtNumber,
|
||||||
|
fmtT,
|
||||||
|
statusLabel,
|
||||||
|
tractionHint,
|
||||||
|
tractionLabel
|
||||||
|
} from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const p = $derived(data.train);
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const clamp = (n: number) => Math.max(0.03, Math.min(1, n));
|
||||||
|
const log = (n: number, max: number) => clamp(Math.log10(Math.max(1, n)) / Math.log10(max));
|
||||||
|
// Pokedex-style "base stats": each bar is scaled against the whole railway world.
|
||||||
|
const baseStats = $derived(
|
||||||
|
[
|
||||||
|
p.maxSpeedKmh != null && {
|
||||||
|
label: 'speed',
|
||||||
|
value: `${fmtNumber(p.maxSpeedKmh)} km/h`,
|
||||||
|
hint: 'top speed',
|
||||||
|
score: clamp(p.maxSpeedKmh / 600),
|
||||||
|
hue: 8
|
||||||
|
},
|
||||||
|
p.powerKw != null && {
|
||||||
|
label: 'power',
|
||||||
|
value: fmtKw(p.powerKw),
|
||||||
|
hint: 'power output',
|
||||||
|
score: log(p.powerKw, 20_000),
|
||||||
|
hue: 45
|
||||||
|
},
|
||||||
|
p.weightT != null && {
|
||||||
|
label: 'weight',
|
||||||
|
value: fmtT(p.weightT),
|
||||||
|
hint: 'in working order',
|
||||||
|
score: log(p.weightT, 1000),
|
||||||
|
hue: 280
|
||||||
|
},
|
||||||
|
p.capacity != null && {
|
||||||
|
label: 'seats',
|
||||||
|
value: fmtNumber(p.capacity),
|
||||||
|
hint: 'passengers',
|
||||||
|
score: clamp(p.capacity / 1700),
|
||||||
|
hue: 210
|
||||||
|
},
|
||||||
|
p.numberBuilt != null && {
|
||||||
|
label: 'built',
|
||||||
|
value: fmtNumber(p.numberBuilt),
|
||||||
|
hint: 'made in total',
|
||||||
|
score: log(p.numberBuilt, 20_000),
|
||||||
|
hue: 140
|
||||||
|
}
|
||||||
|
].filter((s) => !!s)
|
||||||
|
);
|
||||||
|
|
||||||
|
const years = (a: number | null, b: number | null) =>
|
||||||
|
a ? (b && b !== a ? `${a}–${b}` : `${a}`) : null;
|
||||||
|
const facts = $derived(
|
||||||
|
[
|
||||||
|
['built', years(p.firstBuilt, p.lastBuilt)],
|
||||||
|
['in service from', p.introducedYear],
|
||||||
|
['retired', p.retiredYear],
|
||||||
|
['status', p.status && (statusLabel[p.status] ?? p.status)],
|
||||||
|
['number built', p.numberBuilt && fmtNumber(p.numberBuilt)],
|
||||||
|
['kind', p.categoryLabel ?? categoryLabel[p.category]],
|
||||||
|
['traction', p.traction && (tractionLabel[p.traction] ?? p.traction)],
|
||||||
|
['origin', p.country && `${flag(p.country)} ${p.country}`],
|
||||||
|
['builders', p.makers.join(', ')],
|
||||||
|
['designer', p.designer],
|
||||||
|
['operators', p.operators.join(', ')],
|
||||||
|
['top speed', p.maxSpeedKmh && `${fmtNumber(p.maxSpeedKmh)} km/h`],
|
||||||
|
['power', p.powerKw && fmtKw(p.powerKw)],
|
||||||
|
['gauge', p.gauge],
|
||||||
|
['electric system', p.electricSystem],
|
||||||
|
['wheel arrangement', p.wheels],
|
||||||
|
['formation', p.formation],
|
||||||
|
['length', p.lengthM && fmtM(p.lengthM)],
|
||||||
|
['weight', p.weightT && fmtT(p.weightT)],
|
||||||
|
['capacity', p.capacity && `${fmtNumber(p.capacity)} passengers`],
|
||||||
|
['nicknames', p.nicknames],
|
||||||
|
['Dutch name', p.nlName]
|
||||||
|
].filter(
|
||||||
|
([label, value]) =>
|
||||||
|
value || ['built', 'number built', 'top speed', 'power'].includes(label as string)
|
||||||
|
) as [string, string | number | null][]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Short fun facts, all computed from the numbers above (no invented trivia).
|
||||||
|
const fun = $derived(
|
||||||
|
[
|
||||||
|
p.firstBuilt && {
|
||||||
|
icon: '🎂',
|
||||||
|
text: `First built ${thisYear - p.firstBuilt} years ago, in ${p.firstBuilt}.`
|
||||||
|
},
|
||||||
|
p.maxSpeedKmh &&
|
||||||
|
p.maxSpeedKmh >= 140 && {
|
||||||
|
icon: '🏎️',
|
||||||
|
text: `Top speed is ${(p.maxSpeedKmh / 130).toFixed(1)}× a car on the Dutch motorway (130 km/h).`
|
||||||
|
},
|
||||||
|
p.maxSpeedKmh &&
|
||||||
|
p.maxSpeedKmh >= 160 && {
|
||||||
|
icon: '⏱️',
|
||||||
|
text: `At full speed it would cover Amsterdam–Utrecht (≈ 40 km) in ${Math.max(1, Math.round((40 / p.maxSpeedKmh) * 60))} minutes.`
|
||||||
|
},
|
||||||
|
p.powerKw &&
|
||||||
|
p.powerKw >= 300 && {
|
||||||
|
icon: '🚗',
|
||||||
|
text: `${fmtKw(p.powerKw)} is about ${fmtNumber(Math.round(p.powerKw / 85))} family cars (~85 kW each) flat out.`
|
||||||
|
},
|
||||||
|
p.weightT &&
|
||||||
|
p.weightT >= 12 && {
|
||||||
|
icon: '🐘',
|
||||||
|
text: `Weighs about as much as ${fmtNumber(Math.round(p.weightT / 6))} African elephants (~6 t each).`
|
||||||
|
},
|
||||||
|
p.lengthM &&
|
||||||
|
p.lengthM >= 9 && {
|
||||||
|
icon: '📏',
|
||||||
|
text: `${fmtM(p.lengthM)} long: about ${Math.round(p.lengthM / 4.5)} cars parked nose to tail.`
|
||||||
|
},
|
||||||
|
p.capacity &&
|
||||||
|
p.capacity >= 100 && {
|
||||||
|
icon: '🧍',
|
||||||
|
text: `Seats ${fmtNumber(p.capacity)}: ${Math.max(1, Math.round(p.capacity / 50))} full city buses (~50 seats each).`
|
||||||
|
},
|
||||||
|
p.introducedYear &&
|
||||||
|
!p.retiredYear &&
|
||||||
|
p.inService &&
|
||||||
|
thisYear - p.introducedYear >= 20 && {
|
||||||
|
icon: '🧓',
|
||||||
|
text: `Still in service after ${thisYear - p.introducedYear} years.`
|
||||||
|
},
|
||||||
|
p.introducedYear &&
|
||||||
|
p.retiredYear &&
|
||||||
|
p.retiredYear > p.introducedYear && {
|
||||||
|
icon: '⌛',
|
||||||
|
text: `Served for ${p.retiredYear - p.introducedYear} years (${p.introducedYear}–${p.retiredYear}).`
|
||||||
|
}
|
||||||
|
].filter((f) => !!f)
|
||||||
|
);
|
||||||
|
const typeHref = (x: { makerSlug: string; slug: string }) =>
|
||||||
|
`/trackers/trains/${x.makerSlug}/${x.slug}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<nav class="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
{#if data.prev}<a href={typeHref(data.prev)} class="step" title={data.prev.name}
|
||||||
|
><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
|
||||||
|
>{/if}
|
||||||
|
<a
|
||||||
|
href="/trackers/trains/random"
|
||||||
|
class="step"
|
||||||
|
data-sveltekit-preload-data="off"
|
||||||
|
title="Random train"><Shuffle size={14} /></a
|
||||||
|
>
|
||||||
|
{#if data.next}<a href={typeHref(data.next)} class="step" title={data.next.name}
|
||||||
|
>{dexLabel(data.next.dex)}<ChevronRight size={15} /></a
|
||||||
|
>{/if}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/trackers/trains/{data.maker.slug}" aria-label="{data.maker.name} trains"
|
||||||
|
><BrandBadge name={data.maker.name} logo={data.maker.logo} size="lg" /></a
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="dex">
|
||||||
|
{dexLabel(p.dex)}{#if p.nlName}
|
||||||
|
· {p.nlName}{/if}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
{#if p.fullName.startsWith(data.maker.name)}<a
|
||||||
|
href="/trackers/trains/{data.maker.slug}"
|
||||||
|
class="brand">{data.maker.name}</a
|
||||||
|
>
|
||||||
|
{p.fullName.slice(data.maker.name.length).trim()}{:else}{p.fullName}{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-[var(--site-muted)]">
|
||||||
|
{#if p.country}<a href="/trackers/trains/countries/{countrySlug(p.country)}"
|
||||||
|
>{flag(p.country)} {p.country}</a
|
||||||
|
> ·
|
||||||
|
{/if}built by
|
||||||
|
<a href="/trackers/trains/{data.maker.slug}" class="underline">{data.maker.name}</a
|
||||||
|
>{#if p.firstBuilt}
|
||||||
|
· from {p.firstBuilt}{/if}{#if p.description}
|
||||||
|
· {p.description}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href="/trackers/trains?category={encodeURIComponent(p.category)}"
|
||||||
|
class="chip"
|
||||||
|
title={categoryHint[p.category]}
|
||||||
|
>{categoryEmoji[p.category] ?? ''} {categoryLabel[p.category] ?? p.category}</a
|
||||||
|
>
|
||||||
|
{#if p.traction}<a
|
||||||
|
href="/trackers/trains?traction={encodeURIComponent(p.traction)}"
|
||||||
|
class="chip"
|
||||||
|
title={tractionHint[p.traction]}>{tractionLabel[p.traction] ?? p.traction}</a
|
||||||
|
>{/if}
|
||||||
|
{#if p.inService === true}<span class="chip ok">✓ in service</span
|
||||||
|
>{:else if p.status === 'preserved'}<span class="chip">preserved</span
|
||||||
|
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.photos.length}
|
||||||
|
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if p.summary}
|
||||||
|
<section class="card" aria-labelledby="about">
|
||||||
|
<h2 id="about">what is it?</h2>
|
||||||
|
<p class="summary">{p.summary}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.operators.length}
|
||||||
|
<section class="card" aria-labelledby="operators">
|
||||||
|
<h2 id="operators">who runs it</h2>
|
||||||
|
<div class="ops">
|
||||||
|
{#each data.operators as o (o.slug)}
|
||||||
|
<a href="/trackers/trains/operators/{o.slug}"
|
||||||
|
><BrandBadge name={o.name} logo={o.logo} size="sm" />{o.name}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if baseStats.length}
|
||||||
|
<section class="card" aria-labelledby="stats">
|
||||||
|
<h2 id="stats">base stats</h2>
|
||||||
|
<ul class="bars">
|
||||||
|
{#each baseStats as s (s.label)}
|
||||||
|
<li>
|
||||||
|
<span class="label">{s.label}</span>
|
||||||
|
<span class="track"
|
||||||
|
><span class="fill" style:width="{s.score * 100}%" style:--hue={s.hue}></span></span
|
||||||
|
>
|
||||||
|
<span class="value">{s.value}<small>{s.hint}</small></span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fun.length}
|
||||||
|
<section class="card" aria-labelledby="fun">
|
||||||
|
<h2 id="fun">fun facts</h2>
|
||||||
|
<ul class="fun">
|
||||||
|
{#each fun as f (f.text)}<li>
|
||||||
|
<span aria-hidden="true">{f.icon}</span><span>{f.text}</span>
|
||||||
|
</li>{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="note">{categoryLabel[p.category]}: {categoryHint[p.category]}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="facts">
|
||||||
|
<h2 id="facts">spec sheet</h2>
|
||||||
|
<dl class="facts">
|
||||||
|
{#each facts as [label, value] (label)}
|
||||||
|
<div>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd class={[!value && 'blank']}>{value || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
<p class="note">From the Wikipedia infobox and Wikidata. Blank means the sources don't say.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if data.parent || data.variants.length}
|
||||||
|
<section class="card" aria-labelledby="family">
|
||||||
|
<h2 id="family">family</h2>
|
||||||
|
<div class="related">
|
||||||
|
{#if data.parent}<a href={typeHref(data.parent)}
|
||||||
|
>↑ {data.parent.fullName}<small>parent</small></a
|
||||||
|
>{/if}
|
||||||
|
{#each data.variants as v (v.dex)}
|
||||||
|
<a href={typeHref(v)}
|
||||||
|
>{v.fullName}{#if v.firstBuilt}<small>{v.firstBuilt}</small>{/if}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="mt-8 text-xs text-[var(--site-muted)]">
|
||||||
|
Sources: <a href="https://www.wikidata.org/wiki/{p.wikidata}" class="underline"
|
||||||
|
>Wikidata {p.wikidata}</a
|
||||||
|
>{#if p.wikipedia}, <a
|
||||||
|
href="https://en.wikipedia.org/wiki/{encodeURIComponent(p.wikipedia.replace(/ /g, '_'))}"
|
||||||
|
class="underline">Wikipedia: {p.wikipedia}</a
|
||||||
|
> (CC BY-SA 4.0){/if}.{#if p.updatedAt}
|
||||||
|
Updated {p.updatedAt.slice(0, 10)}.{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back,
|
||||||
|
.step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.back:hover,
|
||||||
|
.step:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
margin: 2rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
.dex {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.bars li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5.5rem 1fr minmax(7rem, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--site-hover);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, hsl(var(--hue) 70% 45%), hsl(var(--hue) 85% 62%));
|
||||||
|
animation: grow 0.6s ease-out;
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
@keyframes grow {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fill {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.value small {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
.facts dt {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.facts dd {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.facts dd.blank {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
background: var(--site-surface);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip.ok {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.fun {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.fun li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.related {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.related a {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
}
|
||||||
|
.related a:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.ops a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem 0.2rem 0.25rem;
|
||||||
|
}
|
||||||
|
.ops a:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.related small {
|
||||||
|
color: var(--site-muted);
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
src/routes/trackers/trains/countries/+page.server.ts
Normal file
14
src/routes/trackers/trains/countries/+page.server.ts
Normal file
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
117
src/routes/trackers/trains/countries/+page.svelte
Normal file
117
src/routes/trackers/trains/countries/+page.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { flag, fmtNumber, monogram, brandHue, thumbAt } from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
<header class="mt-6 mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">countries</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
Train types from {fmtNumber(data.countries.length)} countries, counted where they were built and
|
||||||
|
where they ran. Tap one for its trains, oldest first.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.countries as c, i (c.slug)}
|
||||||
|
<li>
|
||||||
|
<a href="/trackers/trains/countries/{c.slug}" class="tile">
|
||||||
|
<span class="media" style:--hue={brandHue(c.name)}>
|
||||||
|
{#if c.photo}
|
||||||
|
<img
|
||||||
|
src={thumbAt(c.photo, 330)}
|
||||||
|
alt=""
|
||||||
|
loading={i < 8 ? 'eager' : 'lazy'}
|
||||||
|
decoding="async"
|
||||||
|
width="330"
|
||||||
|
height="186"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder" aria-hidden="true">{monogram(c.name)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="name">{flag(c.name)} {c.name}</span>
|
||||||
|
<span class="meta">{fmtNumber(c.types)} {c.types === 1 ? 'type' : 'types'}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 0 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.media {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: hsl(var(--hue) 50% 60% / 0.25);
|
||||||
|
}
|
||||||
|
.name,
|
||||||
|
.meta {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
91
src/routes/trackers/trains/countries/[country]/+page.svelte
Normal file
91
src/routes/trackers/trains/countries/[country]/+page.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
categoryEmoji,
|
||||||
|
categoryLabel,
|
||||||
|
flag,
|
||||||
|
fmtNumber,
|
||||||
|
type TrainCard as Card
|
||||||
|
} from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const groups = $derived(
|
||||||
|
CATEGORIES.map((cat) => ({
|
||||||
|
cat,
|
||||||
|
types: data.types.filter((t: Card) => t.category === cat)
|
||||||
|
})).filter((g) => g.types.length)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains/countries" class="back"><ArrowLeft size={15} /> all countries</a>
|
||||||
|
<header class="mt-6 mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
{flag(data.country.name)}
|
||||||
|
{data.country.name}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
{fmtNumber(data.country.types)} train types built in or running in {data.country.name}, oldest
|
||||||
|
first.
|
||||||
|
<a href="/trackers/trains?country={encodeURIComponent(data.country.name)}" class="underline"
|
||||||
|
>Search and sort them</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<nav class="mt-4 flex flex-wrap gap-2" aria-label="Kinds">
|
||||||
|
{#each groups as g (g.cat)}
|
||||||
|
<a href="#{g.cat.replace(/ /g, '-')}" class="quick"
|
||||||
|
>{categoryEmoji[g.cat]} {categoryLabel[g.cat]} ({g.types.length})</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#each groups as g, gi (g.cat)}
|
||||||
|
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
|
||||||
|
<h2 class="mb-3 text-lg font-semibold">
|
||||||
|
{categoryEmoji[g.cat]}
|
||||||
|
{categoryLabel[g.cat]}
|
||||||
|
<span class="text-sm font-normal text-[var(--site-muted)]">{g.types.length}</span>
|
||||||
|
</h2>
|
||||||
|
<ul class="grid">
|
||||||
|
{#each g.types as train, i (train.dex)}
|
||||||
|
<li><TrainCard {train} eager={gi === 0 && i < 4} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.quick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.quick:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
src/routes/trackers/trains/makers/+page.server.ts
Normal file
20
src/routes/trackers/trains/makers/+page.server.ts
Normal file
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
49
src/routes/trackers/trains/makers/+page.svelte
Normal file
49
src/routes/trackers/trains/makers/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MakerTile from '#lib/trains/MakerTile.svelte';
|
||||||
|
import { LOGO_NOTE, fmtNumber } from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const withLogo = $derived(data.makers.filter((m) => m.logo).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
<header class="mt-6 mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">builders</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.makers as maker (maker.slug)}
|
||||||
|
<li><MakerTile {maker} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
src/routes/trackers/trains/operators/+page.server.ts
Normal file
20
src/routes/trackers/trains/operators/+page.server.ts
Normal file
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
49
src/routes/trackers/trains/operators/+page.svelte
Normal file
49
src/routes/trackers/trains/operators/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MakerTile from '#lib/trains/MakerTile.svelte';
|
||||||
|
import { LOGO_NOTE, fmtNumber } from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const withLogo = $derived(data.makers.filter((m) => m.logo).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
<header class="mt-6 mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">operators</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.makers as maker (maker.slug)}
|
||||||
|
<li><MakerTile {maker} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
148
src/routes/trackers/trains/operators/[operator]/+page.svelte
Normal file
148
src/routes/trackers/trains/operators/[operator]/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||||
|
import {
|
||||||
|
commonsPage,
|
||||||
|
flag,
|
||||||
|
fmtNumber,
|
||||||
|
LOGO_NOTE,
|
||||||
|
thumbAt,
|
||||||
|
thumbSrcset
|
||||||
|
} from '#lib/trains/shared.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const b = $derived(data.operator);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||||
|
{#if b.photo}
|
||||||
|
<figure class="hero">
|
||||||
|
<img
|
||||||
|
src={thumbAt(b.photo, 960)}
|
||||||
|
srcset={thumbSrcset(b.photo, [500, 960, 1280])}
|
||||||
|
sizes="(min-width: 1152px) 1104px, 100vw"
|
||||||
|
alt="{b.name} {data.hero?.type ?? ''}"
|
||||||
|
fetchpriority="high"
|
||||||
|
width="1280"
|
||||||
|
height="480"
|
||||||
|
/>
|
||||||
|
{#if data.hero}
|
||||||
|
<figcaption>
|
||||||
|
{b.name}
|
||||||
|
{data.hero.type} ·
|
||||||
|
<a href={commonsPage(data.hero.file)} rel="noopener"
|
||||||
|
>{data.hero.author ?? 'Wikimedia Commons'}</a
|
||||||
|
>{#if data.hero.license}
|
||||||
|
· {#if data.hero.licenseUrl}<a href={data.hero.licenseUrl} rel="noopener license"
|
||||||
|
>{data.hero.license}</a
|
||||||
|
>{:else}{data.hero.license}{/if}{/if}
|
||||||
|
</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
|
<header class="mt-6 mb-8 flex items-center gap-5">
|
||||||
|
{#if b.logo}
|
||||||
|
<img
|
||||||
|
class="logo"
|
||||||
|
src={b.logo}
|
||||||
|
alt="{b.name} logo"
|
||||||
|
title="Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{b.logoLicense
|
||||||
|
? ` (${b.logoLicense})`
|
||||||
|
: ''}"
|
||||||
|
width="72"
|
||||||
|
height="72"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<BrandBadge name={b.name} size="lg" />
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
|
||||||
|
{#if b.fullName && b.fullName !== b.name}<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||||
|
{b.fullName}
|
||||||
|
</p>{/if}
|
||||||
|
<p class="mt-2 text-[var(--site-muted)]">
|
||||||
|
{#if b.country}{flag(b.country)} {b.country} ·
|
||||||
|
{/if}{fmtNumber(b.typeCount)}
|
||||||
|
{b.typeCount === 1 ? 'type' : 'types'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="grid">
|
||||||
|
{#each data.types as train, i (train.dex)}
|
||||||
|
<li><TrainCard {train} eager={i < 4} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
Oldest first. Sources: Wikipedia, Wikidata.{#if b.wikipedia}
|
||||||
|
· <a href="https://en.wikipedia.org/wiki/{b.wikipedia.replace(/ /g, '_')}" rel="noopener"
|
||||||
|
>{b.name} on Wikipedia</a
|
||||||
|
>{/if}{#if b.logoFile}
|
||||||
|
· Logo: <a href={commonsPage(b.logoFile)} rel="noopener"
|
||||||
|
>{b.logoAuthor ?? 'Wikimedia Commons'}</a
|
||||||
|
>{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
|
||||||
|
{LOGO_NOTE}{/if}
|
||||||
|
<a href="/trackers/trains/operators" class="underline">All operators</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
aspect-ratio: 8 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.hero figcaption {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgb(255 255 255 / 0.85);
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hero figcaption a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/routes/trackers/trains/random/+server.ts
Normal file
9
src/routes/trackers/trains/random/+server.ts
Normal file
@@ -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');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user