From 0edd59257216470862beba8eb084e7993c9de6c6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 27 Feb 2026 11:28:25 -0800 Subject: map marker clustering Co-Authored-By: Claude Opus 4.6 --- src/components/LocationsMap.svelte | 476 ++++++++++++++++++++++++++++++++----- 1 file changed, 419 insertions(+), 57 deletions(-) (limited to 'src') diff --git a/src/components/LocationsMap.svelte b/src/components/LocationsMap.svelte index dbc56375..2f942e85 100644 --- a/src/components/LocationsMap.svelte +++ b/src/components/LocationsMap.svelte @@ -74,6 +74,7 @@ }); await new Promise((r) => setTimeout(r, duration)); + marker.getElement().style.display = ""; marker.getElement().click(); updateLabelVisibility(); await new Promise((r) => setTimeout(r, 2500)); @@ -86,8 +87,90 @@ else startFlying(); }; - let toggleList = () => (showList = !showList); - let showList = $state(); + let toggleList = () => { + showList = !showList; + showFilters = false; + if (showList) scrollToFirstActive(); + }; + + function scrollToFirstActive() { + tick().then(() => { + let target = lastToggledOn && activeFilters.has(lastToggledOn) ? lastToggledOn : activeFilters.values().next().value; + if (!target) return; + let el = document.querySelector(`[data-category="${target}"]`); + if (el) el.scrollIntoView({ block: "start" }); + }); + } + let showList = $state(true); + + let showFilters = $state(false); + let filtersUsed = false; + let toggleFilters = () => { + if (!showFilters && !filtersUsed) { + clearAllFilters(); + filtersUsed = true; + } + showFilters = !showFilters; + if (showFilters) showList = false; + }; + + const categoryLabels: { slug: string; name: string }[] = [ + { slug: "restaurant", name: "Restaurant" }, + { slug: "cafe", name: "Café" }, + { slug: "bar", name: "Bar" }, + { slug: "fast-food", name: "Fast Food" }, + { slug: "grocery", name: "Grocery" }, + { slug: "hairdresser", name: "Hairdresser" }, + { slug: "hotel", name: "Hotel" }, + { slug: "shop", name: "Shop" }, + { slug: "atm", name: "ATM" }, + { slug: "health", name: "Health" }, + { slug: "office", name: "Office" }, + { slug: "default", name: "Other" }, + ]; + + let activeFilters: Set = $state(new Set(categoryLabels.map((c) => c.slug))); + + let lastToggledOn: string | undefined = $state(); + + function toggleCategory(slug: string) { + let turningOn = !activeFilters.has(slug); + if (turningOn) { + activeFilters.add(slug); + lastToggledOn = slug; + } else { + activeFilters.delete(slug); + if (lastToggledOn === slug) lastToggledOn = undefined; + } + activeFilters = new Set(activeFilters); + if (turningOn) { + tick().then(() => { + let el = document.querySelector(`[data-category="${slug}"]`); + if (el) el.scrollIntoView({ block: "start" }); + }); + } + } + + function setAllFilters() { + activeFilters = new Set(categoryLabels.map((c) => c.slug)); + } + + function clearAllFilters() { + activeFilters = new Set(); + } + + function soloCategory(slug: string) { + activeFilters = new Set([slug]); + } + + $effect(() => { + for (let marker of markers) { + if (!marker.category) continue; + let popup = marker.getPopup(); + if (!activeFilters.has(marker.category) && popup.isOpen()) popup.remove(); + } + updateClusterSource(); + }); let currentPopup; function startFlying() { @@ -136,21 +219,97 @@ let inview: any[] = $state([]); - let updateLabelVisibility = debounce(() => { + function syncMarkerVisibility() { inview = []; if (!map) return; - let zoom = map.getZoom(); let bounds = map.getBounds(); + let unclusteredIds = new Set(); + if (map.getLayer("unclustered-point")) { + try { + let features = map.queryRenderedFeatures(undefined, { + layers: ["unclustered-point"], + }); + for (let f of features) { + if (f.properties?.id != null) unclusteredIds.add(f.properties.id); + } + } catch (_) {} + } + + let hasClusters = unclusteredIds.size > 0 || map.getSource("locations-source"); + for (let marker of markers) { let isInView = bounds.contains(marker.getLngLat()); - if (isInView) { - inview.push(marker); + let passesFilter = !marker.category || activeFilters.has(marker.category); + + if (hasClusters && map.getLayer("unclustered-point")) { + let isUnclustered = unclusteredIds.has(marker.id); + let isSelected = selected === marker; + marker.getElement().style.display = passesFilter && (isUnclustered || isSelected) ? "" : "none"; + } else { + marker.getElement().style.display = passesFilter ? "" : "none"; } + + if (isInView && passesFilter) inview.push(marker); } inview = inview.sort((a, b) => a.tags.name.localeCompare(b.tags.name)); - }, 200); + } + + let updateLabelVisibility = debounce(syncMarkerVisibility, 200); + + function getCategory(tags: any): string { + if (!tags) return "default"; + let a = tags.amenity; + let s = tags.shop; + let t = tags.tourism; + let o = tags.office; + + if (a === "restaurant") return "restaurant"; + if (a === "cafe") return "cafe"; + if (a === "bar" || a === "pub") return "bar"; + if (a === "fast_food") return "fast-food"; + if (a === "atm" || a === "bureau_de_change") return "atm"; + if (a === "pharmacy" || a === "doctors" || a === "dentist" || a === "hospital" || a === "clinic") return "health"; + if (a === "hairdresser") return "hairdresser"; + if (s === "supermarket" || s === "convenience" || s === "grocery" || s === "greengrocer") return "grocery"; + if (s === "hairdresser" || s === "barber") return "hairdresser"; + if (s) return "shop"; + if (t === "hotel" || t === "guest_house" || t === "hostel" || t === "motel") return "hotel"; + if (o) return "office"; + return "default"; + } + + const categoryIcons: Record = { + restaurant: ``, + cafe: ``, + bar: ``, + "fast-food": ``, + grocery: ``, + hairdresser: ``, + hotel: ``, + shop: ``, + atm: ``, + health: ``, + office: ``, + default: ``, + }; + + function createCategoryMarker(tags: any): HTMLElement { + let cat = getCategory(tags); + let icon = categoryIcons[cat] || categoryIcons.default; + + let el = document.createElement("div"); + el.style.cursor = "pointer"; + el.style.display = "none"; + el.style.width = "36px"; + el.style.height = "46px"; + el.innerHTML = ` + + ${icon} + `; + return el; + } let locationMarkers: any = {}; let addedIds = new Set(); @@ -177,7 +336,7 @@ props: { tags }, }); - let marker = new maplibre.Marker({ color: "#F7931A", scale: 0.65 }) + let marker = new maplibre.Marker({ element: createCategoryMarker(tags) }) .setLngLat([lon, lat]) .setPopup(new maplibre.Popup().setDOMContent(popupContainer)) .addTo(map); @@ -199,12 +358,36 @@ }); marker.tags = tags; + marker.category = getCategory(tags); locationMarkers[location.id] = marker; markers.push(marker); }); updateLabelVisibility(); + updateClusterSource(); + } + + function updateClusterSource() { + if (!map || !map.getSource("locations-source")) return; + let features = markers + .filter((m) => !m.category || activeFilters.has(m.category)) + .map((m) => { + let lngLat = m.getLngLat(); + return { + type: "Feature" as const, + properties: { id: m.id }, + geometry: { + type: "Point" as const, + coordinates: [lngLat.lng, lngLat.lat], + }, + }; + }); + (map.getSource("locations-source") as any).setData({ + type: "FeatureCollection", + features, + }); + map.once("idle", syncMarkerVisibility); } let fetchController: AbortController | null = null; @@ -218,7 +401,7 @@ lat: String(lat), lon: String(lon), radius: String(Math.ceil(radius)), - count: "100", + count: "500", }); let res = await fetch(`/locations?${params}`, { signal: fetchController.signal }); let data = await res.json(); @@ -276,61 +459,179 @@ return maplibrePromise; }; + function getStartPosition(): Promise<{ center: [number, number]; zoom: number }> { + let fallback = { center: [-123.05, 49.26] as [number, number], zoom: 10.5 }; + if (!navigator.geolocation) return Promise.resolve(fallback); + return new Promise((resolve) => { + let timer = setTimeout(() => resolve(fallback), 2000); + navigator.geolocation.getCurrentPosition( + (pos) => { + clearTimeout(timer); + resolve({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 11 }); + }, + () => { + clearTimeout(timer); + resolve(fallback); + }, + { timeout: 2000 }, + ); + }); + } + onMount(async () => { if (browser) { await tick(); if (!mapContainer) return; - loadMaplibre().then((maplibre) => { - maplibreRef = maplibre; - - map = new maplibre.Map({ - container: mapContainer, - style: "https://tiles.stadiamaps.com/styles/stamen_toner.json", - center: [-123.05, 49.26], - zoom: 10.5, - }); + let [maplibre, start] = await Promise.all([loadMaplibre(), getStartPosition()]); + maplibreRef = maplibre; + + map = new maplibre.Map({ + container: mapContainer, + style: "https://tiles.stadiamaps.com/styles/stamen_toner.json", + center: start.center, + zoom: start.zoom, + }); - map.scrollZoom.setWheelZoomRate(1 / 60); - map.scrollZoom.setZoomRate(1 / 30); + map.scrollZoom.setWheelZoomRate(1 / 60); + map.scrollZoom.setZoomRate(1 / 30); - map.on("load", () => { + map.on("load", () => { mapReady = true; - // Initial fetch for default view - fetchNearby(49.26, -123.05, 50); - - // Fetch more on pan/zoom - map.on("moveend", fetchOnMove); - - // Center on user if available - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition((position) => { - let { longitude: lng, latitude: lat } = position.coords; - map.flyTo({ center: [lng, lat], zoom: 11, duration: 1000 }); - fetchNearby(lat, lng, 50); + map.addSource("locations-source", { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + }); + + map.addLayer({ + id: "clusters", + type: "circle", + source: "locations-source", + filter: ["has", "point_count"], + paint: { + "circle-color": "#F7931A", + "circle-radius": [ + "step", + ["get", "point_count"], + 18, + 10, + 22, + 50, + 28, + 100, + 34, + ], + "circle-stroke-width": 2, + "circle-stroke-color": "#fff", + }, + }); + + map.addLayer({ + id: "cluster-count", + type: "symbol", + source: "locations-source", + filter: ["has", "point_count"], + layout: { + "text-field": ["get", "point_count_abbreviated"], + "text-size": 14, + }, + paint: { + "text-color": "#ffffff", + }, + }); + + map.addLayer({ + id: "unclustered-point", + type: "circle", + source: "locations-source", + filter: ["!", ["has", "point_count"]], + paint: { + "circle-radius": 1, + "circle-opacity": 0, + }, + }); + + map.on("click", "clusters", (e: any) => { + let features = map.queryRenderedFeatures(e.point, { + layers: ["clusters"], }); - } - }); + if (!features.length) return; + map.easeTo({ + center: features[0].geometry.coordinates, + zoom: 15, + }); + }); + + map.on("mouseenter", "clusters", () => { + map.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", "clusters", () => { + map.getCanvas().style.cursor = ""; + }); - map.on("mousedown", stopFlying); - map.on("touchstart", stopFlying); - map.on("wheel", stopFlying); - map.on("dragstart", stopFlying); + map.on("moveend", () => updateLabelVisibility()); - let resize = () => map && map.resize(); + // Initial fetch for starting position + fetchNearby(start.center[1], start.center[0], 500); - document.addEventListener("fullscreenchange", resize, false); - document.addEventListener("webkitfullscreenchange", resize, false); - document.addEventListener("mozfullscreenchange", resize, false); - document.addEventListener("MSFullscreenChange", resize, false); + // Fetch more on pan/zoom + map.on("moveend", fetchOnMove); }); + + map.on("mousedown", stopFlying); + map.on("touchstart", stopFlying); + map.on("wheel", stopFlying); + map.on("dragstart", stopFlying); + + let resize = () => map && map.resize(); + + document.addEventListener("fullscreenchange", resize, false); + document.addEventListener("webkitfullscreenchange", resize, false); + document.addEventListener("mozfullscreenchange", resize, false); + document.addEventListener("MSFullscreenChange", resize, false); } }); onDestroy(() => map && map.remove()); let list = $derived( - search ? markers.filter((m) => m.tags.name.toLowerCase().includes(search.toLowerCase())) : inview, + search + ? markers.filter( + (m) => + m.tags.name.toLowerCase().includes(search.toLowerCase()) && + (!m.category || activeFilters.has(m.category)), + ) + : inview, + ); + + let categoryLabelMap = Object.fromEntries(categoryLabels.map((c) => [c.slug, c.name])); + + let presentCategories = $derived( + categoryLabels.filter((c) => markers.some((m) => m.category === c.slug)), ); + + let allList = $derived( + search + ? markers.filter((m) => m.tags.name.toLowerCase().includes(search.toLowerCase())) + : inview, + ); + + let groupedList = $derived.by(() => { + let groups: { slug: string; name: string; markers: any[] }[] = []; + let map = new Map(); + for (let m of allList) { + let cat = m.category || "default"; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(m); + } + for (let [slug, items] of map) { + groups.push({ slug, name: categoryLabelMap[slug] || slug, markers: items }); + } + groups.sort((a, b) => a.name.localeCompare(b.name)); + return groups; + });
@@ -345,36 +646,97 @@
- {#if !showList} + {#if showList}
-
- -
+
+ +
{#if search} {:else} - + {/if}
- {#each list as marker, i} + {#each groupedList as group} + {@const active = activeFilters.has(group.slug)} + {#each group.markers as marker} + + {/each} {/each}
{/if} + {#if showFilters} +
+
+ + +
+
+ {#each presentCategories as cat} + + {/each} +
+
+ {/if}
+