From 42b6b6568af3d8627dbae73feb6e466b4b9c5092 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 22:04:15 +0000 Subject: [PATCH] wildlife: pokedex pages (list, groups, group, species detail, random) and registrations --- src/lib/projects.ts | 8 + src/lib/seo.ts | 5 + src/lib/server/owner.test.ts | 2 + src/lib/server/owner.ts | 1 + src/lib/server/wildlife.ts | 247 ++++++++++ src/lib/title.ts | 1 + src/lib/tracker-icons.ts | 9 + src/lib/wildlife/Gallery.svelte | 256 +++++++++++ src/lib/wildlife/GroupTile.svelte | 112 +++++ src/lib/wildlife/SpeciesBrowser.svelte | 221 +++++++++ src/lib/wildlife/SpeciesCard.svelte | 162 +++++++ src/lib/wildlife/StatusChip.svelte | 40 ++ src/lib/wildlife/shared.ts | 129 ++++++ src/routes/sitemap.xml/+server.ts | 2 + src/routes/trackers/wildlife/+page.server.ts | 22 + src/routes/trackers/wildlife/+page.svelte | 143 ++++++ .../trackers/wildlife/[group]/+page.server.ts | 29 ++ .../trackers/wildlife/[group]/+page.svelte | 101 +++++ .../[group]/[species]/+page.server.ts | 61 +++ .../wildlife/[group]/[species]/+page.svelte | 424 ++++++++++++++++++ .../trackers/wildlife/groups/+page.server.ts | 14 + .../trackers/wildlife/groups/+page.svelte | 42 ++ .../trackers/wildlife/random/+server.ts | 10 + 23 files changed, 2041 insertions(+) create mode 100644 src/lib/server/wildlife.ts create mode 100644 src/lib/wildlife/Gallery.svelte create mode 100644 src/lib/wildlife/GroupTile.svelte create mode 100644 src/lib/wildlife/SpeciesBrowser.svelte create mode 100644 src/lib/wildlife/SpeciesCard.svelte create mode 100644 src/lib/wildlife/StatusChip.svelte create mode 100644 src/lib/wildlife/shared.ts create mode 100644 src/routes/trackers/wildlife/+page.server.ts create mode 100644 src/routes/trackers/wildlife/+page.svelte create mode 100644 src/routes/trackers/wildlife/[group]/+page.server.ts create mode 100644 src/routes/trackers/wildlife/[group]/+page.svelte create mode 100644 src/routes/trackers/wildlife/[group]/[species]/+page.server.ts create mode 100644 src/routes/trackers/wildlife/[group]/[species]/+page.svelte create mode 100644 src/routes/trackers/wildlife/groups/+page.server.ts create mode 100644 src/routes/trackers/wildlife/groups/+page.svelte create mode 100644 src/routes/trackers/wildlife/random/+server.ts diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 25070d5..444faa6 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -142,6 +142,14 @@ const allTrackers = [ href: '/trackers/planes', external: false, private: false + }, + { + name: 'wildlife', + subtitle: + 'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide', + href: '/trackers/wildlife', + external: false, + private: false } ] as const; diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 2636446..d89512e 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -54,6 +54,11 @@ const publicPages: Record = { description: 'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.' }, + '/trackers/wildlife': { + title: 'Wildlife pokedex', + description: + 'A wild animal pokedex: thousands of mammals, birds, reptiles, fish, insects and more seen in the Netherlands and around the world, with Dutch names, Red List status and photos.' + }, '/trackers/pc': { title: 'PC setup', description: diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts index 656b4c4..cc714f0 100644 --- a/src/lib/server/owner.test.ts +++ b/src/lib/server/owner.test.ts @@ -43,6 +43,8 @@ describe('owner gate', () => { expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false); expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true); expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false); + expect(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true); + expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false); expect(isOwnerWritePath('/trackers/pc')).toBe(true); expect(isOwnerOnlyPath('/trackers/pc')).toBe(false); expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false); diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts index e712349..1b7e7f6 100644 --- a/src/lib/server/owner.ts +++ b/src/lib/server/owner.ts @@ -50,6 +50,7 @@ export const ownerWritePrefixes = [ '/trackers/books', '/trackers/cars', '/trackers/planes', + '/trackers/wildlife', '/trackers/music', '/trackers/pc' ] as const; diff --git a/src/lib/server/wildlife.ts b/src/lib/server/wildlife.ts new file mode 100644 index 0000000..22827d1 --- /dev/null +++ b/src/lib/server/wildlife.ts @@ -0,0 +1,247 @@ +// Wildlife pokedex queries (/trackers/wildlife). Public, read-only; data is written only by +// scripts/wildlife-import.ts and scripts/wildlife-photos.ts. +import { db } from '#lib/server/db/index.js'; +import { wildlifeGroups, wildlifePhotos, wildlifeSpecies } from '#lib/server/db/schema/index.js'; +import { and, asc, eq, gt, ilike, inArray, isNotNull, sql, type SQL } from 'drizzle-orm'; +import { + SORTS, + STATUSES, + WHERE, + type SpeciesCard, + type WildSort, + type WildWhere +} from '#lib/wildlife/shared.js'; + +export const PAGE_SIZE = 60; + +export type WildFilters = { + q: string; + group: string; + status: string; + where: WildWhere | ''; + photos: '' | 'yes'; + sort: WildSort; + page: number; +}; + +const pick = (value: string | null, allowed: readonly T[]): T | '' => + allowed.includes(value as T) ? (value as T) : ''; + +export function parseFilters(params: URLSearchParams, group = ''): WildFilters { + const sort = params.get('sort'); + const status = params.get('status') ?? ''; + return { + q: (params.get('q') ?? '').trim().slice(0, 80), + group: group || (params.get('group') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 60), + status: status === 'threatened' ? status : pick(status, STATUSES), + where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]), + photos: pick(params.get('photos'), ['yes'] as const), + sort: sort && sort in SORTS ? (sort as WildSort) : 'nl', + page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000) + }; +} + +const cardColumns = { + dex: wildlifeSpecies.dex, + slug: wildlifeSpecies.slug, + groupSlug: wildlifeGroups.slug, + groupEmoji: wildlifeGroups.emoji, + nameEn: wildlifeSpecies.nameEn, + nameNl: wildlifeSpecies.nameNl, + scientificName: wildlifeSpecies.scientificName, + iucn: wildlifeSpecies.iucn, + nlObs: wildlifeSpecies.nlObs, + worldObs: wildlifeSpecies.worldObs, + nlEstablishment: wildlifeSpecies.nlEstablishment, + photo: wildlifeSpecies.photo, + photoCount: wildlifeSpecies.photoCount +}; + +const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); +const threatRank = sql`case ${wildlifeSpecies.iucn} when 'EX' then 7 when 'EW' then 6 when 'CR' then 5 + when 'EN' then 4 when 'VU' then 3 when 'NT' then 2 when 'LC' then 1 else 0 end`; + +export async function searchSpecies( + f: WildFilters +): Promise<{ species: SpeciesCard[]; total: number }> { + const where: SQL[] = []; + for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6)) + where.push(ilike(wildlifeSpecies.search, `%${escapeLike(word)}%`)); + if (f.group) where.push(eq(wildlifeGroups.slug, f.group)); + if (f.status === 'threatened') + where.push(inArray(wildlifeSpecies.iucn, ['VU', 'EN', 'CR', 'EW'])); + else if (f.status) where.push(eq(wildlifeSpecies.iucn, f.status)); + if (f.where === 'nl') where.push(gt(wildlifeSpecies.nlObs, 0)); + if (f.where === 'europe') + where.push(and(eq(wildlifeSpecies.nlObs, 0), gt(wildlifeSpecies.europeObs, 0))!); + if (f.where === 'world') + where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!); + if (f.photos) where.push(isNotNull(wildlifeSpecies.photo)); + const order = { + nl: [ + sql`${wildlifeSpecies.nlObs} desc`, + sql`${wildlifeSpecies.worldObs} desc`, + asc(wildlifeSpecies.dex) + ], + world: [sql`${wildlifeSpecies.worldObs} desc`, asc(wildlifeSpecies.dex)], + dex: [asc(wildlifeSpecies.dex)], + name: [ + sql`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})` + ], + threatened: [sql`${threatRank} desc`, sql`${wildlifeSpecies.worldObs} desc`], + heaviest: [sql`${wildlifeSpecies.massKg} desc nulls last`, asc(wildlifeSpecies.dex)], + longest: [sql`${wildlifeSpecies.lengthM} desc nulls last`, asc(wildlifeSpecies.dex)], + oldest: [sql`${wildlifeSpecies.lifespanYears} desc nulls last`, asc(wildlifeSpecies.dex)] + }[f.sort]; + const condition = where.length ? and(...where) : undefined; + const [species, [{ total }]] = await Promise.all([ + db + .select(cardColumns) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where(condition) + .orderBy(...order) + .limit(PAGE_SIZE) + .offset((f.page - 1) * PAGE_SIZE), + db + .select({ total: sql`count(*)::int` }) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where(condition) + ]); + return { species, total }; +} + +type Overview = { + groups: { + slug: string; + name: string; + nameNl: string; + emoji: string; + speciesCount: number; + nlCount: number; + photo: string | null; + }[]; + stats: { species: number; groups: number; nl: number; photos: number; threatened: number }; +}; +let overviewCache: { at: number; value: Overview } | null = null; + +/** Groups (in display order) and headline counts; cached for 10 minutes. */ +export async function wildlifeOverview(): Promise { + if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value; + const [groups, [counts]] = await Promise.all([ + db + .select({ + slug: wildlifeGroups.slug, + name: wildlifeGroups.name, + nameNl: wildlifeGroups.nameNl, + emoji: wildlifeGroups.emoji, + speciesCount: wildlifeGroups.speciesCount, + nlCount: wildlifeGroups.nlCount, + photo: wildlifeGroups.photo + }) + .from(wildlifeGroups) + .where(gt(wildlifeGroups.speciesCount, 0)) + .orderBy(asc(wildlifeGroups.sortOrder)), + db + .select({ + species: sql`count(*)::int`, + nl: sql`count(*) filter (where ${wildlifeSpecies.nlObs} > 0)::int`, + photos: sql`coalesce(sum(${wildlifeSpecies.photoCount}), 0)::int`, + threatened: sql`count(*) filter (where ${wildlifeSpecies.iucn} in ('VU','EN','CR','EW'))::int` + }) + .from(wildlifeSpecies) + ]); + const value = { groups, stats: { ...counts, groups: groups.length } }; + if (value.stats.species) overviewCache = { at: Date.now(), value }; + return value; +} + +export async function getGroup(slug: string) { + const [group] = await db + .select() + .from(wildlifeGroups) + .where(eq(wildlifeGroups.slug, slug)) + .limit(1); + return group ?? null; +} + +export async function getSpecies(groupSlug: string, slug: string) { + const [row] = await db + .select({ species: wildlifeSpecies, group: wildlifeGroups }) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where(and(eq(wildlifeGroups.slug, groupSlug), eq(wildlifeSpecies.slug, slug))) + .limit(1); + if (!row) return null; + const s = row.species; + const [neighbours, photos, relatives] = await Promise.all([ + db + .select({ + dex: wildlifeSpecies.dex, + slug: wildlifeSpecies.slug, + name: sql`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})`, + groupSlug: wildlifeGroups.slug + }) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where( + sql`${wildlifeSpecies.dex} in ( + (select max(dex) from wildlife.species where dex < ${s.dex}), + (select min(dex) from wildlife.species where dex > ${s.dex}))` + ), + db + .select({ + kind: wildlifePhotos.kind, + photoId: wildlifePhotos.photoId, + url: wildlifePhotos.url, + width: wildlifePhotos.width, + height: wildlifePhotos.height, + author: wildlifePhotos.author, + license: wildlifePhotos.license, + pageUrl: wildlifePhotos.pageUrl, + place: wildlifePhotos.place + }) + .from(wildlifePhotos) + .where(eq(wildlifePhotos.speciesId, s.id)) + .orderBy(asc(wildlifePhotos.position)), + // Same family (else order), most seen first. + s.familyName || s.orderName + ? db + .select(cardColumns) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where( + and( + s.familyName + ? eq(wildlifeSpecies.familyName, s.familyName) + : eq(wildlifeSpecies.orderName, s.orderName!), + sql`${wildlifeSpecies.id} <> ${s.id}` + ) + ) + .orderBy(sql`${wildlifeSpecies.nlObs} desc`, sql`${wildlifeSpecies.worldObs} desc`) + .limit(8) + : Promise.resolve([] as SpeciesCard[]) + ]); + return { + ...row, + photos, + relatives, + prev: neighbours.find((n) => n.dex < s.dex) ?? null, + next: neighbours.find((n) => n.dex > s.dex) ?? null + }; +} + +/** A random species with a photo. */ +export async function randomSpecies(group?: string) { + const [row] = await db + .select({ slug: wildlifeSpecies.slug, groupSlug: wildlifeGroups.slug }) + .from(wildlifeSpecies) + .innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId)) + .where( + and(isNotNull(wildlifeSpecies.photo), group ? eq(wildlifeGroups.slug, group) : undefined) + ) + .orderBy(sql`random()`) + .limit(1); + return row ?? null; +} diff --git a/src/lib/title.ts b/src/lib/title.ts index 994f9c5..f2b394c 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -21,6 +21,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str '/trackers/books': 'Books', '/trackers/cars': 'Cars', '/trackers/planes': 'Planes', + '/trackers/wildlife': 'Wildlife', '/trackers/pc': 'PC setup', '/blog': 'Blog', '/lists': 'Lists', diff --git a/src/lib/tracker-icons.ts b/src/lib/tracker-icons.ts index 2689040..79457e9 100644 --- a/src/lib/tracker-icons.ts +++ b/src/lib/tracker-icons.ts @@ -122,6 +122,15 @@ export const trackerIcons: Record = { 'M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z' ] }, + wildlife: { + from: '#86efac', + to: '#15803d', + ink: WHITE, + paths: [ + 'M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z', + 'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12' + ] + }, lists: { from: '#94a3b8', to: '#475569', diff --git a/src/lib/wildlife/Gallery.svelte b/src/lib/wildlife/Gallery.svelte new file mode 100644 index 0000000..9ecf1ea --- /dev/null +++ b/src/lib/wildlife/Gallery.svelte @@ -0,0 +1,256 @@ + + +{#snippet credit(p: WildPhoto)} + © {p.author ?? 'iNaturalist user'} · + {licenseLabel(p.license)} +{/snippet} + + + + + {#if current} +
+ {#key index} + {alt(current, + {/key} +
+ {index + 1}/{photos.length}{#if current.place} + · 📍 {current.place}{/if} + {@render credit(current)} +
+
+ {#if photos.length > 1} + + + {/if} + + {/if} +
+ + diff --git a/src/lib/wildlife/GroupTile.svelte b/src/lib/wildlife/GroupTile.svelte new file mode 100644 index 0000000..1f0afc0 --- /dev/null +++ b/src/lib/wildlife/GroupTile.svelte @@ -0,0 +1,112 @@ + + + + {#if group.photo} + + {/if} + + + + {group.name} + {#if !compact}{group.nameNl}{/if} + {fmtNumber(group.speciesCount)} species{#if group.nlCount} + · {fmtNumber(group.nlCount)} in NL{/if} + + + + diff --git a/src/lib/wildlife/SpeciesBrowser.svelte b/src/lib/wildlife/SpeciesBrowser.svelte new file mode 100644 index 0000000..57e9ccb --- /dev/null +++ b/src/lib/wildlife/SpeciesBrowser.svelte @@ -0,0 +1,221 @@ + + +
+ +
+ {#if groups} + + + {#each groups as g (g.slug)}{/each} + + {/if} + + + + {#each STATUSES as s (s)}{/each} + + + + {#each Object.entries(WHERE) as [key, label] (key)}{/each} + + + {#each Object.entries(SORTS) as [key, label] (key)}{/each} + + +
+
+ +
+ {#each quick as chip (chip.label)} + {chip.label} + {/each} + {#if filtering} + clear filters + {/if} +
+ +

+ {fmtNumber(total)} + {total === 1 ? 'species' : 'species'}{#if pages > 1} + · page {f.page} of {pages}{/if} +

+ +{#if species.length} +
    + {#each species as s, i (s.dex)} +
  • + {/each} +
+{:else} +
+

No animals match that.

+

+ Try fewer words or clear the filters. +

+
+{/if} + +{#if pages > 1} + +{/if} + + diff --git a/src/lib/wildlife/SpeciesCard.svelte b/src/lib/wildlife/SpeciesCard.svelte new file mode 100644 index 0000000..80f5b0e --- /dev/null +++ b/src/lib/wildlife/SpeciesCard.svelte @@ -0,0 +1,162 @@ + + + +
+ {#if species.photo} + {name} + {:else} + + {/if} + + {dexLabel(species.dex)} + {#if species.photoCount > 1}📷 {species.photoCount}{/if} +
+
+

{name}

+ {#if species.nameNl && species.nameNl !== name}

🇳🇱 {species.nameNl}

{/if} +

{species.scientificName}

+
+
+ + + {#if species.nlObs > 0}👀 {fmtCompact(species.nlObs)} in NL{:else}🌍 {fmtCompact( + species.worldObs + )} seen{/if} + +
+
+ + diff --git a/src/lib/wildlife/StatusChip.svelte b/src/lib/wildlife/StatusChip.svelte new file mode 100644 index 0000000..d26c8c5 --- /dev/null +++ b/src/lib/wildlife/StatusChip.svelte @@ -0,0 +1,40 @@ + + +{#if iucn} + {iucn}{#if size === 'md'} + {statusLabel[iucn] ?? iucn}{/if} +{/if} + + diff --git a/src/lib/wildlife/shared.ts b/src/lib/wildlife/shared.ts new file mode 100644 index 0000000..c1870ae --- /dev/null +++ b/src/lib/wildlife/shared.ts @@ -0,0 +1,129 @@ +// Shared (client + server) labels and helpers for the wildlife pokedex (/trackers/wildlife). +export { dexLabel, fmtCompact, fmtNumber } from '#lib/cars/shared.js'; + +export const STATUSES = ['LC', 'NT', 'VU', 'EN', 'CR', 'EW', 'EX', 'DD'] as const; +export const statusLabel: Record = { + LC: 'least concern', + NT: 'near threatened', + VU: 'vulnerable', + EN: 'endangered', + CR: 'critically endangered', + EW: 'extinct in the wild', + EX: 'extinct', + DD: 'data deficient' +}; +/** Dutch IUCN labels. */ +export const statusLabelNl: Record = { + LC: 'niet bedreigd', + NT: 'gevoelig', + VU: 'kwetsbaar', + EN: 'bedreigd', + CR: 'ernstig bedreigd', + EW: 'uitgestorven in het wild', + EX: 'uitgestorven', + DD: 'onvoldoende gegevens' +}; +/** Red List colours (roughly the official palette). */ +export const statusColor: Record = { + LC: '#16a34a', + NT: '#84cc16', + VU: '#eab308', + EN: '#f97316', + CR: '#dc2626', + EW: '#7c3aed', + EX: '#52525b', + DD: '#94a3b8' +}; +/** 0 (safe) .. 1 (gone), for the "threat" bar. */ +export const statusThreat: Record = { + LC: 0.08, + NT: 0.25, + VU: 0.45, + EN: 0.65, + CR: 0.85, + EW: 0.95, + EX: 1 +}; + +export const WHERE = { + nl: '🇳🇱 seen in NL', + europe: '🇪🇺 Europe, not NL', + world: '🌍 rest of the world' +} as const; +export type WildWhere = keyof typeof WHERE; + +export const SORTS = { + nl: 'most seen in NL', + world: 'most seen worldwide', + dex: 'dex #', + name: 'name', + threatened: 'most threatened', + heaviest: 'heaviest', + longest: 'longest', + oldest: 'longest living' +} as const; +export type WildSort = keyof typeof SORTS; + +export type SpeciesCard = { + dex: number; + slug: string; + groupSlug: string; + groupEmoji: string; + nameEn: string | null; + nameNl: string | null; + scientificName: string; + iucn: string | null; + nlObs: number; + worldObs: number; + nlEstablishment: string | null; + photo: string | null; + photoCount: number; +}; +export type GroupTileData = { + slug: string; + name: string; + nameNl: string; + emoji: string; + speciesCount: number; + nlCount: number; + photo: string | null; +}; +export type WildPhoto = { + kind: string; + photoId: number; + url: string; + width: number | null; + height: number | null; + author: string | null; + license: string; + pageUrl: string | null; + place: string | null; +}; + +/** iNaturalist photo URLs come in fixed sizes: small 240, medium 500, large 1024 px. */ +export const photoAt = (url: string, size: 'small' | 'medium' | 'large') => + url.replace(/\/(square|small|medium|large|original)\.(\w+)(\?.*)?$/, `/${size}.$2`); +export const photoSrcset = ( + url: string, + sizes: ('small' | 'medium' | 'large')[] = ['small', 'medium'] +) => + sizes.map((s) => `${photoAt(url, s)} ${{ small: 240, medium: 500, large: 1024 }[s]}w`).join(', '); +export const licenseLabel = (code: string) => + code === 'cc0' ? 'CC0' : `${code.toUpperCase().replace(/^CC-/, 'CC ').replace(/-/g, '-')} 4.0`; +export const licenseUrl = (code: string) => + code === 'cc0' + ? 'https://creativecommons.org/publicdomain/zero/1.0/' + : `https://creativecommons.org/licenses/${code.replace(/^cc-/, '')}/4.0/`; + +/** Main display name: English, else Dutch, else scientific. */ +export const displayName = (s: { + nameEn: string | null; + nameNl: string | null; + scientificName: string; +}) => s.nameEn ?? s.nameNl ?? s.scientificName; +/** Stable hue per group for placeholders. */ +export function groupHue(slug: string): number { + let h = 0; + for (const c of slug) h = (h * 31 + c.charCodeAt(0)) % 360; + return h; +} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index d22485b..55833d4 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -18,6 +18,8 @@ const staticPaths = [ '/trackers/cars/brands', '/trackers/planes', '/trackers/planes/makers', + '/trackers/wildlife', + '/trackers/wildlife/groups', '/trackers/music', '/trackers/pc', '/projects/game-tracker', diff --git a/src/routes/trackers/wildlife/+page.server.ts b/src/routes/trackers/wildlife/+page.server.ts new file mode 100644 index 0000000..a0584f4 --- /dev/null +++ b/src/routes/trackers/wildlife/+page.server.ts @@ -0,0 +1,22 @@ +import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const filters = parseFilters(url.searchParams); + const [{ species, total }, overview] = await Promise.all([ + searchSpecies(filters), + wildlifeOverview() + ]); + return { + filters, + species, + total, + pageSize: PAGE_SIZE, + groups: overview.groups, + stats: overview.stats, + seo: { + title: 'Wildlife pokedex', + description: `Search ${overview.stats.species.toLocaleString('en-GB')} wild animal species: everything you can spot in the Netherlands plus famous animals worldwide, with Dutch names, Red List status and photos.` + } + }; +}; diff --git a/src/routes/trackers/wildlife/+page.svelte b/src/routes/trackers/wildlife/+page.svelte new file mode 100644 index 0000000..c2026b7 --- /dev/null +++ b/src/routes/trackers/wildlife/+page.svelte @@ -0,0 +1,143 @@ + + +
+
+
+

wildlife

+

+ A wild animal pokedex: the mammals, birds, reptiles, fish, insects and other creatures you + can actually spot in the Netherlands and Europe, plus famous animals from around the world. + Dutch and English names, how endangered they are and lots of photos. +

+
+ random animal +
+ +
+
+
species
+
{fmtNumber(data.stats.species)}
+
+
+
seen in NL
+
{fmtNumber(data.stats.nl)}
+
+
+
threatened
+
{fmtNumber(data.stats.threatened)}
+
+
+
photos
+
{fmtNumber(data.stats.photos)}
+
+
+ + {#if !filtering && f.page === 1} +
+ +
    + {#each data.groups as group (group.slug)} +
  • + {/each} +
+
+ {/if} + + + +
+ Data: iNaturalist (species lists, + names, research-grade wild observations, IUCN Red List status and Wikipedia intros), + Wikidata (CC0: Dutch names, size, + weight, lifespan, who named it) and GBIF (Dutch + occurrence records). Pets, livestock and captive animals are left out. "Seen in NL" counts research-grade + iNaturalist observations. Photos are Creative Commons licensed iNaturalist photos, shown from iNaturalist + and credited on each animal's page. +
+
+ + diff --git a/src/routes/trackers/wildlife/[group]/+page.server.ts b/src/routes/trackers/wildlife/[group]/+page.server.ts new file mode 100644 index 0000000..b0f30f7 --- /dev/null +++ b/src/routes/trackers/wildlife/[group]/+page.server.ts @@ -0,0 +1,29 @@ +import { error } from '@sveltejs/kit'; +import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, url }) => { + const group = await getGroup(params.group); + if (!group) error(404, 'No such group in the wildlife pokedex'); + const filters = parseFilters(url.searchParams, group.slug); + const { species, total } = await searchSpecies(filters); + return { + group: { + slug: group.slug, + name: group.name, + nameNl: group.nameNl, + emoji: group.emoji, + speciesCount: group.speciesCount, + nlCount: group.nlCount, + photo: group.photo + }, + filters, + species, + total, + pageSize: PAGE_SIZE, + seo: { + title: `${group.name} · wildlife pokedex`, + description: `${group.speciesCount} ${group.name.toLowerCase()} (${group.nameNl.toLowerCase()}) in Stan's wildlife pokedex: ${group.nlCount} seen in the Netherlands, with Dutch names, Red List status and photos.` + } + }; +}; diff --git a/src/routes/trackers/wildlife/[group]/+page.svelte b/src/routes/trackers/wildlife/[group]/+page.svelte new file mode 100644 index 0000000..e0c1ab4 --- /dev/null +++ b/src/routes/trackers/wildlife/[group]/+page.svelte @@ -0,0 +1,101 @@ + + +
+ all animals +
+ {#if g.photo}{/if} +
+
+

{g.nameNl}

+

{g.emoji} {g.name}

+

+ {fmtNumber(g.speciesCount)} species · {fmtNumber(g.nlCount)} seen in the Netherlands +

+
+ random +
+ + +
+ + diff --git a/src/routes/trackers/wildlife/[group]/[species]/+page.server.ts b/src/routes/trackers/wildlife/[group]/[species]/+page.server.ts new file mode 100644 index 0000000..97f7a20 --- /dev/null +++ b/src/routes/trackers/wildlife/[group]/[species]/+page.server.ts @@ -0,0 +1,61 @@ +import { error } from '@sveltejs/kit'; +import { getSpecies } from '#lib/server/wildlife.js'; +import { displayName, photoAt } from '#lib/wildlife/shared.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const found = await getSpecies(params.group, params.species); + if (!found) error(404, 'No such animal in the wildlife pokedex'); + const { species: s, group, photos, relatives, prev, next } = found; + const name = displayName(s); + const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName] + .filter(Boolean) + .join(', '); + return { + species: { + dex: s.dex, + slug: s.slug, + scientificName: s.scientificName, + nameEn: s.nameEn, + nameNl: s.nameNl, + inatId: s.inatId, + gbifKey: s.gbifKey, + wikidata: s.wikidata, + wikipediaEn: s.wikipediaEn, + wikipediaNl: s.wikipediaNl, + className: s.className, + orderName: s.orderName, + familyName: s.familyName, + iucn: s.iucn, + nlStatus: s.nlStatus, + nlEstablishment: s.nlEstablishment, + extinct: s.extinct, + nlObs: s.nlObs, + europeObs: s.europeObs, + worldObs: s.worldObs, + gbifNl: s.gbifNl, + scope: s.scope, + summary: s.summary, + summaryNl: s.summaryNl, + facts: s.facts, + massKg: s.massKg, + lengthM: s.lengthM, + lifespanYears: s.lifespanYears, + namedYear: s.namedYear, + namedBy: s.namedBy, + updatedAt: s.updatedAt?.toISOString() ?? null + }, + group: { slug: group.slug, name: group.name, nameNl: group.nameNl, emoji: group.emoji }, + photos, + relatives, + prev, + next, + seo: { + title: `${name}${alt ? ` (${alt})` : ''} · wildlife pokedex`, + description: + (s.summary ? s.summary.slice(0, 150).replace(/\s+\S*$/, '') + '…' : '') || + `${name} (${s.scientificName}): photos, Dutch name, Red List status and how often it is seen in the Netherlands.`, + image: photos[0] ? photoAt(photos[0].url, 'large') : undefined + } + }; +}; diff --git a/src/routes/trackers/wildlife/[group]/[species]/+page.svelte b/src/routes/trackers/wildlife/[group]/[species]/+page.svelte new file mode 100644 index 0000000..009b89e --- /dev/null +++ b/src/routes/trackers/wildlife/[group]/[species]/+page.svelte @@ -0,0 +1,424 @@ + + +
+ + +
+

{dexLabel(s.dex)} · {g.emoji} {g.name.toLowerCase()}

+

{name}

+

+ {#if s.nameNl && s.nameNl !== name}🇳🇱 {s.nameNl} · + {/if}{s.scientificName} +

+
+ + {g.emoji} {g.name} + 📍 {where} + {#if s.extinct}🪦 extinct{/if} +
+
+ + {#if data.photos.length} + + {:else} +

No Creative Commons photos found yet.

+ {/if} + + {#if summary} +
+
+

what is it?

+ {#if s.summary && s.summaryNl} +
+ + +
+ {/if} +
+

{summary}

+
+ {/if} + +
+

base stats

+
    + {#each baseStats as b (b.label)} +
  • + {b.label} + + {b.value}{b.hint} +
  • + {/each} +
+

+ Bars compare against all animals on log scales: up to 300,000 Dutch and 600,000 worldwide + sightings (the most-seen species), 150 t (blue whale), 30 m and 400 years (Greenland shark). +

+
+ + {#if funFacts.length} +
+

fun facts

+
    + {#each funFacts as f (f.text)}
  • + {f.text} +
  • {/each} +
+
+ {/if} + +
+

fact sheet

+
+ {#each facts as [label, value] (label)} +
+
{label}
+
{value || '—'}
+
+ {/each} +
+ {#if s.iucn} +

+ IUCN in Dutch: {statusLabelNl[s.iucn] ?? s.iucn}. Blank means the sources don't say. +

+ {/if} +
+ + {#if data.relatives.length} +
+

+ relatives{#if s.familyName} · {s.familyName}{/if} +

+
    + {#each data.relatives as r (r.dex)}
  • {/each} +
+
+ {/if} + +

+ Sources: iNaturalist taxon {s.inatId}{#if s.wikidata}, Wikidata {s.wikidata}{/if}{#if s.gbifKey}, GBIF {s.gbifKey}{/if}{#if s.wikipediaEn}, Wikipedia (EN){/if}{#if s.wikipediaNl}, Wikipedia (NL){/if}. Intro text from Wikipedia (CC BY-SA 4.0).{#if s.updatedAt} + Updated {s.updatedAt.slice(0, 10)}.{/if} +

+
+ + diff --git a/src/routes/trackers/wildlife/groups/+page.server.ts b/src/routes/trackers/wildlife/groups/+page.server.ts new file mode 100644 index 0000000..75e3ade --- /dev/null +++ b/src/routes/trackers/wildlife/groups/+page.server.ts @@ -0,0 +1,14 @@ +import { wildlifeOverview } from '#lib/server/wildlife.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const { groups, stats } = await wildlifeOverview(); + return { + groups, + stats, + seo: { + title: 'Wildlife groups', + description: `All ${groups.length} animal groups in the wildlife pokedex: mammals, birds, reptiles, amphibians, fish, insects, spiders and more.` + } + }; +}; diff --git a/src/routes/trackers/wildlife/groups/+page.svelte b/src/routes/trackers/wildlife/groups/+page.svelte new file mode 100644 index 0000000..7303018 --- /dev/null +++ b/src/routes/trackers/wildlife/groups/+page.svelte @@ -0,0 +1,42 @@ + + +
+ all animals +
+

groups

+

+ {fmtNumber(data.stats.species)} species in {data.groups.length} groups. Tap one to see them all. +

+
+ +
    + {#each data.groups as group (group.slug)} +
  • + {/each} +
+
+ + diff --git a/src/routes/trackers/wildlife/random/+server.ts b/src/routes/trackers/wildlife/random/+server.ts new file mode 100644 index 0000000..f840552 --- /dev/null +++ b/src/routes/trackers/wildlife/random/+server.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; +import { randomSpecies } from '#lib/server/wildlife.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ setHeaders, url }) => { + const group = (url.searchParams.get('group') ?? '').replace(/[^a-z0-9-]/g, '') || undefined; + const s = await randomSpecies(group); + setHeaders({ 'cache-control': 'no-store' }); + redirect(303, s ? `/trackers/wildlife/${s.groupSlug}/${s.slug}` : '/trackers/wildlife'); +};