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:
@@ -142,6 +142,14 @@ const allTrackers = [
|
|||||||
href: '/trackers/planes',
|
href: '/trackers/planes',
|
||||||
external: false,
|
external: false,
|
||||||
private: 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;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.'
|
'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': {
|
'/trackers/pc': {
|
||||||
title: 'PC setup',
|
title: 'PC setup',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
|
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(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
|
|||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
|
'/trackers/wildlife',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
'/trackers/pc'
|
'/trackers/pc'
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
247
src/lib/server/wildlife.ts
Normal file
247
src/lib/server/wildlife.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/trackers/books': 'Books',
|
'/trackers/books': 'Books',
|
||||||
'/trackers/cars': 'Cars',
|
'/trackers/cars': 'Cars',
|
||||||
'/trackers/planes': 'Planes',
|
'/trackers/planes': 'Planes',
|
||||||
|
'/trackers/wildlife': 'Wildlife',
|
||||||
'/trackers/pc': 'PC setup',
|
'/trackers/pc': 'PC setup',
|
||||||
'/blog': 'Blog',
|
'/blog': 'Blog',
|
||||||
'/lists': 'Lists',
|
'/lists': 'Lists',
|
||||||
|
|||||||
@@ -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'
|
'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: {
|
lists: {
|
||||||
from: '#94a3b8',
|
from: '#94a3b8',
|
||||||
to: '#475569',
|
to: '#475569',
|
||||||
|
|||||||
256
src/lib/wildlife/Gallery.svelte
Normal file
256
src/lib/wildlife/Gallery.svelte
Normal 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>
|
||||||
112
src/lib/wildlife/GroupTile.svelte
Normal file
112
src/lib/wildlife/GroupTile.svelte
Normal 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>
|
||||||
221
src/lib/wildlife/SpeciesBrowser.svelte
Normal file
221
src/lib/wildlife/SpeciesBrowser.svelte
Normal 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>
|
||||||
162
src/lib/wildlife/SpeciesCard.svelte
Normal file
162
src/lib/wildlife/SpeciesCard.svelte
Normal 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>
|
||||||
40
src/lib/wildlife/StatusChip.svelte
Normal file
40
src/lib/wildlife/StatusChip.svelte
Normal 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
129
src/lib/wildlife/shared.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ const staticPaths = [
|
|||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
'/trackers/planes/makers',
|
'/trackers/planes/makers',
|
||||||
|
'/trackers/wildlife',
|
||||||
|
'/trackers/wildlife/groups',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
'/trackers/pc',
|
'/trackers/pc',
|
||||||
'/projects/game-tracker',
|
'/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