Commit:
37c0103Parent:
6aa592eAdd history sub-nav, sightings map with Leaflet, and bird markers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
deno.lock
+16
-0
diff --git a/deno.lock b/deno.lock
index 3c4107b..39f4224 100644
@@ -8,10 +8,12 @@
"npm:@sveltejs/kit@^2.57.0": "2.61.0_@sveltejs+vite-plugin-svelte@7.1.2__svelte@5.55.9__vite@8.0.14___@types+node@24.12.4__@types+node@24.12.4_svelte@5.55.9_typescript@6.0.3_vite@8.0.14__@types+node@24.12.4_@types+node@24.12.4",
"npm:@sveltejs/vite-plugin-svelte@7": "7.1.2_svelte@5.55.9_vite@8.0.14__@types+node@24.12.4_@types+node@24.12.4",
"npm:@tensorflow/tfjs@^4.22.0": "4.22.0_seedrandom@3.0.5",
"npm:@types/leaflet@^1.9.16": "1.9.21",
"npm:@types/node@24": "24.12.4",
"npm:eslint-plugin-svelte@^3.17.0": "3.17.1_eslint@10.4.0_svelte@5.55.9",
"npm:eslint@^10.2.0": "10.4.0",
"npm:globals@^17.4.0": "17.6.0",
"npm:leaflet@^1.9.4": "1.9.4",
"npm:svelte-check@^4.4.6": "4.4.8_svelte@5.55.9_typescript@6.0.3",
"npm:svelte@^5.55.2": "5.55.9",
"npm:typescript-eslint@^8.58.1": "8.59.4_eslint@10.4.0_typescript@6.0.3",
@@ -385,9 +387,18 @@
"@types/estree@1.0.9": {
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="
},
"@types/geojson@7946.0.16": {
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/leaflet@1.9.21": {
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dependencies": [
"@types/geojson"
]
},
"@types/long@4.0.2": {
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
},
@@ -990,6 +1001,9 @@
"known-css-properties@0.37.0": {
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="
},
"leaflet@1.9.4": {
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"levn@0.4.1": {
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dependencies": [
@@ -1528,10 +1542,12 @@
"npm:@sveltejs/kit@^2.57.0",
"npm:@sveltejs/vite-plugin-svelte@7",
"npm:@tensorflow/tfjs@^4.22.0",
"npm:@types/leaflet@^1.9.16",
"npm:@types/node@24",
"npm:eslint-plugin-svelte@^3.17.0",
"npm:eslint@^10.2.0",
"npm:globals@^17.4.0",
"npm:leaflet@^1.9.4",
"npm:svelte-check@^4.4.6",
"npm:svelte@^5.55.2",
"npm:typescript-eslint@^8.58.1",
package.json
+3
-1
diff --git a/package.json b/package.json
index 7c25b0d..bf3be0f 100644
@@ -15,7 +15,8 @@
"test": "npm run test:e2e"
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0"
"@tensorflow/tfjs": "^4.22.0",
"leaflet": "^1.9.4"
},
"devDependencies": {
"@eslint/compat": "^2.0.4",
@@ -24,6 +25,7 @@
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/leaflet": "^1.9.16",
"@types/node": "^24",
"eslint": "^10.2.0",
"eslint-plugin-svelte": "^3.17.0",
src/routes/history/+layout.svelte
+81
-0
diff --git a/src/routes/history/+layout.svelte b/src/routes/history/+layout.svelte
new file mode 100644
index 0000000..f5cc8e8
@@ -0,0 +1,81 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { page } from "$app/stores";
let { children } = $props();
</script>
{@render children()}
<nav class="sub">
<a
href={resolve("/history")}
aria-label="Detection log"
aria-current={$page.url.pathname === "/history" ? "page" : undefined}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="2" y="5" width="20" height="3" rx="1.5" />
<rect x="2" y="11" width="20" height="3" rx="1.5" />
<rect x="2" y="17" width="14" height="3" rx="1.5" />
</svg>
</a>
<a
href={resolve("/history/map")}
aria-label="Sightings map"
aria-current={$page.url.pathname.startsWith("/history/map") ? "page" : undefined}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="9" r="8" />
<polygon points="5,14 12,23 19,14" />
<circle cx="12" cy="9" r="3.5" fill="var(--bg-deep)" />
</svg>
</a>
</nav>
<style>
.sub {
position: fixed;
bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
left: 0;
right: 0;
height: 3rem;
background: var(--bg-deep);
border-top: 1px solid rgba(255, 244, 214, 0.08);
display: flex;
align-items: center;
justify-content: center;
gap: 3rem;
a {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--bg-cream);
opacity: 0.5;
text-decoration: none;
padding: 0.25rem 0.5rem;
gap: 0.2rem;
transition: opacity 0.18s ease-out;
svg {
width: 1.25rem;
height: 1.25rem;
}
&:hover,
&:focus-visible,
&[aria-current="page"] {
opacity: 1;
}
&[aria-current="page"]::after {
content: "";
width: 0.3rem;
height: 0.3rem;
border-radius: 50%;
background: var(--brand-red);
}
}
}
</style>
src/routes/history/+page.svelte
+1
-1
diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte
index 10cc2f8..3b978fe 100644
@@ -31,7 +31,7 @@
list-style: none;
padding: 1rem;
padding-top: 1.25rem;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 1rem);
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 4rem);
display: flex;
flex-direction: column;
gap: 0.75rem;
src/routes/history/map/+page.svelte
+104
-0
diff --git a/src/routes/history/map/+page.svelte b/src/routes/history/map/+page.svelte
new file mode 100644
index 0000000..6a844aa
@@ -0,0 +1,104 @@
<script lang="ts">
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { get } from "svelte/store";
import { historyStore } from "$lib/history";
import { labelsStore } from "$lib/labels";
function buildMap(node: HTMLElement) {
const entries = get(historyStore).filter((e) => e.coords != null);
const labels = get(labelsStore);
const map = L.map(node, { zoomControl: true });
L.tileLayer(
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
{
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
},
).addTo(map);
const birdIcon = L.divIcon({
html: `<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<polygon points="13,9 20,6 18,13 13,12" fill="#c4253a"/>
<ellipse cx="9" cy="11" rx="6" ry="4.5" fill="#c4253a"/>
<circle cx="5" cy="8" r="4" fill="#c4253a"/>
<polygon points="1,8 5,6.5 5,9.5" fill="#e89a3a"/>
<circle cx="3.5" cy="7.5" r="0.9" fill="#fff4d6"/>
</svg>`,
className: "bird-pin",
iconSize: [20, 20],
iconAnchor: [10, 10],
popupAnchor: [0, -14],
});
if (entries.length > 0) {
const group = L.featureGroup();
for (const entry of entries) {
const name = labels[entry.labelIndex]?.commonName ?? "Unknown bird";
L.marker([entry.coords!.lat, entry.coords!.lng], { icon: birdIcon })
.bindPopup(name)
.addTo(group);
}
group.addTo(map);
map.fitBounds(group.getBounds(), { padding: [40, 40] });
} else {
map.setView([20, 0], 2);
}
return () => map.remove();
}
</script>
<div class="map" {@attach buildMap}></div>
{#if !get(historyStore).some((e) => e.coords != null)}
<p class="empty">No sightings with GPS yet.</p>
{/if}
<style>
.map {
position: fixed;
inset: 0;
bottom: calc(var(--nav-height) + 3rem + env(safe-area-inset-bottom));
}
.empty {
position: fixed;
bottom: calc(var(--nav-height) + 3rem + env(safe-area-inset-bottom) + 1rem);
left: 0;
right: 0;
text-align: center;
color: var(--bg-near-black);
font-weight: 700;
font-size: 0.9rem;
pointer-events: none;
}
:global(.leaflet-popup-content-wrapper) {
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
box-shadow: 0 3px 0 var(--bg-near-black);
padding: 0;
}
:global(.leaflet-popup-content) {
margin: 0.4rem 0.75rem;
font-family: system-ui, sans-serif;
font-weight: 700;
font-size: 0.85rem;
color: var(--bg-near-black);
}
:global(.leaflet-popup-tip-container) {
display: none;
}
:global(.bird-pin) {
background: none;
border: none;
}
</style>