diff --git a/src/lib/planes/MakerTile.svelte b/src/lib/planes/MakerTile.svelte new file mode 100644 index 0000000..6f1197c --- /dev/null +++ b/src/lib/planes/MakerTile.svelte @@ -0,0 +1,71 @@ + + + + + {maker.name} + {#if !compact} + {maker.types} + {maker.types === 1 ? 'type' : 'types'}{#if maker.built} + · {fmtCompact(maker.built)} built{/if} + {/if} + + + diff --git a/src/lib/planes/PlaneCard.svelte b/src/lib/planes/PlaneCard.svelte new file mode 100644 index 0000000..b261cc1 --- /dev/null +++ b/src/lib/planes/PlaneCard.svelte @@ -0,0 +1,211 @@ + + + +
+ {#if plane.photo} + {plane.fullName} + {:else} + + {/if} + + {dexLabel(plane.dex)} + {#if plane.icao[0]}{plane.icao[0]}{/if} +
+
+

{plane.makerName}

+

{plane.name}

+

+ {plane.firstFlightYear + ? `first flew ${plane.firstFlightYear}` + : '—'}{#if plane.inService === true} + · still flying{:else if plane.inService === false} + · retired{/if} +

+
+
+ {#if plane.role}{roleEmoji[plane.role] ?? ''} {roleLabel[plane.role] ?? plane.role}{/if} + {#if engines}{engines}{/if} +
+
+
+
built
+
{plane.numberBuilt ? fmtCompact(plane.numberBuilt) : '—'}
+
+
+
{plane.maxSpeedKmh || !plane.cruiseKmh ? 'top speed' : 'cruise'}
+
+ {(plane.maxSpeedKmh ?? plane.cruiseKmh) + ? `${plane.maxSpeedKmh ?? plane.cruiseKmh} km/h` + : '—'} +
+
+
+
seats
+
{plane.passengers ? plane.passengers : '—'}
+
+
+
+ + diff --git a/src/lib/planes/shared.ts b/src/lib/planes/shared.ts new file mode 100644 index 0000000..2d4d923 --- /dev/null +++ b/src/lib/planes/shared.ts @@ -0,0 +1,188 @@ +// Shared (client + server) labels and helpers for the plane pokedex (/trackers/planes). +export { + dexLabel, + monogram, + brandHue, + fmtCompact, + fmtNumber, + thumbAt, + thumbSrcset, + commonsPage +} from '#lib/cars/shared.js'; +export type { CarPhoto as PlanePhoto } from '#lib/cars/shared.js'; + +export const ROLES = [ + 'airliner', + 'regional', + 'cargo', + 'business', + 'general aviation', + 'helicopter', + 'military', + 'glider', + 'airship' +] as const; +export const ENGINES = [ + 'jet', + 'turboprop', + 'piston', + 'turboshaft', + 'electric', + 'rocket', + 'none' +] as const; +export const ERAS = { + pioneer: { label: 'pioneers (–1918)', from: null, to: 1918 }, + golden: { label: 'golden age (1919–1938)', from: 1919, to: 1938 }, + ww2: { label: 'WW2 (1939–1945)', from: 1939, to: 1945 }, + jet: { label: 'jet age (1946–1969)', from: 1946, to: 1969 }, + modern: { label: 'modern (1970–1999)', from: 1970, to: 1999 }, + new: { label: '21st century (2000–)', from: 2000, to: null } +} as const; +export type PlaneEra = keyof typeof ERAS; +export const SORTS = { + built: 'most built', + dex: 'dex #', + name: 'name', + newest: 'newest', + oldest: 'oldest', + fastest: 'fastest', + biggest: 'heaviest', + range: 'longest range', + seats: 'most seats' +} as const; +export type PlaneSort = keyof typeof SORTS; + +export const roleLabel: Record = { + airliner: 'airliner', + regional: 'regional', + cargo: 'cargo', + business: 'business jet', + 'general aviation': 'light / GA', + helicopter: 'helicopter', + military: 'military', + glider: 'glider', + airship: 'airship' +}; +export const roleEmoji: Record = { + airliner: '🛫', + regional: '🛩️', + cargo: '📦', + business: '💼', + 'general aviation': '🛩️', + helicopter: '🚁', + military: '🎖️', + glider: '🪂', + airship: '🎈' +}; +export const engineLabel: Record = { + jet: 'jet', + turboprop: 'turboprop', + piston: 'piston', + turboshaft: 'turboshaft', + electric: 'electric', + rocket: 'rocket', + none: 'no engine' +}; +/** One beginner-friendly line per engine type. */ +export const engineHint: Record = { + jet: 'Gas turbine pushing air out the back (turbofan or turbojet): fast and high-flying.', + turboprop: 'A jet-style turbine turning a propeller: efficient on short hops.', + piston: + 'A car-like piston engine turning a propeller: simple and cheap, the classic small plane.', + turboshaft: 'A turbine that turns a shaft, usually a helicopter rotor.', + electric: 'Battery-powered electric motors.', + rocket: 'Rocket propulsion: very fast, very short flights.', + none: 'No engine: it glides on rising air.' +}; + +const FLAGS: Record = { + 'United States': '🇺🇸', + 'United States of America': '🇺🇸', + 'United Kingdom': '🇬🇧', + France: '🇫🇷', + Germany: '🇩🇪', + 'Soviet Union': '🇷🇺', + Russia: '🇷🇺', + Italy: '🇮🇹', + Japan: '🇯🇵', + Canada: '🇨🇦', + Brazil: '🇧🇷', + China: '🇨🇳', + "People's Republic of China": '🇨🇳', + Netherlands: '🇳🇱', + Sweden: '🇸🇪', + Spain: '🇪🇸', + Czechoslovakia: '🇨🇿', + 'Czech Republic': '🇨🇿', + Poland: '🇵🇱', + Switzerland: '🇨🇭', + Austria: '🇦🇹', + Australia: '🇦🇺', + Ukraine: '🇺🇦', + India: '🇮🇳', + Israel: '🇮🇱', + Romania: '🇷🇴', + Indonesia: '🇮🇩', + Argentina: '🇦🇷', + 'South Korea': '🇰🇷', + Turkey: '🇹🇷', + Belgium: '🇧🇪', + 'New Zealand': '🇳🇿', + Finland: '🇫🇮', + Slovenia: '🇸🇮', + Hungary: '🇭🇺', + Denmark: '🇩🇰', + Norway: '🇳🇴', + 'South Africa': '🇿🇦', + Iran: '🇮🇷', + Taiwan: '🇹🇼' +}; +export const flag = (country: string | null) => (country ? (FLAGS[country] ?? '') : ''); + +const compact = new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }); +export const fmtKg = (kg: number) => + kg >= 10_000 + ? `${compact.format(kg / 1000)} t` + : `${new Intl.NumberFormat('en-GB').format(kg)} kg`; +export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`; + +export type PlaneCard = { + dex: number; + slug: string; + name: string; + fullName: string; + makerSlug: string; + makerName: string; + makerLogo: string | null; + role: string | null; + engineType: string | null; + engineCount: number | null; + firstFlightYear: number | null; + inService: boolean | null; + status: string | null; + numberBuilt: number | null; + passengers: number | null; + maxSpeedKmh: number | null; + cruiseKmh: number | null; + icao: string[]; + photo: string | null; +}; + +export type MakerTileData = { + slug: string; + name: string; + types: number; + built: number; + logo: string | null; +}; + +export const statusLabel: Record = { + 'in production': 'in production', + 'in service': 'still flying', + retired: 'retired', + 'in development': 'in development' +}; + +export const LOGO_NOTE = + 'Maker 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.'; diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 48d1f9a..25070d5 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -135,6 +135,13 @@ const allTrackers = [ href: '/lists', external: false, private: false + }, + { + name: 'planes', + subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds', + href: '/trackers/planes', + external: false, + private: false } ] as const; diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 3cb86b5..2636446 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -49,6 +49,11 @@ const publicPages: Record = { description: 'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.' }, + '/trackers/planes': { + title: 'Plane pokedex', + description: + 'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.' + }, '/trackers/pc': { title: 'PC setup', description: diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts index cefae35..656b4c4 100644 --- a/src/lib/server/owner.test.ts +++ b/src/lib/server/owner.test.ts @@ -41,6 +41,8 @@ describe('owner gate', () => { expect(isOwnerWritePath('/trackers/health/import')).toBe(true); expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true); expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false); + expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true); + expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false); expect(isOwnerWritePath('/trackers/pc')).toBe(true); expect(isOwnerOnlyPath('/trackers/pc')).toBe(false); expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false); diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts index 33052eb..e712349 100644 --- a/src/lib/server/owner.ts +++ b/src/lib/server/owner.ts @@ -49,6 +49,7 @@ export const ownerWritePrefixes = [ '/trackers/houses', '/trackers/books', '/trackers/cars', + '/trackers/planes', '/trackers/music', '/trackers/pc' ] as const; diff --git a/src/lib/server/planes.ts b/src/lib/server/planes.ts new file mode 100644 index 0000000..7db3a07 --- /dev/null +++ b/src/lib/server/planes.ts @@ -0,0 +1,275 @@ +// Plane pokedex queries (/trackers/planes). Public, read-only; data is written only by +// scripts/planes-import.ts and scripts/planes-photos.ts. +import { db } from '#lib/server/db/index.js'; +import { planeMakers, planePhotos, planeTypes } from '#lib/server/db/schema/index.js'; +import { and, arrayContains, asc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm'; +import { + ENGINES, + ERAS, + ROLES, + SORTS, + type PlaneCard, + type PlaneEra, + type PlaneSort +} from '#lib/planes/shared.js'; + +export const PAGE_SIZE = 60; + +export type PlaneFilters = { + q: string; + maker: string; + role: string; + engine: string; + era: PlaneEra | ''; + service: '' | 'yes' | 'no'; + sort: PlaneSort; + page: number; +}; + +const pick = (value: string | null, allowed: readonly T[]): T | '' => + allowed.includes(value as T) ? (value as T) : ''; + +export function parseFilters(params: URLSearchParams): PlaneFilters { + 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, 60), + role: pick(params.get('role'), ROLES), + engine: pick(params.get('engine'), ENGINES), + era: pick(params.get('era'), Object.keys(ERAS) as PlaneEra[]), + service: pick(params.get('service'), ['yes', 'no'] as const), + sort: sort && sort in SORTS ? (sort as PlaneSort) : 'built', + page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000) + }; +} + +const cardColumns = { + dex: planeTypes.dex, + slug: planeTypes.slug, + name: planeTypes.name, + fullName: planeTypes.fullName, + makerSlug: planeMakers.slug, + makerName: planeMakers.name, + makerLogo: planeMakers.logo, + role: planeTypes.role, + engineType: planeTypes.engineType, + engineCount: planeTypes.engineCount, + firstFlightYear: planeTypes.firstFlightYear, + inService: planeTypes.inService, + status: planeTypes.status, + numberBuilt: planeTypes.numberBuilt, + passengers: planeTypes.passengers, + maxSpeedKmh: planeTypes.maxSpeedKmh, + cruiseKmh: planeTypes.cruiseKmh, + icao: planeTypes.icao, + photo: planeTypes.photo +}; + +const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); + +export async function searchPlanes( + f: PlaneFilters +): Promise<{ planes: PlaneCard[]; total: number }> { + const where: SQL[] = []; + for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6)) { + // "a320" also matches "a 320" / "a-320" in names via the compact form stored in search. + const compact = word.replace(/[^a-z0-9]/g, ''); + const like = ilike(planeTypes.search, `%${escapeLike(word)}%`); + where.push( + compact && compact !== word ? or(like, ilike(planeTypes.search, `%${compact}%`))! : like + ); + } + if (f.maker) where.push(eq(planeMakers.slug, f.maker)); + if (f.role) where.push(arrayContains(planeTypes.roles, [f.role])); + if (f.engine) where.push(eq(planeTypes.engineType, f.engine)); + if (f.era) { + const era = ERAS[f.era]; + if (era.from != null) where.push(gte(planeTypes.firstFlightYear, era.from)); + if (era.to != null) where.push(lte(planeTypes.firstFlightYear, era.to)); + } + if (f.service === 'yes') where.push(eq(planeTypes.inService, true)); + if (f.service === 'no') where.push(eq(planeTypes.inService, false)); + const order = { + built: [sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.dex)], + dex: [asc(planeTypes.dex)], + name: [asc(planeTypes.fullName)], + newest: [sql`${planeTypes.firstFlightYear} desc nulls last`, asc(planeTypes.dex)], + oldest: [sql`${planeTypes.firstFlightYear} asc nulls last`, asc(planeTypes.dex)], + fastest: [sql`${planeTypes.maxSpeedKmh} desc nulls last`, asc(planeTypes.dex)], + biggest: [sql`${planeTypes.mtowKg} desc nulls last`, asc(planeTypes.dex)], + range: [sql`${planeTypes.rangeKm} desc nulls last`, asc(planeTypes.dex)], + seats: [sql`${planeTypes.passengers} desc nulls last`, asc(planeTypes.dex)] + }[f.sort]; + const condition = where.length ? and(...where) : undefined; + const [planes, [{ total }]] = await Promise.all([ + db + .select(cardColumns) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where(condition) + .orderBy(...order) + .limit(PAGE_SIZE) + .offset((f.page - 1) * PAGE_SIZE), + db + .select({ total: sql`count(*)::int` }) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where(condition) + ]); + return { planes, total }; +} + +export type MakerTile = { + slug: string; + name: string; + types: number; + built: number; + country: string | null; + logo: string | null; +}; +type Overview = { + /** Alphabetical (for the filter); `byBuilt` has the same makers, most aircraft built first. */ + makers: MakerTile[]; + byBuilt: MakerTile[]; + stats: { makers: number; types: number; built: number; photos: number }; +}; +let overviewCache: { at: number; value: Overview } | null = null; + +/** Maker list for the filter and headline counts; cached for 10 minutes. */ +export async function planeOverview(): Promise { + if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value; + const [makers, [counts]] = await Promise.all([ + db + .select({ + slug: planeMakers.slug, + name: planeMakers.name, + types: planeMakers.typeCount, + built: planeMakers.builtTotal, + country: planeMakers.country, + logo: planeMakers.logo + }) + .from(planeMakers) + .orderBy(asc(planeMakers.name)), + db + .select({ + types: sql`count(*)::int`, + built: sql`coalesce(sum(${planeTypes.numberBuilt}) filter (where ${planeTypes.familyId} is null), 0)::bigint`, + photos: sql`coalesce(sum(${planeTypes.photoCount}), 0)::int` + }) + .from(planeTypes) + ]); + const value = { + makers, + byBuilt: [...makers].sort( + (a, b) => b.built - a.built || b.types - a.types || a.name.localeCompare(b.name) + ), + stats: { + makers: makers.length, + types: counts.types, + built: Number(counts.built), + photos: counts.photos + } + }; + if (value.stats.types) overviewCache = { at: Date.now(), value }; + return value; +} + +export async function getMaker(slug: string) { + const [maker] = await db.select().from(planeMakers).where(eq(planeMakers.slug, slug)).limit(1); + if (!maker) return null; + const types = await db + .select(cardColumns) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where(eq(planeTypes.makerId, maker.id)) + .orderBy(sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.fullName)); + const [hero] = maker.photo + ? await db + .select({ + file: planePhotos.file, + author: planePhotos.author, + license: planePhotos.license, + licenseUrl: planePhotos.licenseUrl, + type: planeTypes.name + }) + .from(planePhotos) + .innerJoin(planeTypes, eq(planeTypes.id, planePhotos.typeId)) + .where(and(eq(planeTypes.makerId, maker.id), eq(planePhotos.thumb, maker.photo))) + .limit(1) + : []; + return { maker, types, hero: hero ?? null }; +} + +export async function getType(makerSlug: string, typeSlug: string) { + const [row] = await db + .select({ type: planeTypes, maker: planeMakers }) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where(and(eq(planeMakers.slug, makerSlug), eq(planeTypes.slug, typeSlug))) + .limit(1); + if (!row) return null; + const t = row.type; + const [neighbours, photos, family] = await Promise.all([ + db + .select({ + dex: planeTypes.dex, + slug: planeTypes.slug, + name: planeTypes.fullName, + makerSlug: planeMakers.slug + }) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where( + sql`${planeTypes.dex} in ( + (select max(dex) from planes.types where dex < ${t.dex}), + (select min(dex) from planes.types where dex > ${t.dex}))` + ), + db + .select({ + file: planePhotos.file, + kind: planePhotos.kind, + caption: planePhotos.caption, + thumb: planePhotos.thumb, + width: planePhotos.width, + height: planePhotos.height, + author: planePhotos.author, + license: planePhotos.license, + licenseUrl: planePhotos.licenseUrl + }) + .from(planePhotos) + .where(eq(planePhotos.typeId, t.id)) + .orderBy(asc(planePhotos.position)), + // Parent (if any) and direct variants. + db + .select({ ...cardColumns, id: planeTypes.id }) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where( + t.familyId != null + ? or(eq(planeTypes.familyId, t.id), eq(planeTypes.id, t.familyId)) + : eq(planeTypes.familyId, t.id) + ) + .orderBy(sql`${planeTypes.firstFlightYear} asc nulls last`, asc(planeTypes.fullName)) + ]); + const parent = family.find((f) => f.id === t.familyId) ?? null; + return { + ...row, + photos, + parent, + 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 randomPlane() { + const [row] = await db + .select({ slug: planeTypes.slug, makerSlug: planeMakers.slug }) + .from(planeTypes) + .innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId)) + .where(sql`${planeTypes.photo} is not null and coalesce(${planeTypes.numberBuilt}, 0) >= 20`) + .orderBy(sql`random()`) + .limit(1); + return row ?? null; +} diff --git a/src/lib/title.ts b/src/lib/title.ts index 4f25811..994f9c5 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -20,6 +20,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str '/trackers': 'Trackers', '/trackers/books': 'Books', '/trackers/cars': 'Cars', + '/trackers/planes': 'Planes', '/trackers/pc': 'PC setup', '/blog': 'Blog', '/lists': 'Lists', diff --git a/src/lib/tracker-icons.ts b/src/lib/tracker-icons.ts index 7259745..2689040 100644 --- a/src/lib/tracker-icons.ts +++ b/src/lib/tracker-icons.ts @@ -114,6 +114,14 @@ export const trackerIcons: Record = { 'M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.8 1C6.5 17.5 4.5 17 4.5 17A3.5 3.5 0 0 1 5.5 10Z' ] }, + planes: { + from: '#7dd3fc', + to: '#0284c7', + ink: WHITE, + paths: [ + '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' + ] + }, lists: { from: '#94a3b8', to: '#475569', diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 93d657d..d22485b 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -16,6 +16,8 @@ const staticPaths = [ '/trackers/books', '/trackers/cars', '/trackers/cars/brands', + '/trackers/planes', + '/trackers/planes/makers', '/trackers/music', '/trackers/pc', '/projects/game-tracker', diff --git a/src/routes/trackers/planes/+page.server.ts b/src/routes/trackers/planes/+page.server.ts new file mode 100644 index 0000000..4368818 --- /dev/null +++ b/src/routes/trackers/planes/+page.server.ts @@ -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.` + } + }; +}; diff --git a/src/routes/trackers/planes/+page.svelte b/src/routes/trackers/planes/+page.svelte new file mode 100644 index 0000000..e312de9 --- /dev/null +++ b/src/routes/trackers/planes/+page.svelte @@ -0,0 +1,319 @@ + + +
+
+
+

planes

+

+ 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. +

+
+ random plane +
+ +
+
+
aircraft types
+
{fmtNumber(data.stats.types)}
+
+
+
makers
+
{fmtNumber(data.stats.makers)}
+
+
+
aircraft built
+
{fmtCompact(data.stats.built)}
+
+
+
photos
+
{fmtNumber(data.stats.photos)}
+
+
+ + {#if !filtering && f.page === 1} +
+ +
    + {#each data.topMakers as maker (maker.slug)} +
  • + {/each} +
+
+ {/if} + +
+ +
+ + + {#each data.makers as m (m.slug)}{/each} + + + + {#each ROLES as x (x)}{/each} + + + + {#each ENGINES as x (x)}{/each} + + + + {#each Object.entries(ERAS) as [key, era] (key)}{/each} + + + + + + + + {#each Object.entries(SORTS) as [key, label] (key)}{/each} + + +
+
+ +
+ {#each quick as chip (chip.label)} + {chip.label} + {/each} + {#if filtering} + clear filters + {/if} +
+ +

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

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

No planes match that.

+

+ Try fewer words or clear the filters. +

+
+ {/if} + + {#if pages > 1} + + {/if} + +
+ Data: Wikidata (CC0) and the + English Wikipedia infoboxes and + specification sections (CC BY-SA 4.0), ICAO codes from Wikipedia's + list of aircraft type designators. 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} +
+
+ + diff --git a/src/routes/trackers/planes/[maker]/+page.server.ts b/src/routes/trackers/planes/[maker]/+page.server.ts new file mode 100644 index 0000000..b5f7b5e --- /dev/null +++ b/src/routes/trackers/planes/[maker]/+page.server.ts @@ -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.` + } + }; +}; diff --git a/src/routes/trackers/planes/[maker]/+page.svelte b/src/routes/trackers/planes/[maker]/+page.svelte new file mode 100644 index 0000000..3d98137 --- /dev/null +++ b/src/routes/trackers/planes/[maker]/+page.svelte @@ -0,0 +1,151 @@ + + +
+ all planes + {#if b.photo} +
+ {b.name} {data.hero?.type ?? ''} + {#if data.hero} +
+ {b.name} + {data.hero.type} · + {data.hero.author ?? 'Wikimedia Commons'}{#if data.hero.license} + · {#if data.hero.licenseUrl}{data.hero.license}{:else}{data.hero.license}{/if}{/if} +
+ {/if} +
+ {/if} +
+ {#if b.logo} + + {:else} + + {/if} +
+

{b.name}

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

+ {b.fullName} +

{/if} +

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

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

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

+
+ + diff --git a/src/routes/trackers/planes/[maker]/[type]/+page.server.ts b/src/routes/trackers/planes/[maker]/[type]/+page.server.ts new file mode 100644 index 0000000..edb7bef --- /dev/null +++ b/src/routes/trackers/planes/[maker]/[type]/+page.server.ts @@ -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 = { ...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 = (row: T): Omit => { + const rest: Partial = { ...row }; + delete rest.id; + return rest as Omit; + }; + 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.` + } + }; +}; diff --git a/src/routes/trackers/planes/[maker]/[type]/+page.svelte b/src/routes/trackers/planes/[maker]/[type]/+page.svelte new file mode 100644 index 0000000..06f70c0 --- /dev/null +++ b/src/routes/trackers/planes/[maker]/[type]/+page.svelte @@ -0,0 +1,490 @@ + + +
+ + +
+
+ +
+

+ {dexLabel(p.dex)}{#if p.icao.length} + · ICAO {p.icao.join(', ')}{/if} +

+

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

+

+ {#if p.country}{flag(p.country)} {p.country} · + {/if}{p.firstFlightYear + ? `first flew ${p.firstFlightYear}` + : 'first flight unknown'}{#if p.roleLabel} + · {p.roleLabel}{/if} +

+
+
+
+ {#if p.role}{roleEmoji[p.role] ?? ''} {roleLabel[p.role] ?? p.role}{/if} + {#if p.engineType}{p.engineCount && p.engineCount > 1 ? `${p.engineCount}× ` : ''}{engineLabel[ + p.engineType + ] ?? p.engineType}{/if} + {#if p.inService === true}✓ still flying{:else if p.inService === false}retired{/if} +
+
+ + {#if data.photos.length} + + {/if} + + {#if p.summary || p.recognize} +
+

what is it?

+ {#if p.summary}

{p.summary}

{/if} + {#if p.recognize} +

+ How to recognise it: {p.recognize} +

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

base stats

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

+ 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). +

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

fun facts

+
    + {#each fun as f (f.text)}
  • + {f.text} +
  • {/each} +
+ {#if p.engineType && engineHint[p.engineType]} +

{engineLabel[p.engineType]}: {engineHint[p.engineType]}

+ {/if} +
+ {/if} + +
+

spec sheet

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

+ Specs are for the version in the Wikipedia article's specification section. Blank means the + sources don't say. +

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

family

+ +
+ {/if} + +

+ Sources: Wikidata {p.wikidata}{#if p.wikipedia}, Wikipedia: {p.wikipedia} (CC BY-SA 4.0){/if}{#if p.icao.length}, ICAO code from + list of aircraft type designators{/if}.{#if p.updatedAt} + Updated {p.updatedAt.slice(0, 10)}.{/if} +

+
+ + diff --git a/src/routes/trackers/planes/makers/+page.server.ts b/src/routes/trackers/planes/makers/+page.server.ts new file mode 100644 index 0000000..d215688 --- /dev/null +++ b/src/routes/trackers/planes/makers/+page.server.ts @@ -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.` + } + }; +}; diff --git a/src/routes/trackers/planes/makers/+page.svelte b/src/routes/trackers/planes/makers/+page.svelte new file mode 100644 index 0000000..d585d8a --- /dev/null +++ b/src/routes/trackers/planes/makers/+page.svelte @@ -0,0 +1,49 @@ + + +
+ all planes +
+

makers

+

+ All {fmtNumber(data.makers.length)} makers in the plane pokedex, the ones that built the most aircraft + first. Tap one for its aircraft. +

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

+ {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. +

+
+ + diff --git a/src/routes/trackers/planes/random/+server.ts b/src/routes/trackers/planes/random/+server.ts new file mode 100644 index 0000000..87ace94 --- /dev/null +++ b/src/routes/trackers/planes/random/+server.ts @@ -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'); +};