wildlife: pokedex pages (list, groups, group, species detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
This commit is contained in:
@@ -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',
|
||||
|
||||
22
src/routes/trackers/wildlife/+page.server.ts
Normal file
22
src/routes/trackers/wildlife/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
143
src/routes/trackers/wildlife/+page.svelte
Normal file
143
src/routes/trackers/wildlife/+page.svelte
Normal 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>
|
||||
29
src/routes/trackers/wildlife/[group]/+page.server.ts
Normal file
29
src/routes/trackers/wildlife/[group]/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
101
src/routes/trackers/wildlife/[group]/+page.svelte
Normal file
101
src/routes/trackers/wildlife/[group]/+page.svelte
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
424
src/routes/trackers/wildlife/[group]/[species]/+page.svelte
Normal file
424
src/routes/trackers/wildlife/[group]/[species]/+page.svelte
Normal 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>
|
||||
14
src/routes/trackers/wildlife/groups/+page.server.ts
Normal file
14
src/routes/trackers/wildlife/groups/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
42
src/routes/trackers/wildlife/groups/+page.svelte
Normal file
42
src/routes/trackers/wildlife/groups/+page.svelte
Normal 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>
|
||||
10
src/routes/trackers/wildlife/random/+server.ts
Normal file
10
src/routes/trackers/wildlife/random/+server.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user