planes: pokedex tracker pages, cards, makers, random

This commit is contained in:
2026-10-24 20:10:36 +00:00
parent 2259965a28
commit 6696bc7162
20 changed files with 1914 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ planes, total }, overview] = await Promise.all([searchPlanes(filters), planeOverview()]);
return {
filters,
planes,
total,
pageSize: PAGE_SIZE,
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
topMakers: overview.byBuilt
.slice(0, 16)
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
stats: overview.stats,
seo: {
title: 'Plane pokedex',
description: `Search ${overview.stats.types.toLocaleString('en-GB')} aircraft types from ${overview.stats.makers} makers: airliners, helicopters, bizjets, light aircraft and warbirds, with specs and photos.`
}
};
};

View File

@@ -0,0 +1,319 @@
<script lang="ts">
import { page } from '$app/state';
import MakerTile from '#lib/planes/MakerTile.svelte';
import PlaneCard from '#lib/planes/PlaneCard.svelte';
import {
ENGINES,
engineLabel,
ERAS,
fmtCompact,
fmtNumber,
LOGO_NOTE,
ROLES,
roleEmoji,
roleLabel,
SORTS
} from '#lib/planes/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.role || f.engine || 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/planes${qs ? `?${qs}` : ''}`;
}
const quick = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
{ label: '💼 bizjets', params: { role: 'business', sort: 'built' } },
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
{ label: '🚀 fastest', params: { sort: 'fastest' } },
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
{ label: '✈️ 737', params: { q: '737' } }
];
</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">planes</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A plane pokedex: airliners, helicopters, business jets, light aircraft, gliders and famous
warbirds that were actually built and flown. Tap one to see what it is and how to spot it.
</p>
</div>
<a href="/trackers/planes/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random plane</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>aircraft types</dt>
<dd>{fmtNumber(data.stats.types)}</dd>
</div>
<div>
<dt>makers</dt>
<dd>{fmtNumber(data.stats.makers)}</dd>
</div>
<div>
<dt>aircraft 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">makers</h2>
<a href="/trackers/planes/makers" class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.makers)} makers →</a
>
</div>
<ul class="brands">
{#each data.topMakers as maker (maker.slug)}
<li><MakerTile {maker} compact /></li>
{/each}
</ul>
</section>
{/if}
<form
bind:this={form}
method="GET"
action="/trackers/planes"
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 '737', 'a320', 'cessna', 'apache', 'B738'…"
aria-label="Search planes"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="maker" aria-label="Maker" value={f.maker} onchange={submit}>
<option value="">all makers</option>
{#each data.makers as m (m.slug)}<option value={m.slug}>{m.name} ({m.types})</option>{/each}
</NativeSelect>
<NativeSelect name="role" aria-label="Role" value={f.role} onchange={submit}>
<option value="">any role</option>
{#each ROLES as x (x)}<option value={x}>{roleEmoji[x]} {roleLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="engine" aria-label="Engine type" value={f.engine} onchange={submit}>
<option value="">any engine</option>
{#each ENGINES as x (x)}<option value={x}>{engineLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="era" aria-label="Era (first flight)" 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="">flying or retired</option>
<option value="yes">still flying</option>
<option value="no">retired</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/planes" 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.planes.length}
<ul class="grid">
{#each data.planes as plane, i (plane.dex)}
<li><PlaneCard {plane} eager={i < 4} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No planes match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/planes" 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> infoboxes and
specification sections (CC BY-SA 4.0), ICAO codes from Wikipedia's
<a href="https://en.wikipedia.org/wiki/List_of_aircraft_type_designators" class="underline"
>list of aircraft type designators</a
>. Only types that were built in numbers (no prototypes or drones). Era is by first flight.
Blank means unknown. Photos from Wikimedia Commons, credited on each plane'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(4, 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>

View File

@@ -0,0 +1,34 @@
import { error } from '@sveltejs/kit';
import { getMaker } from '#lib/server/planes.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const found = await getMaker(params.maker);
if (!found) error(404, 'No such maker in the plane 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
},
hero,
types,
seo: {
title: `${maker.name} aircraft`,
description: `All ${maker.typeCount} ${maker.name} aircraft types in Stan's plane pokedex${maker.country ? ` (${maker.country})` : ''}, with specs and photos.`
}
};
};

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import PlaneCard from '#lib/planes/PlaneCard.svelte';
import {
commonsPage,
flag,
fmtCompact,
fmtNumber,
LOGO_NOTE,
thumbAt,
thumbSrcset
} from '#lib/planes/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/planes" class="back"><ArrowLeft size={15} /> all planes</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}
· first flights {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 plane, i (plane.dex)}
<li><PlaneCard {plane} 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/planes/makers" class="underline">All makers</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>

View File

@@ -0,0 +1,49 @@
import { error } from '@sveltejs/kit';
import { getType } from '#lib/server/planes.js';
import type { planeTypes } from '#lib/server/db/schema/index.js';
import type { PageServerLoad } from './$types';
type Type = typeof planeTypes.$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 aircraft in the pokedex');
const { type, maker, photos, parent, variants, 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 {
plane: publicType(type),
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
photos,
parent: parent ? strip(parent) : null,
variants: variants.map(strip),
prev,
next,
seo: {
title: type.fullName,
description: `${type.fullName}${type.firstFlightYear ? ` (first flight ${type.firstFlightYear})` : ''}${type.roleLabel ? `, ${type.roleLabel.toLowerCase()}` : ''}: specs, photos and how to recognise it.`
}
};
};

View File

@@ -0,0 +1,490 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import Gallery from '#lib/cars/Gallery.svelte';
import {
dexLabel,
engineHint,
engineLabel,
flag,
fmtKg,
fmtM,
fmtNumber,
roleEmoji,
roleLabel,
statusLabel
} from '#lib/planes/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.plane);
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 aircraft world.
const baseStats = $derived(
[
(p.maxSpeedKmh ?? p.cruiseKmh) != null && {
label: 'speed',
value: `${fmtNumber((p.maxSpeedKmh ?? p.cruiseKmh)!)} km/h`,
hint: p.maxSpeedKmh != null ? 'top speed' : 'cruise speed',
score: clamp((p.maxSpeedKmh ?? p.cruiseKmh)! / 3500),
hue: 8
},
p.rangeKm != null && {
label: 'range',
value: `${fmtNumber(p.rangeKm)} km`,
hint: 'without refuelling',
score: clamp(p.rangeKm / 20_000),
hue: 140
},
p.passengers != null && {
label: 'seats',
value: `${fmtNumber(p.passengers)}`,
hint: 'passengers',
score: clamp(p.passengers / 850),
hue: 210
},
p.mtowKg != null && {
label: 'size',
value: fmtKg(p.mtowKg),
hint: 'max take-off weight',
score: log(p.mtowKg, 640_000),
hue: 280
},
p.numberBuilt != null && {
label: 'built',
value: fmtNumber(p.numberBuilt),
hint: 'made in total',
score: log(p.numberBuilt, 45_000),
hue: 45
}
].filter((s) => !!s)
);
const engines = $derived(
p.engineType === 'none'
? 'none (glider)'
: [
p.engineCount ? `${p.engineCount}×` : '',
p.engines.length ? p.engines.join(' / ') : p.engineType ? engineLabel[p.engineType] : ''
]
.join(' ')
.trim() || null
);
const facts = $derived(
[
['first flight', p.firstFlight],
['introduced', p.introducedYear],
['retired', p.retiredYear],
['status', p.status && (statusLabel[p.status] ?? p.status)],
['number built', p.numberBuilt && fmtNumber(p.numberBuilt)],
['role', p.roleLabel],
['origin', p.country && `${flag(p.country)} ${p.country}`],
['makers', p.makers.join(', ')],
['engines', engines],
['crew', p.crew],
['passengers', p.passengers],
['length', p.lengthM && fmtM(p.lengthM)],
['wingspan', p.wingspanM && fmtM(p.wingspanM)],
['rotor diameter', p.rotorM && fmtM(p.rotorM)],
['height', p.heightM && fmtM(p.heightM)],
['max take-off weight', p.mtowKg && fmtKg(p.mtowKg)],
['top speed', p.maxSpeedKmh && `${fmtNumber(p.maxSpeedKmh)} km/h`],
['cruise speed', p.cruiseKmh && `${fmtNumber(p.cruiseKmh)} km/h`],
['range', p.rangeKm && `${fmtNumber(p.rangeKm)} km`],
['ceiling', p.ceilingM && `${fmtNumber(p.ceilingM)} m`],
['wings', p.wing],
['landing gear', p.gear],
['ICAO code', p.icao.join(', ')]
].filter(
([label, value]) =>
value ||
['first flight', 'number built', 'engines', 'top speed', 'range'].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.firstFlightYear && {
icon: '🎂',
text: `First flew ${thisYear - p.firstFlightYear} years ago, in ${p.firstFlightYear}.`
},
p.maxSpeedKmh &&
p.maxSpeedKmh >= 200 && {
icon: '🏎️',
text: `Top speed is ${(p.maxSpeedKmh / 130).toFixed(1)}× a car on the Dutch motorway (130 km/h).`
},
p.maxSpeedKmh &&
p.maxSpeedKmh > 1235 && {
icon: '💥',
text: `Faster than sound: up to Mach ${(p.maxSpeedKmh / 1235).toFixed(1)} (sea-level speed of sound ≈ 1,235 km/h).`
},
(p.wingspanM ?? p.rotorM) &&
(p.wingspanM ?? p.rotorM)! >= 9 && {
icon: '🚗',
text: `${p.wingspanM ? 'Wingspan' : 'Rotor'} of ${fmtM((p.wingspanM ?? p.rotorM)!)}: about ${Math.round((p.wingspanM ?? p.rotorM)! / 4.5)} cars parked nose to tail.`
},
p.mtowKg &&
p.mtowKg >= 12_000 && {
icon: '🐘',
text: `Fully loaded it can weigh as much as ${fmtNumber(Math.round(p.mtowKg / 6000))} African elephants (~6 t each).`
},
p.rangeKm &&
p.rangeKm >= 1000 && {
icon: '🌍',
text:
p.rangeKm >= 5900
? `Range of ${fmtNumber(p.rangeKm)} km: Amsterdam to New York (≈5,850 km) without stopping.`
: `Range of ${fmtNumber(p.rangeKm)} km: ${(p.rangeKm / 180).toFixed(0)}× Amsterdam to Brussels.`
},
p.ceilingM &&
p.ceilingM > 8849 && {
icon: '🏔️',
text: `Can fly ${(p.ceilingM / 8849).toFixed(1)}× higher than Mount Everest (8,849 m).`
}
].filter((f) => !!f)
);
const typeHref = (x: { makerSlug: string; slug: string }) =>
`/trackers/planes/${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/planes" class="back"><ArrowLeft size={15} /> all planes</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/planes/random"
class="step"
data-sveltekit-preload-data="off"
title="Random plane"><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/planes/{data.maker.slug}" aria-label="{data.maker.name} aircraft"
><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.icao.length}
· ICAO {p.icao.join(', ')}{/if}
</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
{#if p.fullName.startsWith(data.maker.name)}<a
href="/trackers/planes/{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}{flag(p.country)} {p.country} ·
{/if}{p.firstFlightYear
? `first flew ${p.firstFlightYear}`
: 'first flight unknown'}{#if p.roleLabel}
· {p.roleLabel}{/if}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
{#if p.role}<a href="/trackers/planes?role={encodeURIComponent(p.role)}" class="chip"
>{roleEmoji[p.role] ?? ''} {roleLabel[p.role] ?? p.role}</a
>{/if}
{#if p.engineType}<a
href="/trackers/planes?engine={p.engineType}"
class="chip"
title={engineHint[p.engineType]}
>{p.engineCount && p.engineCount > 1 ? `${p.engineCount}× ` : ''}{engineLabel[
p.engineType
] ?? p.engineType}</a
>{/if}
{#if p.inService === true}<span class="chip ok">✓ still flying</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 || p.recognize}
<section class="card" aria-labelledby="about">
<h2 id="about">what is it?</h2>
{#if p.summary}<p class="summary">{p.summary}</p>{/if}
{#if p.recognize}
<p class="recognize">
<span aria-hidden="true">🔎</span><span
><strong>How to recognise it:</strong> {p.recognize}</span
>
</p>
{/if}
</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 aircraft: 3,500 km/h, 20,000 km, 850 seats; size and number built
on a log scale up to 640 t (An-225) and 45,000 built (Cessna 172).
</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>
{#if p.engineType && engineHint[p.engineType]}
<p class="note">{engineLabel[p.engineType]}: {engineHint[p.engineType]}</p>
{/if}
</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">
Specs are for the version in the Wikipedia article's specification section. 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.firstFlightYear}<small>{v.firstFlightYear}</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.icao.length}, ICAO code from
<a href="https://en.wikipedia.org/wiki/List_of_aircraft_type_designators" class="underline"
>list of aircraft type designators</a
>{/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;
}
.recognize {
margin-top: 0.9rem;
display: flex;
gap: 0.6rem;
align-items: flex-start;
border: 1px dashed var(--site-border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
font-size: 0.88rem;
}
.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);
}
.related small {
color: var(--site-muted);
margin-left: 0.3rem;
}
</style>

View File

@@ -0,0 +1,20 @@
import { planeOverview } from '#lib/server/planes.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byBuilt, stats } = await planeOverview();
return {
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
slug,
name,
types,
built,
logo
})),
stats,
seo: {
title: 'Plane makers',
description: `All ${stats.makers} aircraft makers in the plane pokedex with their logos, most aircraft built first.`
}
};
};

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import MakerTile from '#lib/planes/MakerTile.svelte';
import { LOGO_NOTE, fmtNumber } from '#lib/planes/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/planes" class="back"><ArrowLeft size={15} /> all planes</a>
<header class="mt-6 mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">makers</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
All {fmtNumber(data.makers.length)} makers in the plane pokedex, the ones that built the most aircraft
first. Tap one for its aircraft.
</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} makers have a logo; the rest get a coloured monogram.
{LOGO_NOTE} Each maker 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>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { randomPlane } from '#lib/server/planes.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ setHeaders }) => {
const plane = await randomPlane();
setHeaders({ 'cache-control': 'no-store' });
redirect(303, plane ? `/trackers/planes/${plane.makerSlug}/${plane.slug}` : '/trackers/planes');
};