cars: car pokedex tracker (brands, models, US variants from RDW + fueleconomy.gov), import script, search/filter UI, model pages
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-24 15:38:30 +00:00
parent cc4b10f239
commit e95b7da1e3
27 changed files with 21353 additions and 4 deletions

View File

@@ -14,6 +14,7 @@ const staticPaths = [
'/blog',
'/lists',
'/trackers/books',
'/trackers/cars',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -0,0 +1,20 @@
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ cars, total }, overview] = await Promise.all([searchCars(filters), carOverview()]);
return {
filters,
cars,
total,
pageSize: PAGE_SIZE,
brands: overview.brands,
stats: overview.stats,
currentYear: new Date().getFullYear(),
seo: {
title: 'Car pokedex',
description: `Search ${overview.stats.models.toLocaleString('en-GB')} car models from ${overview.stats.brands} brands, with Dutch registration data and US fuel economy specs.`
}
};
};

View File

@@ -0,0 +1,325 @@
<script lang="ts">
import { page } from '$app/state';
import CarCard from '#lib/cars/CarCard.svelte';
import {
BODIES,
bodyLabel,
DRIVES,
driveLabel,
FUELS,
fuelLabel,
fmtCompact,
fmtNumber,
SORTS
} from '#lib/cars/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.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
);
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/cars${qs ? `?${qs}` : ''}`;
}
const quick = [
{ label: '🔥 most common in NL', params: { sort: 'popular', market: 'nl' } },
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🐎 Mustang', params: { q: 'mustang' } }
];
</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">cars</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A car pokedex: every brand and model sold as a regular production car that I could find in
Dutch registrations or US fuel-economy tests. Tap one to learn what it is.
</p>
</div>
<a href="/trackers/cars/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random car</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>brands</dt>
<dd>{fmtNumber(data.stats.brands)}</dd>
</div>
<div>
<dt>models</dt>
<dd>{fmtNumber(data.stats.models)}</dd>
</div>
<div>
<dt>US versions</dt>
<dd>{fmtNumber(data.stats.variants)}</dd>
</div>
<div>
<dt>cars on Dutch roads</dt>
<dd>{fmtCompact(data.stats.nlCars)}</dd>
</div>
</dl>
<form
bind:this={form}
method="GET"
action="/trackers/cars"
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 'mustang', 'yaris', 'golf gti'…"
aria-label="Search cars"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="brand" aria-label="Brand" value={f.brand} onchange={submit}>
<option value="">all brands</option>
{#each data.brands as b (b.slug)}<option value={b.slug}>{b.name} ({b.models})</option
>{/each}
</NativeSelect>
<NativeSelect name="fuel" aria-label="Fuel" value={f.fuel} onchange={submit}>
<option value="">any fuel</option>
{#each FUELS as x (x)}<option value={x}>{fuelLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="body" aria-label="Body type" value={f.body} onchange={submit}>
<option value="">any body</option>
{#each BODIES as x (x)}<option value={x}>{bodyLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="drive" aria-label="Drive" value={f.drive} onchange={submit}>
<option value="">any drive</option>
{#each DRIVES as x (x)}<option value={x}>{driveLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="market" aria-label="Market" value={f.market} onchange={submit}>
<option value="">NL + US</option>
<option value="nl">registered in NL</option>
<option value="us">sold in the US</option>
</NativeSelect>
<span class="years">
<Input
type="number"
name="from"
value={f.from ?? ''}
min="1900"
max={data.currentYear}
placeholder="from"
aria-label="Built from year"
onchange={submit}
class="w-20"
/>
<span aria-hidden="true">–</span>
<Input
type="number"
name="to"
value={f.to ?? ''}
min="1900"
max={data.currentYear}
placeholder="to"
aria-label="Built up to year"
onchange={submit}
class="w-20"
/>
</span>
<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/cars" 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 ? 'model' : 'models'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if data.cars.length}
<ul class="grid">
{#each data.cars as car (car.dex)}
<li><CarCard {car} current={data.currentYear} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No cars match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/cars" 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://opendata.rdw.nl" class="underline">RDW open data</a> (Dutch vehicle
register, CC0) and
<a href="https://www.fueleconomy.gov/feg/download.shtml" class="underline">fueleconomy.gov</a>
(US EPA/DOE). Models are grouped from registered trade names, so a few odd splits remain. Prices are
average catalogue prices of new cars when first registered in NL. Blank means unknown.
</footer>
</div>
<style>
.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;
}
.years {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--site-muted);
}
.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,27 @@
import { error } from '@sveltejs/kit';
import { getBrand } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const found = await getBrand(params.brand);
if (!found) error(404, 'No such brand in the car pokedex');
const { brand, models } = found;
return {
brand: {
slug: brand.slug,
name: brand.name,
country: brand.country,
modelCount: brand.modelCount,
nlCount: brand.nlCount,
firstYear: brand.firstYear,
lastYear: brand.lastYear,
sources: brand.sources
},
models,
currentYear: new Date().getFullYear(),
seo: {
title: `${brand.name} cars`,
description: `All ${brand.modelCount} ${brand.name} models in Stan's car pokedex${brand.country ? ` (${brand.country})` : ''}, with Dutch registrations and specs.`
}
};
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import CarCard from '#lib/cars/CarCard.svelte';
import { flag, fmtNumber, years } from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.brand);
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/cars" class="back"><ArrowLeft size={15} /> all cars</a>
<header class="mt-6 mb-8 flex items-center gap-5">
<BrandBadge name={b.name} size="lg" />
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{#if b.country}{flag(b.country)} {b.country} ·
{/if}{fmtNumber(b.modelCount)} models{#if b.firstYear}
· {years(b.firstYear, b.lastYear, data.currentYear)}{/if}{#if b.nlCount}
· {fmtNumber(b.nlCount)} on Dutch roads{/if}
</p>
</div>
</header>
<ul class="grid">
{#each data.models as car (car.dex)}
<li><CarCard {car} current={data.currentYear} /></li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">
Sorted by how many are registered in NL. Sources: {b.sources.join(', ')}.
</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;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
}
</style>

View File

@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import { getModel } from '#lib/server/cars.js';
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
import type { PageServerLoad } from './$types';
type Model = typeof carModels.$inferSelect;
type Variant = typeof carVariants.$inferSelect;
// Drop internal ids and the search blob before the row goes to the browser.
function publicCar(m: Model) {
const out: Partial<Model> = { ...m };
delete out.id;
delete out.brandId;
delete out.search;
delete out.createdAt;
delete out.updatedAt;
return {
...(out as Omit<Model, 'id' | 'brandId' | 'search' | 'createdAt' | 'updatedAt'>),
updatedAt: m.updatedAt?.toISOString() ?? null
};
}
function publicVariant(v: Variant) {
const out: Partial<Variant> = { ...v };
delete out.id;
delete out.modelId;
delete out.source;
return out as Omit<Variant, 'id' | 'modelId' | 'source'>;
}
export const load: PageServerLoad = async ({ params }) => {
const found = await getModel(params.brand, params.model);
if (!found) error(404, 'No such car in the pokedex');
const { model, brand, variants, prev, next } = found;
return {
car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country },
variants: variants.map(publicVariant),
prev,
next,
currentYear: new Date().getFullYear(),
seo: {
title: `${brand.name} ${model.name}`,
description: `${brand.name} ${model.name}${model.firstYear ? ` (${model.firstYear}${model.lastYear && model.lastYear !== model.firstYear ? `–${model.lastYear}` : ''})` : ''}: specs, fuel types, Dutch registrations and US fuel economy.`
}
};
};

View File

@@ -0,0 +1,576 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import FuelChip from '#lib/cars/FuelChip.svelte';
import {
bodyLabel,
dexLabel,
driveLabel,
flag,
fmtEuro,
fmtNumber,
fuelHint,
kwToHp,
mpgToL100,
range,
years
} from '#lib/cars/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 car = $derived(data.car);
const variants = $derived(data.variants);
const clamp = (n: number) => Math.max(0.03, Math.min(1, n));
// Pokedex-style "base stats": each bar is scaled against the whole car world, not this model.
const baseStats = $derived(
[
car.powerKwMax != null && {
label: 'power',
value: `${kwToHp(car.powerKwMax)} hp`,
hint: 'top version',
score: clamp(kwToHp(car.powerKwMax) / 600),
hue: 8
},
car.co2Min != null && {
label: 'efficiency',
value: car.co2Min === 0 ? '0 g CO₂/km' : `${car.co2Min} g CO₂/km`,
hint: 'cleanest version',
score: clamp(1 - car.co2Min / 300),
hue: 140
},
car.nlCount > 0 && {
label: 'popularity',
value: `${fmtNumber(car.nlCount)} in NL`,
hint: car.nlRank ? `#${car.nlRank} most common` : '',
score: clamp(Math.log10(car.nlCount) / Math.log10(400_000)),
hue: 210
},
car.nlPriceLatest != null && {
label: 'price',
value: fmtEuro(car.nlPriceLatest),
hint: `new, ${car.nlPriceLatestYear}`,
score: clamp(car.nlPriceLatest / 150_000),
hue: 45
},
car.massKg != null && {
label: 'weight',
value: `${fmtNumber(car.massKg)} kg`,
hint: 'average, ready to drive',
score: clamp((car.massKg - 500) / 2500),
hue: 280
}
].filter((s) => !!s)
);
const nlMax = $derived(Math.max(1, ...car.nlYears.map((y) => y.n)));
const nlYears = $derived.by(() => {
// Fill gaps so the chart has one bar per year.
if (!car.nlYears.length) return [];
const byYear = new Map(car.nlYears.map((y) => [y.y, y]));
const first = car.nlYears[0].y;
const last = car.nlYears.at(-1)!.y;
return Array.from(
{ length: last - first + 1 },
(_, i) => byYear.get(first + i) ?? { y: first + i, n: 0, price: null }
);
});
const peak = $derived(car.nlYears.reduce((a, b) => (b.n > a.n ? b : a), car.nlYears[0]));
const used = $derived(
car.nlYears.filter(
(y) => y.y <= data.currentYear - 8 && y.y >= data.currentYear - 20 && y.n >= 20
)
);
const facts = $derived([
['built', years(car.firstYear, car.lastYear, data.currentYear)],
['body', car.bodies.map((b) => bodyLabel[b] ?? b).join(', ')],
['engine', range(car.engineLMin, car.engineLMax, (n) => `${n.toFixed(1)} L`)],
['cylinders', range(car.cylindersMin, car.cylindersMax)],
['turbo', car.turbo == null ? null : car.turbo ? 'some versions' : 'no'],
[
'power',
range(car.powerKwMin, car.powerKwMax, (n) => `${kwToHp(n)}`) &&
`${range(car.powerKwMin, car.powerKwMax, (n) => `${kwToHp(n)}`)} hp`
],
['drive', car.drives.map((d) => driveLabel[d] ?? d).join(', ')],
['gearbox', car.transmissions.join(', ')],
['seats', car.seats],
['doors', car.doors],
['weight', car.massKg && `${fmtNumber(car.massKg)} kg`],
[
'fuel use',
range(car.l100Min, car.l100Max, (n) => n.toFixed(1)) &&
`${range(car.l100Min, car.l100Max, (n) => n.toFixed(1))} L/100 km`
],
['CO₂', range(car.co2Min, car.co2Max) && `${range(car.co2Min, car.co2Max)} g/km`],
['US rating', range(car.mpgMin, car.mpgMax) && `${range(car.mpgMin, car.mpgMax)} mpg`],
['EV range', car.rangeKmMax && `up to ${fmtNumber(car.rangeKmMax)} km`],
['US size class', car.sizeClasses.join(', ')],
['avg new price NL', car.nlPrice && fmtEuro(car.nlPrice)]
] as [string, string | number | null][]);
let showAll = $state(false);
const shownVariants = $derived(showAll ? variants : variants.slice(0, 25));
const shortDrive = (d: string | null) =>
d
?.replace(/Front-Wheel Drive/i, 'FWD')
.replace(/Rear-Wheel Drive/i, 'RWD')
.replace(/All-Wheel Drive/i, 'AWD')
.replace(/4-Wheel or All-Wheel Drive/i, '4WD/AWD')
.replace(/4-Wheel Drive/i, '4WD')
.replace(/Part-time 4-Wheel Drive/i, 'part-time 4WD') ?? '—';
</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/cars" class="back"><ArrowLeft size={15} /> all cars</a>
<span class="flex items-center gap-1">
{#if data.prev}<a
href="/trackers/cars/{data.prev.brandSlug}/{data.prev.slug}"
class="step"
title={data.prev.name}><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
>{/if}
<a
href="/trackers/cars/random"
class="step"
data-sveltekit-preload-data="off"
title="Random car"><Shuffle size={14} /></a
>
{#if data.next}<a
href="/trackers/cars/{data.next.brandSlug}/{data.next.slug}"
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/cars/{data.brand.slug}" aria-label="{data.brand.name} models"
><BrandBadge name={data.brand.name} size="lg" /></a
>
<div class="min-w-0">
<p class="dex">{dexLabel(car.dex)}</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
<a href="/trackers/cars/{data.brand.slug}" class="brand">{data.brand.name}</a>
{car.name}
</h1>
<p class="mt-2 text-[var(--site-muted)]">
{#if data.brand.country}{flag(data.brand.country)} {data.brand.country} ·
{/if}{years(car.firstYear, car.lastYear, data.currentYear) ??
'years unknown'}{#if car.bodies.length}
· {car.bodies.map((b) => bodyLabel[b] ?? b).join(' / ')}{/if}
</p>
</div>
</div>
{#if car.fuels.length}
<div class="mt-5 flex flex-wrap gap-2">
{#each car.fuels as fuel (fuel)}<FuelChip {fuel} title={fuelHint[fuel]} />{/each}
</div>
{/if}
</header>
{#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 cars: 600 hp, 0 g CO₂, 400k registrations, €150k.</p>
</section>
{/if}
{#if car.fuels.length}
<section class="card" aria-labelledby="fuels">
<h2 id="fuels">what the fuel types mean</h2>
<ul class="hints">
{#each car.fuels as fuel (fuel)}
<li><FuelChip {fuel} /> <span>{fuelHint[fuel]}</span></li>
{/each}
</ul>
</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">Ranges cover every version ever sold. Blank means the sources don't say.</p>
</section>
{#if nlYears.length}
<section class="card" aria-labelledby="nl">
<h2 id="nl">🇳🇱 on Dutch roads</h2>
<p class="lead">
<strong>{fmtNumber(car.nlCount)}</strong> are registered in the Netherlands today{#if car.nlRank},
#{car.nlRank} of all models{/if}. Most common build year: <strong>{peak.y}</strong>
({fmtNumber(peak.n)} cars).
</p>
<div class="chart" role="img" aria-label="Registered cars per year of first registration">
{#each nlYears as y (y.y)}
<span
class="col"
title="{y.y}: {fmtNumber(y.n)} cars{y.price ? `, new ≈ ${fmtEuro(y.price)}` : ''}"
><span class="bar" style:height="{Math.max(y.n ? 2 : 0, (y.n / nlMax) * 100)}%"
></span></span
>
{/each}
</div>
<div class="axis"><span>{nlYears[0].y}</span><span>{nlYears.at(-1)?.y}</span></div>
<p class="note">Cars still registered, by year of first registration (RDW).</p>
{#if used.length}
<h3>secondhand shopping hint</h3>
<p class="text-sm text-[var(--site-muted)]">
Cars 8–20 years old are where budget buys usually live. The register has
<strong class="text-[var(--site-text)]"
>{fmtNumber(used.reduce((a, y) => a + y.n, 0))}</strong
>
{car.name}s from {used[0].y}–{used.at(-1)?.y}, so there should be plenty to choose from on
Marktplaats or AutoScout24. Their price when new:
</p>
<ul class="prices">
{#each used.filter((y) => y.price).slice(-6) as y (y.y)}
<li><span>{y.y}</span><strong>{fmtEuro(y.price!)}</strong></li>
{/each}
</ul>
{/if}
{#if car.nlNames.length > 1}
<details class="mt-4">
<summary>names it's registered under ({car.nlNames.length})</summary>
<ul class="names">
{#each car.nlNames as n (n.name)}<li>{n.name} <span>{fmtNumber(n.n)}</span></li>{/each}
</ul>
</details>
{/if}
</section>
{/if}
{#if variants.length}
<section class="card" aria-labelledby="us">
<h2 id="us">🇺🇸 US versions ({fmtNumber(variants.length)})</h2>
<p class="note mb-3">
Every version the EPA tested{#if car.usYears.length}, model years {car
.usYears[0]}–{car.usYears.at(-1)}{/if}. mpg is combined; L/100 km converted.
</p>
<div class="table-wrap">
<table>
<thead>
<tr
><th>year</th><th>version</th><th>engine</th><th>gearbox</th><th>drive</th><th
>fuel</th
><th class="num">mpg</th><th class="num">L/100</th></tr
>
</thead>
<tbody>
{#each shownVariants as v, i (i)}
<tr>
<td>{v.year}</td>
<td>{v.name}</td>
<td
>{#if v.evMotor && !v.engineL}{v.evMotor}{:else}{v.engineL
? `${v.engineL.toFixed(1)} L`
: ''}{v.cylinders ? ` ${v.cylinders} cyl` : ''}{v.turbo
? ' turbo'
: ''}{v.supercharged ? ' SC' : ''}{/if}{#if v.rangeMi}
· {Math.round(v.rangeMi * 1.609)} km range{/if}</td
>
<td>{v.transmission ?? '—'}</td>
<td>{shortDrive(v.drive)}</td>
<td>{v.fuel ?? '—'}</td>
<td class="num">{v.mpgComb ?? '—'}</td>
<td class="num"
>{v.mpgComb && v.fuel !== 'Electricity'
? mpgToL100(v.mpgComb).toFixed(1)
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{#if variants.length > 25}
<button type="button" class="more" onclick={() => (showAll = !showAll)}
>{showAll ? 'show fewer' : `show all ${fmtNumber(variants.length)}`}</button
>
{/if}
</section>
{/if}
<p class="mt-8 text-xs text-[var(--site-muted)]">
Sources: {#each car.sources as s, i (s)}{i ? ', ' : ''}{#if s === 'RDW open data'}<a
href="https://opendata.rdw.nl/Voertuigen/Open-Data-RDW-Gekentekende_voertuigen/m9d7-ebf2"
class="underline">RDW open data</a
>{:else if s === 'fueleconomy.gov'}<a
href="https://www.fueleconomy.gov/feg/download.shtml"
class="underline">fueleconomy.gov</a
>{:else}{s}{/if}{/each}.{#if car.updatedAt}
Updated {car.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;
}
h3 {
font-size: 0.9rem;
font-weight: 600;
margin: 1.25rem 0 0.4rem;
}
.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);
}
.hints {
display: grid;
gap: 0.5rem;
font-size: 0.85rem;
}
.hints li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.hints span:last-child {
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;
}
.lead {
font-size: 0.9rem;
color: var(--site-muted);
margin-bottom: 1rem;
}
.lead strong {
color: var(--site-text);
}
.chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 8rem;
}
.col {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.col:hover .bar {
background: hsl(210 90% 70%);
}
.bar {
width: 100%;
border-radius: 2px 2px 0 0;
background: hsl(210 70% 55%);
}
.axis {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--site-muted);
margin-top: 0.25rem;
}
.prices {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.prices li {
display: flex;
flex-direction: column;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
.prices span {
color: var(--site-muted);
}
summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--site-muted);
}
.names {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.78rem;
}
.names li {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.1rem 0.6rem;
}
.names span {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
font-size: 0.78rem;
border-collapse: collapse;
}
th {
text-align: left;
font-weight: 500;
color: var(--site-muted);
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.more {
margin-top: 0.75rem;
font-size: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.25rem 0.8rem;
}
.more:hover {
background: var(--site-hover);
}
</style>

View File

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