294 lines
9.7 KiB
TypeScript
294 lines
9.7 KiB
TypeScript
// 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';
|
|
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
|
|
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
|
|
|
|
export const PAGE_SIZE = 60;
|
|
|
|
export type PlaneFilters = {
|
|
q: string;
|
|
maker: string;
|
|
role: string;
|
|
engine: string;
|
|
era: PlaneEra | '';
|
|
service: '' | 'yes' | 'no';
|
|
sort: PlaneSort;
|
|
/** Area for the "most active" sort (?in=, default NL). */
|
|
area: string;
|
|
got: Got;
|
|
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) : 'active',
|
|
area: parseArea(params.get('in')),
|
|
got: parseGot(params.get('got')),
|
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
|
};
|
|
}
|
|
|
|
const cardColumns = {
|
|
id: planeTypes.id,
|
|
seen: seenSql('planes', planeTypes.id),
|
|
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 got = gotSql('planes', planeTypes.id, f.got);
|
|
if (got) where.push(got);
|
|
const order = {
|
|
active: [
|
|
sql`${activitySql('planes', planeTypes.id, f.area)} desc`,
|
|
sql`${planeTypes.numberBuilt} desc nulls last`,
|
|
asc(planeTypes.dex)
|
|
],
|
|
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 = {
|
|
id: number;
|
|
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({
|
|
id: planeMakers.id,
|
|
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;
|
|
}
|