wildlife: pokedex pages (list, groups, group, species detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m4s

This commit is contained in:
2026-10-24 22:04:15 +00:00
parent ba44365c71
commit 42b6b6568a
23 changed files with 2041 additions and 0 deletions

View File

@@ -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;

View File

@@ -54,6 +54,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
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:

View File

@@ -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);

View File

@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
'/trackers/books',
'/trackers/cars',
'/trackers/planes',
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc'
] as const;

247
src/lib/server/wildlife.ts Normal file
View File

@@ -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 = <T extends string>(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<number>`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<Overview> {
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<number>`count(*)::int`,
nl: sql<number>`count(*) filter (where ${wildlifeSpecies.nlObs} > 0)::int`,
photos: sql<number>`coalesce(sum(${wildlifeSpecies.photoCount}), 0)::int`,
threatened: sql<number>`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<string>`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;
}

View File

@@ -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',

View File

@@ -122,6 +122,15 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'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',

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import X from 'lucide-svelte/icons/x';
import { licenseLabel, licenseUrl, photoAt, photoSrcset, type WildPhoto } from './shared.js';
let { photos, name }: { photos: WildPhoto[]; name: string } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let index = $state(0);
const current = $derived(photos[index]);
const alt = (p: WildPhoto, i: number) =>
`${name}${p.place ? `, ${p.place}` : ''}, photo ${i + 1} of ${photos.length}`;
const page = (p: WildPhoto) => p.pageUrl ?? `https://www.inaturalist.org/photos/${p.photoId}`;
// Links go to the photo on iNaturalist; with JS they open the lightbox instead.
function open(event: MouseEvent, i: number) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
index = i;
dialog?.showModal();
}
const step = (d: number) => (index = (index + d + photos.length) % photos.length);
function onkeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') step(1);
else if (event.key === 'ArrowLeft') step(-1);
else return;
event.preventDefault();
}
function onclick(event: MouseEvent) {
if (event.target === dialog) dialog?.close();
}
</script>
{#snippet credit(p: WildPhoto)}
<a href={page(p)} rel="noopener">© {p.author ?? 'iNaturalist user'}</a> ·
<a href={licenseUrl(p.license)} rel="noopener license">{licenseLabel(p.license)}</a>
{/snippet}
<section class="gallery" aria-label="Photos of {name}">
<ul class:single={photos.length === 1}>
{#each photos as p, i (p.photoId)}
<li class:lead={i === 0}>
<a href={page(p)} onclick={(e) => open(e, i)} title={p.place ?? undefined}>
<img
src={photoAt(p.url, i === 0 ? 'large' : 'medium')}
srcset={i === 0 ? photoSrcset(p.url, ['medium', 'large']) : photoSrcset(p.url)}
sizes={i === 0 ? '(min-width: 1152px) 680px, 100vw' : '(min-width: 1152px) 220px, 50vw'}
alt={alt(p, i)}
loading={i === 0 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
decoding="async"
width={p.width ?? undefined}
height={p.height ?? undefined}
/>
{#if p.place}<span class="cap">📍 {p.place}</span>{/if}
</a>
</li>
{/each}
</ul>
<details class="credits">
<summary>
{photos.length} photo{photos.length === 1 ? '' : 's'} from iNaturalist (Creative Commons) · credits
& licences
</summary>
<ol>
{#each photos as p, i (p.photoId)}
<li>
{i + 1}. {#if p.place}{p.place}:
{/if}{@render credit(p)}
</li>
{/each}
</ol>
</details>
</section>
<dialog bind:this={dialog} {onkeydown} {onclick} aria-label="{name} photos">
{#if current}
<figure>
{#key index}
<img src={photoAt(current.url, 'large')} alt={alt(current, index)} />
{/key}
<figcaption>
<span
>{index + 1}/{photos.length}{#if current.place}
· 📍 {current.place}{/if}</span
>
<span class="credit">{@render credit(current)}</span>
</figcaption>
</figure>
{#if photos.length > 1}
<button class="nav prev" onclick={() => step(-1)} aria-label="Previous photo"
><ChevronLeft size={28} /></button
>
<button class="nav next" onclick={() => step(1)} aria-label="Next photo"
><ChevronRight size={28} /></button
>
{/if}
<button class="close" onclick={() => dialog?.close()} aria-label="Close" autofocus
><X size={22} /></button
>
{/if}
</dialog>
<style>
.gallery ul {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, 1fr);
}
.gallery li.lead {
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.gallery ul {
grid-template-columns: repeat(4, 1fr);
}
.gallery li.lead {
grid-column: span 2;
grid-row: span 2;
}
.gallery ul.single {
grid-template-columns: 1fr;
}
}
.gallery a {
position: relative;
display: block;
height: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.7rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
}
.gallery li.lead a {
aspect-ratio: auto;
min-height: 16rem;
}
.gallery img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.gallery a:hover img,
.gallery a:focus-visible img {
transform: scale(1.04);
}
.cap {
position: absolute;
left: 0.4rem;
bottom: 0.4rem;
max-width: calc(100% - 0.8rem);
padding: 0.05rem 0.4rem;
border-radius: 0.35rem;
font-size: 0.68rem;
color: #fff;
background: rgb(0 0 0 / 0.55);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credits {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.credits summary {
cursor: pointer;
}
.credits ol {
margin-top: 0.4rem;
display: grid;
gap: 0.15rem;
}
.credits a,
figcaption a {
text-decoration: underline;
}
dialog {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: rgb(0 0 0 / 0.92);
color: #fff;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
}
figure {
position: absolute;
inset: 3.5rem 1rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
pointer-events: none;
}
figure img {
max-width: 100%;
min-height: 0;
flex: 0 1 auto;
object-fit: contain;
border-radius: 0.4rem;
pointer-events: auto;
}
figcaption {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3rem 1rem;
font-size: 0.8rem;
color: rgb(255 255 255 / 0.8);
pointer-events: auto;
text-align: center;
}
.nav,
.close {
position: absolute;
display: grid;
place-items: center;
border-radius: 999px;
background: rgb(255 255 255 / 0.12);
color: #fff;
}
.nav:hover,
.close:hover,
.nav:focus-visible,
.close:focus-visible {
background: rgb(255 255 255 / 0.25);
}
.nav {
top: 50%;
width: 3rem;
height: 3rem;
transform: translateY(-50%);
}
.prev {
left: 0.75rem;
}
.next {
right: 0.75rem;
}
.close {
top: 0.75rem;
right: 0.75rem;
width: 2.5rem;
height: 2.5rem;
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { fmtNumber, groupHue, photoAt, type GroupTileData } from './shared.js';
let { group, compact = false }: { group: GroupTileData; compact?: boolean } = $props();
</script>
<a
href="/trackers/wildlife/{group.slug}"
class={['tile', { compact }]}
title="{group.name} · {group.nameNl}"
style:--hue={groupHue(group.slug)}
>
{#if group.photo}
<img
src={photoAt(group.photo, compact ? 'small' : 'medium')}
alt=""
loading="lazy"
decoding="async"
/>
{/if}
<span class="shade"></span>
<span class="emoji" aria-hidden="true">{group.emoji}</span>
<span class="text">
<span class="name">{group.name}</span>
{#if !compact}<span class="nl">{group.nameNl}</span>{/if}
<span class="meta"
>{fmtNumber(group.speciesCount)} species{#if group.nlCount}
· {fmtNumber(group.nlCount)} in NL{/if}</span
>
</span>
</a>
<style>
.tile {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
min-height: 9rem;
overflow: hidden;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: radial-gradient(
circle at 30% 20%,
hsl(var(--hue) 35% 24%),
hsl(var(--hue) 30% 10%)
);
color: #fff;
transition:
transform 0.15s,
border-color 0.15s;
}
.compact {
min-height: 6.5rem;
}
.tile:hover,
.tile:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.tile:hover img {
transform: scale(1.06);
}
.shade {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.8), rgb(0 0 0 / 0.1) 65%);
}
.emoji {
position: absolute;
top: 0.5rem;
left: 0.55rem;
font-size: 1.5rem;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.6));
}
.compact .emoji {
font-size: 1.15rem;
}
.text {
position: relative;
display: flex;
flex-direction: column;
padding: 0.6rem 0.7rem;
min-width: 0;
}
.name {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2;
}
.compact .name {
font-size: 0.8rem;
}
.nl {
font-size: 0.72rem;
opacity: 0.85;
}
.meta {
margin-top: 0.15rem;
font-size: 0.68rem;
opacity: 0.8;
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { page } from '$app/state';
import SpeciesCard from './SpeciesCard.svelte';
import {
fmtNumber,
SORTS,
STATUSES,
statusLabel,
WHERE,
type SpeciesCard as Card
} from './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 X from 'lucide-svelte/icons/x';
import type { WildFilters } from '#lib/server/wildlife.js';
let {
base,
filters: f,
species,
total,
pageSize,
groups = null,
quick = []
}: {
base: string;
filters: WildFilters;
species: Card[];
total: number;
pageSize: number;
groups?: { slug: string; name: string; emoji: string; speciesCount: number }[] | null;
quick?: { label: string; params: Record<string, string> }[];
} = $props();
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
const filtering = $derived(
!!(f.q || (groups && f.group) || f.status || f.where || f.photos || f.sort !== 'nl')
);
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 `${base}${qs ? `?${qs}` : ''}`;
}
</script>
<form
bind:this={form}
method="GET"
action={base}
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 'vos', 'red fox', 'vulpes', 'owl', 'libel'…"
aria-label="Search species"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
{#if groups}
<NativeSelect name="group" aria-label="Group" value={f.group} onchange={submit}>
<option value="">all animals</option>
{#each groups as g (g.slug)}<option value={g.slug}
>{g.emoji} {g.name} ({g.speciesCount})</option
>{/each}
</NativeSelect>
{/if}
<NativeSelect name="status" aria-label="IUCN status" value={f.status} onchange={submit}>
<option value="">any status</option>
<option value="threatened">⚠️ threatened (VU, EN, CR)</option>
{#each STATUSES as s (s)}<option value={s}>{s} · {statusLabel[s]}</option>{/each}
</NativeSelect>
<NativeSelect name="where" aria-label="Where" value={f.where} onchange={submit}>
<option value="">anywhere</option>
{#each Object.entries(WHERE) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<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={base} 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(total)}
{total === 1 ? 'species' : 'species'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if species.length}
<ul class="grid">
{#each species as s, i (s.dex)}
<li><SpeciesCard species={s} eager={i < 4} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No animals match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href={base} 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}
<style>
.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: 14rem;
}
.pill,
.quick {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
background: var(--site-surface);
transition: background 0.15s;
}
.quick {
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
font-weight: 400;
}
.pill:hover,
.quick:hover {
background: var(--site-hover);
}
.clear {
color: var(--site-muted);
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
}
.empty {
border: 1px dashed var(--site-border);
border-radius: 0.9rem;
padding: 3rem 1rem;
text-align: center;
}
.pager {
margin-top: 2rem;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import StatusChip from './StatusChip.svelte';
import {
dexLabel,
displayName,
fmtCompact,
groupHue,
photoAt,
photoSrcset,
type SpeciesCard
} from './shared.js';
let { species, eager = false }: { species: SpeciesCard; eager?: boolean } = $props();
const href = $derived(`/trackers/wildlife/${species.groupSlug}/${species.slug}`);
const name = $derived(displayName(species));
</script>
<a {href} class="card">
<div class="media" style:--hue={groupHue(species.groupSlug)}>
{#if species.photo}
<img
src={photoAt(species.photo, 'medium')}
srcset={photoSrcset(species.photo)}
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
alt={name}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
width="330"
height="248"
/>
{:else}
<span class="placeholder" aria-hidden="true">{species.groupEmoji}</span>
{/if}
<span class="emoji" aria-hidden="true">{species.groupEmoji}</span>
<span class="dex">{dexLabel(species.dex)}</span>
{#if species.photoCount > 1}<span class="count" title="photos">📷 {species.photoCount}</span
>{/if}
</div>
<div class="min-w-0">
<h3 class="name">{name}</h3>
{#if species.nameNl && species.nameNl !== name}<p class="nl">🇳🇱 {species.nameNl}</p>{/if}
<p class="sci">{species.scientificName}</p>
</div>
<div class="foot">
<StatusChip iucn={species.iucn} />
<span class="seen" title="Research-grade observations on iNaturalist">
{#if species.nlObs > 0}👀 {fmtCompact(species.nlObs)} in NL{:else}🌍 {fmtCompact(
species.worldObs
)} seen{/if}
</span>
</div>
</a>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.55rem;
height: 100%;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 0.9rem;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.card:hover,
.card:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.media {
position: relative;
margin: -0.9rem -0.9rem 0.1rem;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.85rem 0.85rem 0 0;
background: radial-gradient(circle at 30% 20%, hsl(var(--hue) 35% 22%), hsl(var(--hue) 30% 9%));
}
.media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.card:hover .media img {
transform: scale(1.05);
}
.placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.5rem;
opacity: 0.35;
}
.emoji {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
font-size: 1rem;
line-height: 1;
background: rgb(0 0 0 / 0.5);
border-radius: 999px;
padding: 0.25rem 0.35rem;
backdrop-filter: blur(4px);
}
.dex,
.count {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.72rem;
color: #fff;
background: rgb(0 0 0 / 0.55);
border-radius: 999px;
padding: 0.05rem 0.45rem;
backdrop-filter: blur(4px);
}
.count {
top: auto;
bottom: 0.55rem;
}
.name {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.nl {
margin-top: 0.1rem;
font-size: 0.78rem;
}
.sci {
margin-top: 0.1rem;
font-size: 0.75rem;
font-style: italic;
color: var(--site-muted);
overflow-wrap: anywhere;
}
.foot {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.55rem;
min-height: 2rem;
}
.seen {
font-size: 0.72rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { statusColor, statusLabel, statusLabelNl } from './shared.js';
let { iucn, size = 'sm' }: { iucn: string | null; size?: 'sm' | 'md' } = $props();
</script>
{#if iucn}
<span
class={['status', size]}
style:--c={statusColor[iucn] ?? '#94a3b8'}
title="IUCN Red List: {statusLabel[iucn] ?? iucn} ({statusLabelNl[iucn] ?? ''})"
><b>{iucn}</b>{#if size === 'md'}
{statusLabel[iucn] ?? iucn}{/if}</span
>
{/if}
<style>
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
background: color-mix(in srgb, var(--c) 18%, transparent);
white-space: nowrap;
line-height: 1;
}
.status b {
font-weight: 700;
color: var(--c);
}
.sm {
font-size: 0.68rem;
padding: 0.22rem 0.45rem;
}
.md {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
}
</style>

129
src/lib/wildlife/shared.ts Normal file
View File

@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, number> = {
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;
}

View File

@@ -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',

View File

@@ -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.`
}
};
};

View File

@@ -0,0 +1,143 @@
<script lang="ts">
import GroupTile from '#lib/wildlife/GroupTile.svelte';
import SpeciesBrowser from '#lib/wildlife/SpeciesBrowser.svelte';
import { fmtNumber } from '#lib/wildlife/shared.js';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const f = $derived(data.filters);
const filtering = $derived(!!(f.q || f.group || f.status || f.where || f.sort !== 'nl'));
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🇳🇱 Dutch birds', params: { group: 'birds', where: 'nl', sort: 'nl' } },
{ label: '🦊 Dutch mammals', params: { group: 'mammals', where: 'nl', sort: 'nl' } },
{ label: '⚠️ threatened', params: { status: 'threatened', sort: 'world' } },
{ label: '🌍 famous worldwide', params: { where: 'world', sort: 'world' } },
{ label: '🐘 heaviest', params: { sort: 'heaviest' } },
{ label: '🐢 longest living', params: { sort: 'oldest' } },
{ label: '🦉 owls', params: { q: 'owl' } }
];
</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">wildlife</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
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.
</p>
</div>
<a href="/trackers/wildlife/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random animal</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>species</dt>
<dd>{fmtNumber(data.stats.species)}</dd>
</div>
<div>
<dt>seen in NL</dt>
<dd>{fmtNumber(data.stats.nl)}</dd>
</div>
<div>
<dt>threatened</dt>
<dd>{fmtNumber(data.stats.threatened)}</dd>
</div>
<div>
<dt>photos</dt>
<dd>{fmtNumber(data.stats.photos)}</dd>
</div>
</dl>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="groups-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="groups-heading" class="text-lg font-semibold">groups</h2>
<a href="/trackers/wildlife/groups" class="text-sm text-[var(--site-muted)] hover:underline"
>all {data.stats.groups} groups →</a
>
</div>
<ul class="groups">
{#each data.groups as group (group.slug)}
<li><GroupTile {group} compact /></li>
{/each}
</ul>
</section>
{/if}
<SpeciesBrowser
base="/trackers/wildlife"
filters={data.filters}
species={data.species}
total={data.total}
pageSize={data.pageSize}
groups={data.groups}
{quick}
/>
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
Data: <a href="https://www.inaturalist.org" class="underline">iNaturalist</a> (species lists,
names, research-grade wild observations, IUCN Red List status and Wikipedia intros),
<a href="https://www.wikidata.org" class="underline">Wikidata</a> (CC0: Dutch names, size,
weight, lifespan, who named it) and <a href="https://www.gbif.org" class="underline">GBIF</a> (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.
</footer>
</div>
<style>
.groups {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: 0.5rem;
}
.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;
}
.pill {
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;
}
.pill:hover {
background: var(--site-hover);
}
</style>

View File

@@ -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.`
}
};
};

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import SpeciesBrowser from '#lib/wildlife/SpeciesBrowser.svelte';
import { fmtNumber, photoAt } from '#lib/wildlife/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const g = $derived(data.group);
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '🇳🇱 seen in NL', params: { where: 'nl' } },
{ label: '⚠️ threatened', params: { status: 'threatened', sort: 'world' } },
{ label: '🌍 most seen worldwide', params: { sort: 'world' } },
{ label: '📖 dex order', params: { sort: 'dex' } }
];
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/wildlife" class="back"><ArrowLeft size={15} /> all animals</a>
<header class="hero mt-6 mb-8">
{#if g.photo}<img src={photoAt(g.photo, 'large')} alt="" />{/if}
<div class="shade"></div>
<div class="text">
<p class="text-sm opacity-80">{g.nameNl}</p>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{g.emoji} {g.name}</h1>
<p class="mt-2 opacity-90">
{fmtNumber(g.speciesCount)} species · {fmtNumber(g.nlCount)} seen in the Netherlands
</p>
</div>
<a
href="/trackers/wildlife/random?group={g.slug}"
class="rand"
data-sveltekit-preload-data="off"><Shuffle size={15} /> random</a
>
</header>
<SpeciesBrowser
base="/trackers/wildlife/{g.slug}"
filters={data.filters}
species={data.species}
total={data.total}
pageSize={data.pageSize}
{quick}
/>
</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);
}
.hero {
position: relative;
overflow: hidden;
min-height: 13rem;
display: flex;
align-items: flex-end;
border-radius: 1rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
color: #fff;
}
.hero img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.shade {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.8), rgb(0 0 0 / 0.15) 70%);
}
.text {
position: relative;
padding: 1.25rem 1.4rem;
}
.rand {
position: absolute;
top: 0.8rem;
right: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.rand:hover {
background: rgb(0 0 0 / 0.7);
}
</style>

View File

@@ -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
}
};
};

View File

@@ -0,0 +1,424 @@
<script lang="ts">
import Gallery from '#lib/wildlife/Gallery.svelte';
import SpeciesCard from '#lib/wildlife/SpeciesCard.svelte';
import StatusChip from '#lib/wildlife/StatusChip.svelte';
import {
dexLabel,
displayName,
fmtNumber,
statusLabel,
statusLabelNl,
statusThreat
} from '#lib/wildlife/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 s = $derived(data.species);
const g = $derived(data.group);
const name = $derived(displayName(s));
let lang = $state<'en' | 'nl'>('en');
const clamp = (n: number) => Math.max(0.03, Math.min(1, n));
const log = (n: number, max: number) => clamp(Math.log10(Math.max(1, n)) / Math.log10(max));
const fmtKg = (kg: number) =>
kg >= 1000
? `${fmtNumber(Math.round(kg / 100) / 10)} t`
: kg >= 1
? `${fmtNumber(Math.round(kg * 10) / 10)} kg`
: `${fmtNumber(Math.round(kg * 10000) / 10)} g`;
const fmtLen = (m: number) =>
m >= 1
? `${fmtNumber(Math.round(m * 100) / 100)} m`
: m >= 0.01
? `${fmtNumber(Math.round(m * 1000) / 10)} cm`
: `${fmtNumber(Math.round(m * 10000) / 10)} mm`;
// Pokedex-style "base stats", scaled against the whole animal kingdom (log scales).
const baseStats = $derived(
[
{
label: 'NL sightings',
value: fmtNumber(s.nlObs),
hint: 'iNaturalist, research grade',
score: s.nlObs ? log(s.nlObs, 300_000) : 0,
hue: 25
},
{
label: 'worldwide',
value: fmtNumber(s.worldObs),
hint: 'iNaturalist observations',
score: s.worldObs ? log(s.worldObs, 600_000) : 0,
hue: 200
},
s.massKg != null && {
label: 'weight',
value: fmtKg(s.massKg),
hint: 'adult, up to',
score: log(s.massKg * 1_000_000, 150_000_000_000_000),
hue: 280
},
s.lengthM != null && {
label: 'length',
value: fmtLen(s.lengthM),
hint: 'adult, up to',
score: log(s.lengthM * 10_000, 300_000),
hue: 140
},
s.lifespanYears != null && {
label: 'lifespan',
value: `${fmtNumber(Math.round(s.lifespanYears))} yr`,
hint: 'maximum recorded',
score: log(s.lifespanYears * 10, 4000),
hue: 45
},
s.iucn &&
statusThreat[s.iucn] != null && {
label: 'threat',
value: s.iucn,
hint: statusLabel[s.iucn],
score: statusThreat[s.iucn],
hue: 0
}
].filter((x) => !!x)
);
const funFacts = $derived(
s.facts.map((f) => {
const m = f.match(/^(\p{Extended_Pictographic}\uFE0F?|\p{Regional_Indicator}{2})\s*(.*)$/u);
return m ? { icon: m[1], text: m[2] } : { icon: '•', text: f };
})
);
const where = $derived(
s.nlObs > 0
? s.nlEstablishment === 'introduced'
? 'in the Netherlands (introduced)'
: 'in the Netherlands'
: s.europeObs > 0
? 'in Europe, not in NL'
: 'outside Europe'
);
const facts = $derived(
[
['English', s.nameEn],
['Nederlands', s.nameNl],
['scientific name', s.scientificName],
['class', s.className],
['order', s.orderName],
['family', s.familyName],
['IUCN Red List', s.iucn && `${s.iucn} · ${statusLabel[s.iucn] ?? s.iucn}`],
['Dutch status', s.nlStatus],
['in NL', s.nlEstablishment],
['sightings in NL', fmtNumber(s.nlObs)],
['sightings in Europe', s.europeObs ? fmtNumber(s.europeObs) : null],
['sightings worldwide', fmtNumber(s.worldObs)],
['GBIF records in NL', s.gbifNl != null ? fmtNumber(s.gbifNl) : null],
['weight', s.massKg != null && fmtKg(s.massKg)],
['length', s.lengthM != null && fmtLen(s.lengthM)],
['lifespan', s.lifespanYears != null && `${fmtNumber(Math.round(s.lifespanYears))} years`],
['named', s.namedYear && `${s.namedYear}${s.namedBy ? ` by ${s.namedBy}` : ''}`]
].filter(
([label, value]) => value || ['weight', 'length', 'lifespan'].includes(label as string)
) as [string, string | number | null][]
);
const summary = $derived(lang === 'nl' && s.summaryNl ? s.summaryNl : (s.summary ?? s.summaryNl));
const wiki = (lang: string, title: string) =>
`https://${lang}.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
const href = (x: { groupSlug: string; slug: string }) =>
`/trackers/wildlife/${x.groupSlug}/${x.slug}`;
</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/wildlife/{g.slug}" class="back"><ArrowLeft size={15} /> {g.name}</a>
<span class="flex items-center gap-1">
{#if data.prev}<a href={href(data.prev)} class="step" title={data.prev.name}
><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
>{/if}
<a
href="/trackers/wildlife/random"
class="step"
data-sveltekit-preload-data="off"
title="Random animal"><Shuffle size={14} /></a
>
{#if data.next}<a href={href(data.next)} class="step" title={data.next.name}
>{dexLabel(data.next.dex)}<ChevronRight size={15} /></a
>{/if}
</span>
</nav>
<header class="hero">
<p class="dex">{dexLabel(s.dex)} · {g.emoji} {g.name.toLowerCase()}</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">{name}</h1>
<p class="mt-2 text-lg text-[var(--site-muted)]">
{#if s.nameNl && s.nameNl !== name}<span lang="nl">🇳🇱 {s.nameNl}</span> ·
{/if}<i>{s.scientificName}</i>
</p>
<div class="mt-4 flex flex-wrap items-center gap-2">
<StatusChip iucn={s.iucn} size="md" />
<a href="/trackers/wildlife/{g.slug}" class="chip">{g.emoji} {g.name}</a>
<span class="chip">📍 {where}</span>
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
</div>
</header>
{#if data.photos.length}
<Gallery photos={data.photos} {name} />
{:else}
<p class="card text-sm text-[var(--site-muted)]">No Creative Commons photos found yet.</p>
{/if}
{#if summary}
<section class="card mt-4" aria-labelledby="about">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 id="about" class="!mb-0">what is it?</h2>
{#if s.summary && s.summaryNl}
<div class="toggle" role="group" aria-label="Language">
<button class:on={lang === 'en'} onclick={() => (lang = 'en')}>EN</button>
<button class:on={lang === 'nl'} onclick={() => (lang = 'nl')}>NL</button>
</div>
{/if}
</div>
<p class="summary" lang={lang === 'nl' && s.summaryNl ? 'nl' : 'en'}>{summary}</p>
</section>
{/if}
<section class="card" aria-labelledby="stats">
<h2 id="stats">base stats</h2>
<ul class="bars">
{#each baseStats as b (b.label)}
<li>
<span class="label">{b.label}</span>
<span class="track"
><span class="fill" style:width="{b.score * 100}%" style:--hue={b.hue}></span></span
>
<span class="value">{b.value}<small>{b.hint}</small></span>
</li>
{/each}
</ul>
<p class="note">
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).
</p>
</section>
{#if funFacts.length}
<section class="card" aria-labelledby="fun">
<h2 id="fun">fun facts</h2>
<ul class="fun">
{#each funFacts as f (f.text)}<li>
<span aria-hidden="true">{f.icon}</span><span>{f.text}</span>
</li>{/each}
</ul>
</section>
{/if}
<section class="card" aria-labelledby="facts">
<h2 id="facts">fact sheet</h2>
<dl class="facts">
{#each facts as [label, value] (label)}
<div>
<dt>{label}</dt>
<dd class={[!value && 'blank']}>{value || '—'}</dd>
</div>
{/each}
</dl>
{#if s.iucn}
<p class="note">
IUCN in Dutch: {statusLabelNl[s.iucn] ?? s.iucn}. Blank means the sources don't say.
</p>
{/if}
</section>
{#if data.relatives.length}
<section class="mt-8" aria-labelledby="relatives">
<h2 id="relatives">
relatives{#if s.familyName}<span class="muted"> · {s.familyName}</span>{/if}
</h2>
<ul class="grid">
{#each data.relatives as r (r.dex)}<li><SpeciesCard species={r} /></li>{/each}
</ul>
</section>
{/if}
<p class="mt-8 text-xs leading-relaxed text-[var(--site-muted)]">
Sources: <a href="https://www.inaturalist.org/taxa/{s.inatId}" class="underline"
>iNaturalist taxon {s.inatId}</a
>{#if s.wikidata}, <a href="https://www.wikidata.org/wiki/{s.wikidata}" class="underline"
>Wikidata {s.wikidata}</a
>{/if}{#if s.gbifKey}, <a href="https://www.gbif.org/species/{s.gbifKey}" class="underline"
>GBIF {s.gbifKey}</a
>{/if}{#if s.wikipediaEn}, <a href={wiki('en', s.wikipediaEn)} class="underline"
>Wikipedia (EN)</a
>{/if}{#if s.wikipediaNl}, <a href={wiki('nl', s.wikipediaNl)} class="underline"
>Wikipedia (NL)</a
>{/if}. Intro text from Wikipedia (CC BY-SA 4.0).{#if s.updatedAt}
Updated {s.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);
}
.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;
}
.muted {
color: var(--site-muted);
font-weight: 400;
font-style: italic;
}
.note {
margin-top: 0.6rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.toggle {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 999px;
overflow: hidden;
font-size: 0.75rem;
}
.toggle button {
padding: 0.15rem 0.6rem;
color: var(--site-muted);
}
.toggle button.on {
background: var(--site-hover);
color: var(--site-text);
font-weight: 600;
}
.bars {
display: grid;
gap: 0.55rem;
}
.bars li {
display: grid;
grid-template-columns: 6.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);
}
.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;
}
.summary {
font-size: 0.95rem;
line-height: 1.6;
}
.chip {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--site-border);
background: var(--site-surface);
white-space: nowrap;
}
.fun {
display: grid;
gap: 0.5rem;
font-size: 0.88rem;
}
.fun li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr));
}
</style>

View File

@@ -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.`
}
};
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import GroupTile from '#lib/wildlife/GroupTile.svelte';
import { fmtNumber } from '#lib/wildlife/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/wildlife" class="back"><ArrowLeft size={15} /> all animals</a>
<header class="mt-6 mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groups</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
{fmtNumber(data.stats.species)} species in {data.groups.length} groups. Tap one to see them all.
</p>
</header>
<ul class="grid">
{#each data.groups as group (group.slug)}
<li><GroupTile {group} /></li>
{/each}
</ul>
</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(14rem, 1fr));
gap: 0.75rem;
}
</style>

View File

@@ -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');
};