fix(hikes): sync tag filter to URL + re-fit overview map on filter change

Two bugs:

* Toggling a tag in the filter bar didn't update `?tag=` in the URL,
  so the page wasn't shareable / back-button-restorable past the
  initial deep-link state. Add a writer $effect that mirrors
  `filter.tags` into the URL via `replaceState` (no history churn).

* The overview map's polylines + camera were built once on mount and
  never refreshed when the `hikes` prop changed, so filtering left
  the map showing the full set and zoomed for it. Extract polyline
  rendering into a function called both on mount and from a
  prop-watching $effect; on change, smoothly fly to the new union.
This commit is contained in:
2026-05-19 10:19:08 +02:00
parent 2a8721fde0
commit 706dedbdc5
3 changed files with 105 additions and 53 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.75.3", "version": "1.75.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -185,9 +185,15 @@
}); });
// One polyline per hike, sourced from the manifest's already- // One polyline per hike, sourced from the manifest's already-
// simplified previewPolyline (≤150 points each). // simplified previewPolyline (≤150 points each). The layer is
// re-populated on every `hikes` prop change (see the $effect
// below) so toggling filters updates the visible routes — and
// re-fits the camera to the new union bounds.
const layer = L.layerGroup().addTo(map); const layer = L.layerGroup().addTo(map);
const bounds = L.latLngBounds([]);
function renderPolylines(): boolean {
layer.clearLayers();
const b = L.latLngBounds([]);
for (const hike of hikes) { for (const hike of hikes) {
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue; if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]); const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
@@ -216,20 +222,11 @@
}); });
for (const [lat, lng] of latLngs) { for (const [lat, lng] of latLngs) {
bounds.extend([lat, lng]); b.extend([lat, lng]);
} }
} }
if (b.isValid()) {
if (bounds.isValid()) { initialBounds = b;
initialBounds = bounds;
// When the caller handed us a pre-rendered hero pose, we
// already called `setView(initialCenter, initialZoom)` above
// and rely on the tile-load handler to fly to bounds (so the
// static→live cross-fade happens at the matching pose). With
// no pre-rendered hero, fitBounds straight away.
if (!initialCenter || typeof initialZoom !== 'number') {
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
}
recenterMap = () => { recenterMap = () => {
if (!initialBounds) return; if (!initialBounds) return;
map.flyToBounds(initialBounds, { map.flyToBounds(initialBounds, {
@@ -239,6 +236,18 @@
easeLinearity: 0.25 easeLinearity: 0.25
}); });
}; };
return true;
}
initialBounds = null;
recenterMap = null;
return false;
}
// Initial paint — no animated fit when the caller handed us a
// pre-rendered hero pose (the tile-load handover handles the
// fly-to), otherwise fit straight to the union bounds.
if (renderPolylines() && (!initialCenter || typeof initialZoom !== 'number') && initialBounds) {
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
} }
// User location (opt-in). Same Tauri-first / Web-Geolocation-fallback // User location (opt-in). Same Tauri-first / Web-Geolocation-fallback
@@ -300,6 +309,26 @@
// React to control toggles outside the attachment. // React to control toggles outside the attachment.
const stopReactRoot = $effect.root(() => { const stopReactRoot = $effect.root(() => {
// Re-render polylines whenever the `hikes` prop changes
// (filter bar toggles, tag deep-link). The first $effect
// run fires immediately and would re-do the initial paint
// for no UX gain — skip it via a tick counter.
let rerunTick = 0;
$effect(() => {
void hikes;
if (rerunTick++ === 0) return;
if (renderPolylines() && initialBounds) {
// Smooth re-fit so the user sees the camera glide
// toward whichever subset is now on display.
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
}
});
$effect(() => { $effect(() => {
if (baseLayer === currentBase) return; if (baseLayer === currentBase) return;
tileLayers[currentBase].remove(); tileLayers[currentBase].remove();
+27 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state'; import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import HikeCard from '$lib/components/hikes/HikeCard.svelte'; import HikeCard from '$lib/components/hikes/HikeCard.svelte';
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte'; import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte'; import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
@@ -60,14 +61,36 @@
}); });
// Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`) // Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`)
// or any saved URL with `?tag=...` pre-selects those tags. Repeated // or any saved URL with `?tag=...` pre-selects those tags. Runs once on
// params accumulate (`?tag=winter&tag=easy`). Only runs on the client — // mount; thereafter the URL writer below is the source of truth.
// SSR has no searchParams to read here. let initialTagsApplied = false;
$effect(() => { $effect(() => {
if (initialTagsApplied) return;
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const params = page.url.searchParams.getAll('tag'); const params = page.url.searchParams.getAll('tag');
if (params.length === 0) return;
for (const t of params) if (t) filter.tags.add(t); for (const t of params) if (t) filter.tags.add(t);
initialTagsApplied = true;
});
// Tag URL sync: every toggle in the filter bar reflects into the URL
// so the page is shareable / back-button-restorable. `replaceState`
// rather than `goto` keeps history clean — toggling four tags would
// otherwise leave four back-button stops.
$effect(() => {
if (typeof window === 'undefined' || !initialTagsApplied) return;
const url = new URL(window.location.href);
const wanted = [...filter.tags].sort();
const current = url.searchParams.getAll('tag').slice().sort();
// Skip the no-op rewrite path — `replaceState` would still touch
// history's state object and trigger downstream `page.url` effects
// for no UX benefit.
if (
wanted.length === current.length &&
wanted.every((t, i) => t === current[i])
) return;
url.searchParams.delete('tag');
for (const t of wanted) url.searchParams.append('tag', t);
replaceState(url, page.state);
}); });
// One-shot per mount: set the slider ceilings to the actual data maxes. // One-shot per mount: set the slider ceilings to the actual data maxes.