cars: brand logos everywhere (search cards, brand grid, model headers), stricter logo matching + curated fixes, logo refresh in cars-photos
All checks were successful
Deploy production / deploy (push) Successful in 2m24s

This commit is contained in:
2026-10-24 17:37:43 +00:00
parent fac49b57ba
commit 8442d59b68
14 changed files with 664 additions and 42 deletions

View File

@@ -27,11 +27,13 @@ const flag = (name: string) => args.includes(name);
const opt = (name: string) => (args.includes(name) ? args[args.indexOf(name) + 1] : null); const opt = (name: string) => (args.includes(name) ? args[args.indexOf(name) + 1] : null);
const dryRun = flag('--dry-run'); const dryRun = flag('--dry-run');
const missingOnly = flag('--missing'); const missingOnly = flag('--missing');
const logosOnly = flag('--logos'); // brand logos only, models untouched
const enrichFile = opt('--enrich'); const enrichFile = opt('--enrich');
const modelsFile = opt('--models'); const modelsFile = opt('--models');
const cacheDir = opt('--cache'); const cacheDir = opt('--cache');
const outFile = opt('--out'); const outFile = opt('--out');
const MAX_PHOTOS = 12; const MAX_PHOTOS = 12;
const LOGO = 250; // logo render width (SVGs come back as PNG thumbnails)
const THUMB = 960; // Wikimedia standard thumbnail widths: 120 250 330 500 960 1280 1920 const THUMB = 960; // Wikimedia standard thumbnail widths: 120 250 330 500 960 1280 1920
const UA = const UA =
'StanRungeCarDex/1.0 (https://stanrunge.dev/trackers/cars; car pokedex photo matcher) bun'; 'StanRungeCarDex/1.0 (https://stanrunge.dev/trackers/cars; car pokedex photo matcher) bun';
@@ -256,6 +258,352 @@ SELECT ?m (SAMPLE(?l) AS ?label) (SAMPLE(?logo) AS ?logo) (SAMPLE(?a) AS ?articl
return { items, makers, children }; return { items, makers, children };
} }
// ---------------------------------------------------------------- brand logos
type Brand = { slug: string; name: string; wikidata: string | null; hasLogo: boolean };
type LogoItem = {
id: string;
label: string;
article: string | null;
category: string | null;
links: number;
classes: Set<string>;
industries: Set<string>;
logos: string[]; // best first
};
/** Registration names whose brand goes by another name on Wikidata/Wikipedia. */
const BRAND_NAMES: Record<string, string[]> = {
'BMW Alpina': ['Alpina'],
'Auto Bianchi': ['Autobianchi'],
'VW-Porsche': ['Volkswagen-Porsche'],
KGM: ['KG Mobility'],
SsangYong: ['SsangYong Motor'],
Dfsk: ['DFSK'],
Faw: ['FAW', 'FAW Group'],
Austin: ['Austin Motor Company'],
Daimler: ['Daimler Company'],
Nekaf: ['NEKAF'],
INEOS: ['Ineos Automotive', 'Ineos Grenadier'],
STI: ['Subaru Tecnica International'],
'Lynk & Co': ['Lynk & Co'],
Smart: ['Smart (marque)'],
'Aston Martin': ['Aston Martin Lagonda'],
Ram: ['Ram Trucks'],
Think: ['Think Global'],
Shelby: ['Shelby American', 'Carroll Shelby International']
};
const AUTO_INDUSTRY = new Set(['Q190117', 'Q1420', 'Q3266850']); // automotive industry, car, ...
const AUTO_CLASS = new Set([
'Q786820', // automobile manufacturer
'Q2001319', // automobile marque
'Q16889133', // vehicle brand? (checked below against the data)
'Q1137109', // car brand
'Q19866001' // motor vehicle manufacturer
]);
const CORP_SUFFIX =
/[\s,]+(motors?|motor cars?|motor car company|motor company|motor corporation|automobiles?|automobili|automobil|automotive|auto|cars?|company|corporation|corp\.?|inc\.?|ltd\.?|limited|llc|gmbh|kg|ag|co\.?|s\.?a\.?|s\.?p\.?a\.?|marque|brand)$/i;
/** "Bentley Motors Limited", "Mini (marque)" and "Škoda Auto" all reduce to the bare brand. */
const brandKey = (s: string) => {
let t = s.replace(/\s*\([^)]*\)\s*$/, '').trim();
for (let prev = ''; prev !== t;) {
prev = t;
t = t.replace(CORP_SUFFIX, '').trim();
}
return cmp(t);
};
const LOGO_WORD = /logo|wordmark|crest|emblem|marque|brand|logotip/i;
const FREE_PD = /public domain|^pd\b|cc0/i;
/** A file is only used when it is clearly a drawn logo, never a photo of a badge on a car. */
const BAD_LOGOS = new Set(['Morris motor logo.jpg']); // photos of badges despite the name
function logoFile(file: string, brand: string[]): Photo | null {
if (BAD_LOGOS.has(file)) return null;
const photo = toPhoto(file, 'logo', null, LOGO);
if (!photo) return null;
const named = brand.some((k) => cmp(file).includes(k));
if (/\.svg$/i.test(file)) return photo;
if (/\.(png|gif)$/i.test(file))
return LOGO_WORD.test(file) || (FREE_PD.test(photo.license ?? '') && named) ? photo : null;
if (/\.(jpe?g|webp)$/i.test(file))
return LOGO_WORD.test(file) && FREE_PD.test(photo.license ?? '') ? photo : null;
return null;
}
/** Category files must be "<Brand> logo 2021.svg"-like: no F1 team, motorcycle or parent names. */
const PLAIN_WORDS = new Set(
'LOGO LOGOS LOGOTYPE WORDMARK EMBLEM BRAND MARQUE SYMBOL ICON BADGE CREST NEW OLD CURRENT SIMPLE FLAT COLOR COLOUR COLOURED COLORED BLACK WHITE GREY GRAY RED BLUE SILVER VERTICAL HORIZONTAL STACKED TRANSPARENT AUTOMOBILE AUTOMOBILES AUTOMOBILI CARS CAR MOTOR MOTORS COMPANY SVG PNG'.split(
' '
)
);
function plainLogoName(file: string, names: string[]) {
const t = tokens(file.replace(/\.[a-z]+$/i, ''));
for (const n of names) {
const nt = tokens(n);
const at = t.findIndex((_, i) => nt.every((x, j) => t[i + j] === x));
if (at < 0) continue;
const rest = [...t.slice(0, at), ...t.slice(at + nt.length)];
if (
rest.some((x) => x.startsWith('LOGO')) &&
rest.every((x) => PLAIN_WORDS.has(x) || /^\d+$/.test(x))
)
return true;
}
return false;
}
/** Hand-checked Commons logos for brands whose Wikidata item has none (or only photos). */
const LOGO_FILES: Record<string, string> = {
// Wikidata prefers the group/corporate logo for these; the car brand has its own.
BMW: 'BMW logo (gray).svg',
Renault: 'Renault 2021 Text.svg',
Seat: 'SEAT Logo from 2017.svg',
SsangYong: 'Ssangyong company logo.svg',
Bentley: 'Bentley (Logo 1919).png',
Caterham: 'Logo of Caterham Cars.png',
NSU: 'NSU 1960 Logo.svg',
Scion: 'Scion logo.png',
Yugo: 'Yugo-logo.svg',
Mahindra: 'Mahindra Automotive new logo.png'
};
/** Registration names that are not the company Wikidata knows under that name. */
const BRAND_SKIP = new Set(['Iav']);
async function logoItems(ids: string[]) {
type Row = Record<string, string | undefined>;
const out = new Map<string, LogoItem>();
for (let i = 0; i < ids.length; i += 200) {
const chunk = ids.slice(i, i + 200);
const rows = await sparql<Row>(`
SELECT ?item (SAMPLE(?l) AS ?label) (SAMPLE(?a) AS ?article) (SAMPLE(?c) AS ?cat) (SAMPLE(?sl) AS ?links)
(GROUP_CONCAT(DISTINCT STRAFTER(STR(?cls), "entity/"); separator="|") AS ?classes)
(GROUP_CONCAT(DISTINCT STRAFTER(STR(?ind), "entity/"); separator="|") AS ?industries)
(GROUP_CONCAT(DISTINCT CONCAT(STR(?logo), ";", STR(?rank), ";", COALESCE(STR(YEAR(?start)), ""), ";", IF(BOUND(?end), "end", "")); separator="|") AS ?logos)
WHERE {
VALUES ?item { ${chunk.map((q) => `wd:${q}`).join(' ')} }
OPTIONAL { ?item rdfs:label ?l FILTER(LANG(?l) = "en") }
OPTIONAL { ?a schema:about ?item; schema:isPartOf <https://en.wikipedia.org/> }
OPTIONAL { ?item wdt:P373 ?c }
OPTIONAL { ?item wikibase:sitelinks ?sl }
OPTIONAL { ?item wdt:P31 ?cls }
OPTIONAL { ?item wdt:P452 ?ind }
OPTIONAL {
?item p:P154 ?st . ?st ps:P154 ?logo ; wikibase:rank ?rank .
FILTER(?rank != wikibase:DeprecatedRank)
OPTIONAL { ?st pq:P580 ?start } OPTIONAL { ?st pq:P582 ?end }
}
} GROUP BY ?item`);
for (const r of rows) {
const id = qid(r.item!);
const logos = split(r.logos)
.map((x) => {
const [uri, rank, start, end] = x.split(';');
return {
file: fileName(uri),
current: !end,
score:
(rank.endsWith('PreferredRank') ? 10000 : 0) + (end ? 0 : 5000) + (Number(start) || 0)
};
})
.sort((a, b) => b.score - a.score);
// Logos with an end date are history; only used when nothing current is known.
const current = logos.filter((x) => x.current);
out.set(id, {
id,
label: r.label ?? id,
article: r.article
? decodeURIComponent(r.article.split('/wiki/').at(-1)!).replace(/_/g, ' ')
: null,
category: r.cat ?? null,
links: Number(r.links ?? 0),
classes: new Set(split(r.classes)),
industries: new Set(split(r.industries)),
logos: [...new Set((current.length ? current : logos).map((x) => x.file))]
});
}
}
return out;
}
/** The logo= file of an enwiki infobox, only if it lives on Commons (local files are fair use). */
async function infoboxLogo(article: string) {
const res = (await mw('en.wikipedia.org', {
action: 'query',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
rvsection: '0',
redirects: '1',
titles: article
})) as { query?: { pages?: { revisions?: { slots?: { main?: { content?: string } } }[] }[] } };
const text = res.query?.pages?.[0]?.revisions?.[0]?.slots?.main?.content ?? '';
const m = text.match(
/\|\s*logo\s*=\s*(?:\[\[)?(?:File:|Image:)?\s*([^|\]\n{}<]+?\.(?:svg|png|gif))/i
);
return m ? m[1].trim().replace(/_/g, ' ') : null;
}
async function findLogos(
brands: Brand[],
makers: Map<string, { id: string; label: string }>
): Promise<{
logos: Map<
string,
{ photo: Photo; wikidata: string | null; article: string | null; via: string }
>;
brandPick: Map<string, { id: string; label: string; article: string | null }>;
}> {
brands = brands.filter((b) => !BRAND_SKIP.has(b.name));
log(`logos: looking up ${brands.length} brands`);
const names = new Map(brands.map((b) => [b.slug, [b.name, ...(BRAND_NAMES[b.name] ?? [])]]));
const keys = new Map(brands.map((b) => [b.slug, [...new Set(names.get(b.slug)!.map(brandKey))]]));
// Candidates: Wikidata search hits for every name, the stored item and car-model makers.
const cands = new Map<string, Set<string>>();
for (const b of brands) {
const set = new Set<string>();
if (b.wikidata) set.add(b.wikidata);
for (const m of makers.values())
if (keys.get(b.slug)!.includes(brandKey(m.label))) set.add(m.id);
for (const n of names.get(b.slug)!) {
const res = (await mw('www.wikidata.org', {
action: 'wbsearchentities',
search: n,
language: 'en',
type: 'item',
limit: '20'
})) as { search?: { id: string }[] };
for (const hit of res.search ?? []) set.add(hit.id);
}
cands.set(b.slug, set);
}
const items = await logoItems([...new Set([...cands.values()].flatMap((s) => [...s]))]);
await saveApiCache(true);
const logos = new Map<
string,
{ photo: Photo; wikidata: string | null; article: string | null; via: string }
>();
const brandPick = new Map<string, { id: string; label: string; article: string | null }>();
const ranked = new Map<string, LogoItem[]>();
for (const b of brands) {
const k = keys.get(b.slug)!;
const ok = [...cands.get(b.slug)!]
.map((id) => items.get(id))
.filter((i): i is LogoItem => !!i)
// Named like the brand (the article only counts for items without an English label).
.filter((i) => k.includes(brandKey(i.label === i.id && i.article ? i.article : i.label)))
.filter(
(i) =>
makers.has(i.id) ||
[...i.industries].some((x) => AUTO_INDUSTRY.has(x)) ||
[...i.classes].some((x) => AUTO_CLASS.has(x))
);
const score = (i: LogoItem) =>
(makers.has(i.id) ? 4 : 0) +
([...i.classes].some((x) => AUTO_CLASS.has(x)) ? 2 : 0) +
([...i.industries].some((x) => AUTO_INDUSTRY.has(x)) ? 2 : 0) +
(k.includes(cmp(i.label)) ? 1 : 0) +
Math.min(i.links, 400) / 1000;
ok.sort((a, b) => score(b) - score(a));
ranked.set(b.slug, ok);
if (ok[0]) brandPick.set(b.slug, ok[0]);
}
// 1. P154 of the brand's own item (never a parent company's).
await imageInfo(
[...ranked.values()].flatMap((l) => l.flatMap((i) => i.logos)),
LOGO
);
// 0. Hand-checked files.
await imageInfo(Object.values(LOGO_FILES), LOGO);
for (const b of brands) {
const file = LOGO_FILES[b.name];
const photo = file ? toPhoto(file, 'logo', null, LOGO) : null;
const i = brandPick.get(b.slug);
if (photo)
logos.set(b.slug, {
photo,
wikidata: i?.id ?? null,
article: i?.article ?? null,
via: 'curated'
});
}
// A file named after the brand wins over e.g. a parent company's logo on a shared item.
for (const b of brands)
for (const named of [true, false]) {
if (logos.has(b.slug)) break;
for (const i of ranked.get(b.slug)!) {
const photo = i.logos
.filter((f) => !named || keys.get(b.slug)!.some((k) => cmp(f).includes(k)))
.map((f) => logoFile(f, keys.get(b.slug)!))
.find(Boolean);
if (photo) {
logos.set(b.slug, { photo, wikidata: i.id, article: i.article, via: `P154 ${i.id}` });
break;
}
}
}
// 2. The logo in the brand article's infobox, when that file is on Commons.
for (const b of brands) {
if (logos.has(b.slug)) continue;
for (const i of ranked
.get(b.slug)!
.filter((i) => i.article)
.slice(0, 2)) {
const file = await infoboxLogo(i.article!);
if (!file) continue;
await imageInfo([file], LOGO);
const photo = logoFile(file, keys.get(b.slug)!);
if (photo && (LOGO_WORD.test(file) || keys.get(b.slug)!.some((x) => cmp(file).includes(x)))) {
logos.set(b.slug, {
photo,
wikidata: i.id,
article: i.article,
via: `infobox ${i.article}`
});
break;
}
}
}
// 3. "<Brand> logos" on Commons: a logo file named after the brand, newest year first.
for (const b of brands) {
if (logos.has(b.slug)) continue;
const best = ranked.get(b.slug)![0];
if (!best) continue; // without an automotive item the category could be anyone's
// The item's own Commons category is trusted; a category merely named like the brand
// (shared with e.g. a motorcycle maker) only if the brand's article uses the file.
const used = best.article
? new Set((await articleImages([best.article])).get(best.article) ?? [])
: new Set<string>();
const cats = [...new Set([best.category, ...names.get(b.slug)!].filter(Boolean))] as string[];
for (const c of cats) {
const files = (await categoryFiles(`${c} logos`))
.filter((f) => /\.(svg|png)$/i.test(f) && /logo/i.test(f))
.filter((f) => plainLogoName(f, names.get(b.slug)!))
.filter((f) => c === best.category || used.has(f))
.sort(
(x, y) =>
Math.max(0, ...(y.match(/(?:19|20)\d\d/g) ?? []).map(Number)) -
Math.max(0, ...(x.match(/(?:19|20)\d\d/g) ?? []).map(Number)) ||
Number(/\.svg$/i.test(y)) - Number(/\.svg$/i.test(x)) ||
x.length - y.length
);
await imageInfo(files.slice(0, 5), LOGO);
const file = files.slice(0, 5).find((f) => logoFile(f, keys.get(b.slug)!));
if (file) {
const photo = logoFile(file, keys.get(b.slug)!)!;
logos.set(b.slug, {
photo,
wikidata: best.id,
article: best.article,
via: `Category:${c} logos`
});
break;
}
}
}
await saveApiCache(true);
log(`logos: ${logos.size}/${brands.length} brands`);
return { logos, brandPick };
}
// ---------------------------------------------------------------- matching // ---------------------------------------------------------------- matching
type Target = { type Target = {
@@ -371,7 +719,9 @@ async function imageInfo(files: string[], width = THUMB) {
prop: 'imageinfo', prop: 'imageinfo',
iiprop: 'url|size|mime|extmetadata', iiprop: 'url|size|mime|extmetadata',
iiurlwidth: String(width), iiurlwidth: String(width),
iiextmetadatafilter: 'Artist|LicenseShortName|LicenseUrl|AttributionRequired|NonFree', iiextmetadatafilter: `Artist|LicenseShortName|LicenseUrl|AttributionRequired|NonFree${
width === LOGO ? '|Restrictions' : ''
}`,
titles: chunk.map((f) => `File:${f}`).join('|') titles: chunk.map((f) => `File:${f}`).join('|')
}); });
const norm = new Map((res.query?.normalized ?? []).map((n) => [n.to, n.from])); const norm = new Map((res.query?.normalized ?? []).map((n) => [n.to, n.from]));
@@ -393,8 +743,11 @@ function toPhoto(
if (!info?.thumburl) return null; if (!info?.thumburl) return null;
const meta = info.extmetadata ?? {}; const meta = info.extmetadata ?? {};
if (meta.NonFree?.value && meta.NonFree.value !== 'false') return null; if (meta.NonFree?.value && meta.NonFree.value !== 'false') return null;
const license = meta.LicenseShortName?.value ? stripHtml(meta.LicenseShortName.value) : null; let license = meta.LicenseShortName?.value ? stripHtml(meta.LicenseShortName.value) : null;
if (!license || /fair use|non-free/i.test(license)) return null; if (!license || /fair use|non-free/i.test(license)) return null;
// Most logos are public domain only as copyright goes (PD-textlogo) but still trademarks.
if (kind === 'logo' && /trademark/i.test(meta.Restrictions?.value ?? ''))
license += ' · trademark';
if (kind !== 'logo') { if (kind !== 'logo') {
if (!/^image\/(jpeg|png|webp)$/.test(info.mime)) return null; if (!/^image\/(jpeg|png|webp)$/.test(info.mime)) return null;
if (info.width < THUMB || info.height < 400) return null; if (info.width < THUMB || info.height < 400) return null;
@@ -576,7 +929,7 @@ async function photosFor(
async function loadTargets(): Promise<{ async function loadTargets(): Promise<{
targets: Target[]; targets: Target[];
brands: { slug: string; name: string }[]; brands: Brand[];
}> { }> {
if (modelsFile) { if (modelsFile) {
const built = JSON.parse(await readFile(modelsFile, 'utf8')) as { const built = JSON.parse(await readFile(modelsFile, 'utf8')) as {
@@ -596,7 +949,7 @@ async function loadTargets(): Promise<{
photos: 0 photos: 0
})) }))
), ),
brands: built.map((b) => ({ slug: b.slug, name: b.name })) brands: built.map((b) => ({ slug: b.slug, name: b.name, wikidata: null, hasLogo: false }))
}; };
} }
const sql = db(); const sql = db();
@@ -614,7 +967,10 @@ async function loadTargets(): Promise<{
select m.id, b.name as brand, b.slug as brand_slug, m.name as model, m.slug, select m.id, b.name as brand, b.slug as brand_slug, m.name as model, m.slug,
m.nl_count + m.variant_count * 50 as nl, m.photo_count as photos m.nl_count + m.variant_count * 50 as nl, m.photo_count as photos
from cars.models m join cars.brands b on b.id = m.brand_id`; from cars.models m join cars.brands b on b.id = m.brand_id`;
const brands = await sql<{ slug: string; name: string }[]>`select slug, name from cars.brands`; const brands = (
await sql<{ slug: string; name: string; wikidata: string | null; logo: string | null }[]>`
select slug, name, wikidata, logo from cars.brands`
).map((b) => ({ slug: b.slug, name: b.name, wikidata: b.wikidata, hasLogo: !!b.logo }));
return { return {
targets: rows.map((r) => ({ targets: rows.map((r) => ({
id: r.id, id: r.id,
@@ -665,40 +1021,43 @@ async function main() {
const targets = loaded.targets.filter((t) => !missingOnly || t.photos === 0); const targets = loaded.targets.filter((t) => !missingOnly || t.photos === 0);
targets.sort((a, b) => b.nl - a.nl); targets.sort((a, b) => b.nl - a.nl);
const wd = await loadWikidata(); const wd = await loadWikidata();
const { match, makersOf } = matcher(wd); const { match } = matcher(wd);
const matches = new Map<Target, Match>(); const matches = new Map<Target, Match>();
for (const t of targets) { for (const t of targets) {
const m = match(t); const m = match(t);
if (m) matches.set(t, m); if (m) matches.set(t, m);
} }
log(`matched ${matches.size}/${targets.length} models to Wikidata`); log(`matched ${matches.size}/${targets.length} models to Wikidata`);
const photos = await photosFor(targets, wd, matches, loaded.targets); const photos = logosOnly
? new Map<Target, Photo[]>()
: await photosFor(targets, wd, matches, loaded.targets);
// Brand logos (P154 of the manufacturer/brand item whose name matches best). const { logos, brandPick } = await findLogos(
const logos = new Map<string, { photo: Photo; wikidata: string; article: string | null }>(); brands.filter((b) => !missingOnly || !b.hasLogo),
const brandPick = new Map< wd.makers
string,
{ id: string; logo: string | null; article: string | null; label: string }
>();
for (const b of brands) {
const cands = [...makersOf(b.name)].map((id) => wd.makers.get(id)!).filter(Boolean);
const exact = cands.filter((m) => cmp(m.label) === cmp(b.name));
const pick = [...exact, ...cands].find((m) => m.logo) ?? exact[0] ?? null;
if (pick) brandPick.set(b.slug, pick);
}
await imageInfo(
[...brandPick.values()].map((p) => p.logo).filter((l): l is string => !!l),
250
); );
for (const [slug, p] of brandPick) {
const photo = p.logo ? toPhoto(p.logo, 'logo', null, 250) : null;
if (photo) logos.set(slug, { photo, wikidata: p.id, article: p.article });
}
await saveApiCache(true); await saveApiCache(true);
if (logosOnly && outFile)
await writeFile(
outFile,
JSON.stringify(
brands.map((b) => ({
brand: b.name,
item: brandPick.get(b.slug)?.id ?? null,
label: brandPick.get(b.slug)?.label ?? null,
logo: logos.get(b.slug)?.photo.file ?? null,
thumb: logos.get(b.slug)?.photo.thumb ?? null,
via: logos.get(b.slug)?.via ?? null,
license: logos.get(b.slug)?.photo.license ?? null
})),
null,
1
)
);
const total = [...photos.values()].reduce((a, p) => a + p.length, 0); const total = [...photos.values()].reduce((a, p) => a + p.length, 0);
log(`photos: ${photos.size} models with photos, ${total} photos, ${logos.size} brand logos`); log(`photos: ${photos.size} models with photos, ${total} photos, ${logos.size} brand logos`);
if (outFile) if (outFile && !logosOnly)
await writeFile( await writeFile(
outFile, outFile,
JSON.stringify( JSON.stringify(
@@ -718,11 +1077,11 @@ async function main() {
const sql = db(); const sql = db();
await sql.begin(async (tx) => { await sql.begin(async (tx) => {
const ids = targets.map((t) => t.id!).filter(Boolean); const ids = logosOnly ? [] : targets.map((t) => t.id!).filter(Boolean);
// Replace photos for every model we looked at (including ones that lost their match). // Replace photos for every model we looked at (including ones that lost their match).
await tx`delete from cars.photos where model_id = any(${ids})`; await tx`delete from cars.photos where model_id = any(${ids})`;
await tx`update cars.models set wikidata = null, wikipedia = null, photo = null, photo_count = 0, photos_checked_at = now() where id = any(${ids})`; await tx`update cars.models set wikidata = null, wikipedia = null, photo = null, photo_count = 0, photos_checked_at = now() where id = any(${ids})`;
for (const t of targets) { for (const t of logosOnly ? [] : targets) {
const m = matches.get(t); const m = matches.get(t);
if (!m || !t.id) continue; if (!m || !t.id) continue;
const list = photos.get(t) ?? []; const list = photos.get(t) ?? [];

View File

@@ -1,17 +1,34 @@
<script lang="ts"> <script lang="ts">
import { brandHue, monogram } from './shared.js'; import { brandHue, monogram, thumbAt } from './shared.js';
let { name, size = 'md' }: { name: string; size?: 'sm' | 'md' | 'lg' } = $props(); let {
name,
logo = null,
size = 'md'
}: { name: string; logo?: string | null; size?: 'sm' | 'md' | 'lg' | 'xl' } = $props();
const hue = $derived(brandHue(name)); const hue = $derived(brandHue(name));
// Logos sit on a light tile so dark wordmarks stay readable on the dark theme; a logo
// that fails to load falls back to the brand-coloured monogram.
let failed = $state(false);
const src = $derived(logo ? thumbAt(logo, size === 'sm' || size === 'md' ? 120 : 250) : null);
</script> </script>
<span class={['badge', size]} style:--hue={hue} aria-hidden="true">{monogram(name)}</span> {#if src && !failed}
<span class={['tile', size]} aria-hidden="true">
<img {src} alt="" loading="lazy" decoding="async" onerror={() => (failed = true)} />
</span>
{:else}
<span class={['badge', size]} style:--hue={hue} aria-hidden="true">{monogram(name)}</span>
{/if}
<style> <style>
.badge { .badge,
.tile {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
flex-shrink: 0; flex-shrink: 0;
}
.badge {
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -25,11 +42,28 @@
inset 0 0 0 2px hsl(var(--hue) 60% 55% / 0.45), inset 0 0 0 2px hsl(var(--hue) 60% 55% / 0.45),
0 2px 8px hsl(var(--hue) 60% 20% / 0.4); 0 2px 8px hsl(var(--hue) 60% 20% / 0.4);
} }
.tile {
border-radius: 0.55rem;
background: #fff;
padding: 0.2rem;
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.08),
0 2px 8px rgb(0 0 0 / 0.35);
}
.tile img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sm { .sm {
width: 1.9rem; width: 1.9rem;
height: 1.9rem; height: 1.9rem;
font-size: 0.7rem; font-size: 0.7rem;
} }
.tile.sm {
width: 2.6rem;
padding: 0.18rem 0.25rem;
}
.md { .md {
width: 2.6rem; width: 2.6rem;
height: 2.6rem; height: 2.6rem;
@@ -40,4 +74,21 @@
height: 4.5rem; height: 4.5rem;
font-size: 1.4rem; font-size: 1.4rem;
} }
.tile.lg {
border-radius: 1rem;
padding: 0.5rem;
}
.xl {
width: 100%;
height: 100%;
font-size: 1.6rem;
}
.badge.xl {
width: 3.6rem;
height: 3.6rem;
}
.tile.xl {
border-radius: 0.6rem;
padding: 0.6rem 0.8rem;
}
</style> </style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import BrandBadge from './BrandBadge.svelte';
import { fmtCompact, type BrandTileData } from './shared.js';
let { brand, compact = false }: { brand: BrandTileData; compact?: boolean } = $props();
</script>
<a href="/trackers/cars/{brand.slug}" class={['tile', { compact }]} title={brand.name}>
<span class="mark"><BrandBadge name={brand.name} logo={brand.logo} size="xl" /></span>
<span class="name">{brand.name}</span>
{#if !compact}
<span class="meta"
>{brand.models}
{brand.models === 1 ? 'model' : 'models'}{#if brand.nlCount}
· 🇳🇱 {fmtCompact(brand.nlCount)}{/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

@@ -39,7 +39,7 @@
{:else} {:else}
<span class="placeholder" aria-hidden="true">{monogram(car.brandName)}</span> <span class="placeholder" aria-hidden="true">{monogram(car.brandName)}</span>
{/if} {/if}
<span class="badge"><BrandBadge name={car.brandName} size="sm" /></span> <span class="badge"><BrandBadge name={car.brandName} logo={car.brandLogo} size="sm" /></span>
<span class="dex">{dexLabel(car.dex)}</span> <span class="dex">{dexLabel(car.dex)}</span>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">

View File

@@ -157,6 +157,7 @@ export type CarCard = {
name: string; name: string;
brandSlug: string; brandSlug: string;
brandName: string; brandName: string;
brandLogo: string | null;
firstYear: number | null; firstYear: number | null;
lastYear: number | null; lastYear: number | null;
fuels: string[]; fuels: string[];
@@ -186,3 +187,15 @@ export type CarPhoto = {
license: string | null; license: string | null;
licenseUrl: string | null; licenseUrl: string | null;
}; };
/** A brand in the logo grid (/trackers/cars/brands and the strip on /trackers/cars). */
export type BrandTileData = {
slug: string;
name: string;
models: number;
nlCount: number;
logo: string | null;
};
export const LOGO_NOTE =
'Brand 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 brand.';

View File

@@ -49,6 +49,7 @@ const cardColumns = {
name: carModels.name, name: carModels.name,
brandSlug: carBrands.slug, brandSlug: carBrands.slug,
brandName: carBrands.name, brandName: carBrands.name,
brandLogo: carBrands.logo,
firstYear: carModels.firstYear, firstYear: carModels.firstYear,
lastYear: carModels.lastYear, lastYear: carModels.lastYear,
fuels: carModels.fuels, fuels: carModels.fuels,
@@ -102,8 +103,18 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
return { cars, total }; return { cars, total };
} }
export type BrandTile = {
slug: string;
name: string;
models: number;
nlCount: number;
country: string | null;
logo: string | null;
};
type Overview = { type Overview = {
brands: { slug: string; name: string; models: number }[]; /** Alphabetical (for the filter); `byNl` has the same brands, most common in NL first. */
brands: BrandTile[];
byNl: BrandTile[];
stats: { brands: number; models: number; variants: number; nlCars: number }; stats: { brands: number; models: number; variants: number; nlCars: number };
}; };
let overviewCache: { at: number; value: Overview } | null = null; let overviewCache: { at: number; value: Overview } | null = null;
@@ -113,7 +124,14 @@ export async function carOverview(): Promise<Overview> {
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value; if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
const [brands, [counts]] = await Promise.all([ const [brands, [counts]] = await Promise.all([
db db
.select({ slug: carBrands.slug, name: carBrands.name, models: carBrands.modelCount }) .select({
slug: carBrands.slug,
name: carBrands.name,
models: carBrands.modelCount,
nlCount: carBrands.nlCount,
country: carBrands.country,
logo: carBrands.logo
})
.from(carBrands) .from(carBrands)
.orderBy(asc(carBrands.name)), .orderBy(asc(carBrands.name)),
db db
@@ -126,6 +144,9 @@ export async function carOverview(): Promise<Overview> {
]); ]);
const value = { const value = {
brands, brands,
byNl: [...brands].sort(
(a, b) => b.nlCount - a.nlCount || b.models - a.models || a.name.localeCompare(b.name)
),
stats: { stats: {
brands: brands.length, brands: brands.length,
models: counts.models, models: counts.models,

View File

@@ -15,6 +15,7 @@ const staticPaths = [
'/lists', '/lists',
'/trackers/books', '/trackers/books',
'/trackers/cars', '/trackers/cars',
'/trackers/cars/brands',
'/trackers/music', '/trackers/music',
'/projects/game-tracker', '/projects/game-tracker',
'/projects/game-tracker/pokemon', '/projects/game-tracker/pokemon',

View File

@@ -9,7 +9,10 @@ export const load: PageServerLoad = async ({ url }) => {
cars, cars,
total, total,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
brands: overview.brands, brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
topBrands: overview.byNl
.slice(0, 16)
.map(({ slug, name, models, nlCount, logo }) => ({ slug, name, models, nlCount, logo })),
stats: overview.stats, stats: overview.stats,
currentYear: new Date().getFullYear(), currentYear: new Date().getFullYear(),
seo: { seo: {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import BrandTile from '#lib/cars/BrandTile.svelte';
import CarCard from '#lib/cars/CarCard.svelte'; import CarCard from '#lib/cars/CarCard.svelte';
import { import {
BODIES, BODIES,
@@ -10,6 +11,7 @@
fuelLabel, fuelLabel,
fmtCompact, fmtCompact,
fmtNumber, fmtNumber,
LOGO_NOTE,
SORTS SORTS
} from '#lib/cars/shared.js'; } from '#lib/cars/shared.js';
import { Input } from '#lib/components/ui/input/index.js'; import { Input } from '#lib/components/ui/input/index.js';
@@ -92,6 +94,22 @@
</div> </div>
</dl> </dl>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="brands-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="brands-heading" class="text-lg font-semibold">brands</h2>
<a href="/trackers/cars/brands" class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.brands)} brands →</a
>
</div>
<ul class="brands">
{#each data.topBrands as brand (brand.slug)}
<li><BrandTile {brand} compact /></li>
{/each}
</ul>
</section>
{/if}
<form <form
bind:this={form} bind:this={form}
method="GET" method="GET"
@@ -213,11 +231,17 @@
register, CC0) and register, CC0) and
<a href="https://www.fueleconomy.gov/feg/download.shtml" class="underline">fueleconomy.gov</a> <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 (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. average catalogue prices of new cars when first registered in NL. Blank means unknown. Photos from
Wikimedia Commons, credited on each car's page. {LOGO_NOTE}
</footer> </footer>
</div> </div>
<style> <style>
.brands {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.5rem;
}
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -1,7 +1,15 @@
<script lang="ts"> <script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte'; import BrandBadge from '#lib/cars/BrandBadge.svelte';
import CarCard from '#lib/cars/CarCard.svelte'; import CarCard from '#lib/cars/CarCard.svelte';
import { commonsPage, flag, fmtNumber, thumbAt, thumbSrcset, years } from '#lib/cars/shared.js'; import {
commonsPage,
flag,
fmtNumber,
LOGO_NOTE,
thumbAt,
thumbSrcset,
years
} from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -74,7 +82,9 @@
>{/if}{#if b.logoFile} >{/if}{#if b.logoFile}
· Logo: <a href={commonsPage(b.logoFile)} rel="noopener" · Logo: <a href={commonsPage(b.logoFile)} rel="noopener"
>{b.logoAuthor ?? 'Wikimedia Commons'}</a >{b.logoAuthor ?? 'Wikimedia Commons'}</a
>{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons. >{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
{LOGO_NOTE}{/if}
<a href="/trackers/cars/brands" class="underline">All brands</a>.
</p> </p>
</div> </div>

View File

@@ -34,7 +34,7 @@ export const load: PageServerLoad = async ({ params }) => {
const { model, brand, variants, photos, prev, next } = found; const { model, brand, variants, photos, prev, next } = found;
return { return {
car: publicCar(model), car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country }, brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
variants: variants.map(publicVariant), variants: variants.map(publicVariant),
photos, photos,
prev, prev,

View File

@@ -152,7 +152,7 @@
<header class="hero"> <header class="hero">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/trackers/cars/{data.brand.slug}" aria-label="{data.brand.name} models" <a href="/trackers/cars/{data.brand.slug}" aria-label="{data.brand.name} models"
><BrandBadge name={data.brand.name} size="lg" /></a ><BrandBadge name={data.brand.name} logo={data.brand.logo} size="lg" /></a
> >
<div class="min-w-0"> <div class="min-w-0">
<p class="dex">{dexLabel(car.dex)}</p> <p class="dex">{dexLabel(car.dex)}</p>

View File

@@ -0,0 +1,20 @@
import { carOverview } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byNl, stats } = await carOverview();
return {
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
slug,
name,
models,
nlCount,
logo
})),
stats,
seo: {
title: 'Car brands',
description: `All ${stats.brands} car brands in the pokedex with their logos, most common on Dutch roads first.`
}
};
};

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import BrandTile from '#lib/cars/BrandTile.svelte';
import { LOGO_NOTE, fmtNumber } from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const withLogo = $derived(data.brands.filter((b) => b.logo).length);
</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">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">brands</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
All {fmtNumber(data.brands.length)} brands in the pokedex, the ones you see most on Dutch roads
first. Tap one for its models.
</p>
</header>
<ul class="grid">
{#each data.brands as brand (brand.slug)}
<li><BrandTile {brand} /></li>
{/each}
</ul>
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
{withLogo} of {data.brands.length} brands have a logo; the rest get a coloured monogram.
{LOGO_NOTE} Each brand page credits its logo's author and licence.
</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;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: 0.75rem;
}
</style>