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",
"version": "1.75.3",
"version": "1.75.4",
"private": true,
"type": "module",
"scripts": {
@@ -185,60 +185,69 @@
});
// 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 bounds = L.latLngBounds([]);
for (const hike of hikes) {
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
const color = SAC_COLOR[hike.difficulty] ?? '#5e81ac';
const poly = L.polyline(latLngs, {
color,
weight: 4,
opacity: 0.9,
interactive: true
}).addTo(layer);
poly.bindTooltip(
`<strong>${hike.title}</strong><br>` +
`${hike.distanceKm.toFixed(1)} km · ↑${hike.elevationGainM} m · SAC ${hike.difficulty}`,
{ sticky: true, direction: 'top', opacity: 0.95, className: 'hike-overview-tooltip' }
);
poly.on('mouseover', () => {
poly.setStyle({ weight: 7, opacity: 1 });
poly.bringToFront();
});
poly.on('mouseout', () => {
poly.setStyle({ weight: 4, opacity: 0.9 });
});
poly.on('click', () => {
goto(resolve('/hikes/[slug]', { slug: hike.slug }));
});
function renderPolylines(): boolean {
layer.clearLayers();
const b = L.latLngBounds([]);
for (const hike of hikes) {
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
const color = SAC_COLOR[hike.difficulty] ?? '#5e81ac';
const poly = L.polyline(latLngs, {
color,
weight: 4,
opacity: 0.9,
interactive: true
}).addTo(layer);
for (const [lat, lng] of latLngs) {
bounds.extend([lat, lng]);
poly.bindTooltip(
`<strong>${hike.title}</strong><br>` +
`${hike.distanceKm.toFixed(1)} km · ↑${hike.elevationGainM} m · SAC ${hike.difficulty}`,
{ sticky: true, direction: 'top', opacity: 0.95, className: 'hike-overview-tooltip' }
);
poly.on('mouseover', () => {
poly.setStyle({ weight: 7, opacity: 1 });
poly.bringToFront();
});
poly.on('mouseout', () => {
poly.setStyle({ weight: 4, opacity: 0.9 });
});
poly.on('click', () => {
goto(resolve('/hikes/[slug]', { slug: hike.slug }));
});
for (const [lat, lng] of latLngs) {
b.extend([lat, lng]);
}
}
if (b.isValid()) {
initialBounds = b;
recenterMap = () => {
if (!initialBounds) return;
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
};
return true;
}
initialBounds = null;
recenterMap = null;
return false;
}
if (bounds.isValid()) {
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 = () => {
if (!initialBounds) return;
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
};
// 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
@@ -300,6 +309,26 @@
// React to control toggles outside the attachment.
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(() => {
if (baseLayer === currentBase) return;
tileLayers[currentBase].remove();
+27 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import HikeCard from '$lib/components/hikes/HikeCard.svelte';
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.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`)
// or any saved URL with `?tag=...` pre-selects those tags. Repeated
// params accumulate (`?tag=winter&tag=easy`). Only runs on the client —
// SSR has no searchParams to read here.
// or any saved URL with `?tag=...` pre-selects those tags. Runs once on
// mount; thereafter the URL writer below is the source of truth.
let initialTagsApplied = false;
$effect(() => {
if (initialTagsApplied) return;
if (typeof window === 'undefined') return;
const params = page.url.searchParams.getAll('tag');
if (params.length === 0) return;
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.