Files
website/src/routes/projects/animaldex/+page.svelte
2026-09-01 21:57:31 +00:00

358 lines
11 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
interface TrailPoint {
id: string;
lat: number;
lng: number;
seenAt: string;
reporterName: string | null;
photoUrl: string | null;
}
interface AnimalFeatureProps {
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: string | TrailPoint[];
}
interface GeoJSONFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: AnimalFeatureProps;
}
let mapContainer: HTMLDivElement;
let map: MaptilerMap | null = null;
let htmlMarkers: MaptilerMarker[] = [];
let MapMarker: typeof MaptilerMarker | null = null;
let filterSpecies = $state('');
let filterBreed = $state('');
let filterName = $state('');
let filterReporter = $state('');
let filterFrom = $state('');
let filterTo = $state('');
let showFilters = $state(false);
let selected = $state<{
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: TrailPoint[];
} | null>(null);
function parseTrail(raw: string | TrailPoint[]): TrailPoint[] {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
onMount(async () => {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker;
const initMap = (center: [number, number], zoom: number) => {
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center,
zoom
});
map.on('load', () => {
map!.addSource('trails', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map!.addLayer({
id: 'trails',
type: 'line',
source: 'trails',
paint: {
'line-color': '#f97316',
'line-width': 2,
'line-dasharray': [2, 2],
'line-opacity': 0.7
}
});
loadMarkers(Marker);
});
map.on('moveend', () => {
if (MapMarker) loadMarkers(MapMarker);
});
};
navigator.geolocation.getCurrentPosition(
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
() => initMap([4.708, 52.009], 15.5),
{ enableHighAccuracy: true, timeout: 5000 }
);
});
onDestroy(() => {
htmlMarkers.forEach((m) => m.remove());
map?.remove();
});
async function loadMarkers(Marker: typeof MaptilerMarker) {
if (!map) return;
const bounds = map.getBounds();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
minLat: String(bounds.getSouth()),
minLng: String(bounds.getWest()),
maxLat: String(bounds.getNorth()),
maxLng: String(bounds.getEast())
});
if (filterSpecies) params.set('species', filterSpecies);
if (filterBreed) params.set('breed', filterBreed);
if (filterName) params.set('name', filterName);
if (filterReporter) params.set('reporter', filterReporter);
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
const res = await fetch(`/api/map?${params}`);
if (!res.ok) return;
const geojson: { features: GeoJSONFeature[] } = await res.json();
htmlMarkers.forEach((m) => m.remove());
htmlMarkers = [];
const lineFeatures = geojson.features
.filter((f) => parseTrail(f.properties.trail).length > 1)
.map((f) => {
const trail = parseTrail(f.properties.trail);
return {
type: 'Feature' as const,
geometry: {
type: 'LineString' as const,
coordinates: [...trail]
.sort((a, b) => new Date(a.seenAt).getTime() - new Date(b.seenAt).getTime())
.map((s) => [s.lng, s.lat])
},
properties: {}
};
});
const trailSource = map?.getSource('trails');
// @ts-expect-error setData exists on GeoJSONSource
trailSource?.setData({ type: 'FeatureCollection', features: lineFeatures });
for (const feature of geojson.features) {
const props = feature.properties;
const trail = parseTrail(props.trail);
const photoUrl = trail[0]?.photoUrl ?? null;
const el = document.createElement('div');
el.style.cssText = `
width: 44px; height: 44px; border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
cursor: pointer; overflow: hidden;
background: #f97316;
`;
if (photoUrl) {
el.style.backgroundImage = `url(${photoUrl})`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
} else {
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
}
el.onclick = () => {
selected = {
animalId: props.animalId,
species: props.species,
breed: props.breed,
animalName: props.animalName,
sightingCount: props.sightingCount,
trail
};
};
const marker = new Marker({ element: el })
.setLngLat(feature.geometry.coordinates)
.addTo(map!);
htmlMarkers.push(marker);
}
}
function applyFilters() {
showFilters = false;
if (MapMarker) loadMarkers(MapMarker);
}
function clearFilters() {
filterSpecies = '';
filterBreed = '';
filterName = '';
filterReporter = '';
filterFrom = '';
filterTo = '';
if (MapMarker) loadMarkers(MapMarker);
}
const activeFilterCount = $derived(
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
.length
);
</script>
<svelte:head>
<title>Animaldex</title>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<div class="relative h-screen w-full overflow-hidden">
<div bind:this={mapContainer} class="h-full w-full"></div>
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<a
href={resolve('/projects/animaldex/register')}
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
>
+ Log a sighting
</a>
<button
onclick={() => (showFilters = !showFilters)}
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
>
Filters
{#if activeFilterCount > 0}
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
>{activeFilterCount}</span
>
{/if}
</button>
</div>
{#if showFilters}
<div
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
>✕</button
>
</div>
<div class="space-y-2">
<input
bind:value={filterSpecies}
placeholder="Species"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterBreed}
placeholder="Breed"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterName}
placeholder="Animal name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterReporter}
placeholder="Reporter name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
<input
id="filter-from"
type="date"
bind:value={filterFrom}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
<input
id="filter-to"
type="date"
bind:value={filterTo}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<button
onclick={applyFilters}
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
>Apply</button
>
<button
onclick={clearFilters}
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
>Clear</button
>
</div>
</div>
{/if}
{#if selected}
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
{#if selected.trail[0]?.photoUrl}
<div class="relative h-40 bg-gray-100">
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
<button
onclick={() => (selected = null)}
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
>✕</button
>
</div>
{/if}
<div class="p-4">
<div class="mb-1 flex items-start justify-between">
<div>
<p class="text-lg font-semibold text-gray-900">
{selected.animalName ?? selected.species}
</p>
<p class="text-sm text-gray-500">
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
</p>
</div>
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
>{selected.sightingCount}
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</div>
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
<span class="text-gray-600"
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}</span
>
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
</div>
{/each}
</div>
<a
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
>
I saw this animal too!
</a>
</div>
</div>
{/if}
</div>