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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.75.3",
|
||||
"version": "1.75.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -185,9 +185,15 @@
|
||||
});
|
||||
|
||||
// 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([]);
|
||||
|
||||
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]);
|
||||
@@ -216,20 +222,11 @@
|
||||
});
|
||||
|
||||
for (const [lat, lng] of latLngs) {
|
||||
bounds.extend([lat, lng]);
|
||||
b.extend([lat, lng]);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
if (b.isValid()) {
|
||||
initialBounds = b;
|
||||
recenterMap = () => {
|
||||
if (!initialBounds) return;
|
||||
map.flyToBounds(initialBounds, {
|
||||
@@ -239,6 +236,18 @@
|
||||
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
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user