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",
|
"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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user