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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -14,6 +14,7 @@ const staticPaths = [
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
|
||||
20
src/routes/trackers/cars/+page.server.ts
Normal file
20
src/routes/trackers/cars/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
325
src/routes/trackers/cars/+page.svelte
Normal file
325
src/routes/trackers/cars/+page.svelte
Normal 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>
|
||||
27
src/routes/trackers/cars/[brand]/+page.server.ts
Normal file
27
src/routes/trackers/cars/[brand]/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
53
src/routes/trackers/cars/[brand]/+page.svelte
Normal file
53
src/routes/trackers/cars/[brand]/+page.svelte
Normal 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>
|
||||
46
src/routes/trackers/cars/[brand]/[model]/+page.server.ts
Normal file
46
src/routes/trackers/cars/[brand]/[model]/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
576
src/routes/trackers/cars/[brand]/[model]/+page.svelte
Normal file
576
src/routes/trackers/cars/[brand]/[model]/+page.svelte
Normal 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>
|
||||
9
src/routes/trackers/cars/random/+server.ts
Normal file
9
src/routes/trackers/cars/random/+server.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user