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,71 @@
<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="/trackers/planes/{maker.slug}" class={['tile', { compact }]} title={maker.name}>
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
<span class="name">{maker.name}</span>
{#if !compact}
<span class="meta"
>{maker.types}
{maker.types === 1 ? 'type' : 'types'}{#if maker.built}
· {fmtCompact(maker.built)} built{/if}</span
>
{/if}
</a>
<style>
.tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
height: 100%;
padding: 0.7rem 0.6rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
text-align: center;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.tile:hover,
.tile:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.mark {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 16 / 9;
}
.compact .mark {
aspect-ratio: 2 / 1;
}
.name {
max-width: 100%;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .name {
font-size: 0.75rem;
font-weight: 500;
color: var(--site-muted);
}
.meta {
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import {
brandHue,
dexLabel,
engineLabel,
fmtCompact,
monogram,
roleEmoji,
roleLabel,
thumbAt,
thumbSrcset,
type PlaneCard
} from './shared.js';
let { plane, eager = false }: { plane: PlaneCard; eager?: boolean } = $props();
const href = $derived(`/trackers/planes/${plane.makerSlug}/${plane.slug}`);
const engines = $derived(
plane.engineType && plane.engineType !== 'none'
? `${plane.engineCount && plane.engineCount > 1 ? `${plane.engineCount}× ` : ''}${engineLabel[plane.engineType] ?? plane.engineType}`
: plane.engineType === 'none'
? 'no engine'
: null
);
</script>
<a {href} class="card">
<div class="media" style:--hue={brandHue(plane.makerName)}>
{#if plane.photo}
<img
src={thumbAt(plane.photo, 330)}
srcset={thumbSrcset(plane.photo)}
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
alt={plane.fullName}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
width="330"
height="206"
/>
{:else}
<span class="placeholder" aria-hidden="true">{monogram(plane.makerName)}</span>
{/if}
<span class="badge"><BrandBadge name={plane.makerName} logo={plane.makerLogo} size="sm" /></span
>
<span class="dex">{dexLabel(plane.dex)}</span>
{#if plane.icao[0]}<span class="icao" title="ICAO type designator">{plane.icao[0]}</span>{/if}
</div>
<div class="min-w-0">
<p class="brand">{plane.makerName}</p>
<h3 class="name">{plane.name}</h3>
<p class="meta">
{plane.firstFlightYear
? `first flew ${plane.firstFlightYear}`
: '—'}{#if plane.inService === true}
· <span class="flying">still flying</span>{:else if plane.inService === false}
· retired{/if}
</p>
</div>
<div class="chips">
{#if plane.role}<span class="chip"
>{roleEmoji[plane.role] ?? ''} {roleLabel[plane.role] ?? plane.role}</span
>{/if}
{#if engines}<span class="chip">{engines}</span>{/if}
</div>
<dl class="nums">
<div title="Number built (Wikipedia / Wikidata)">
<dt>built</dt>
<dd>{plane.numberBuilt ? fmtCompact(plane.numberBuilt) : '—'}</dd>
</div>
<div title={plane.maxSpeedKmh ? 'Maximum speed' : 'Cruise speed'}>
<dt>{plane.maxSpeedKmh || !plane.cruiseKmh ? 'top speed' : 'cruise'}</dt>
<dd>
{(plane.maxSpeedKmh ?? plane.cruiseKmh)
? `${plane.maxSpeedKmh ?? plane.cruiseKmh} km/h`
: '—'}
</dd>
</div>
<div title="Passengers (typical maximum)">
<dt>seats</dt>
<dd>{plane.passengers ? plane.passengers : '—'}</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;
}
.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>

188
src/lib/planes/shared.ts Normal file
View File

@@ -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<string, string> = {
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<string, string> = {
airliner: '🛫',
regional: '🛩️',
cargo: '📦',
business: '💼',
'general aviation': '🛩️',
helicopter: '🚁',
military: '🎖️',
glider: '🪂',
airship: '🎈'
};
export const engineLabel: Record<string, string> = {
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<string, string> = {
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<string, string> = {
'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<string, string> = {
'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.';

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ export const ownerWritePrefixes = [
'/trackers/houses',
'/trackers/books',
'/trackers/cars',
'/trackers/planes',
'/trackers/music',
'/trackers/pc'
] as const;

275
src/lib/server/planes.ts Normal file
View File

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

View File

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

View File

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