aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-27 11:28:25 -0800
committerAdam Soltys <[email protected]>2026-02-27 11:28:25 -0800
commit0edd59257216470862beba8eb084e7993c9de6c6 (patch)
treee60f90ac93741ce55aba00e8ffe2a1f9360077fa /src
parente9fa391cf87ee09cf8b591e0d0c373c71c5a83b6 (diff)
downloadcoinos-ui-0edd59257216470862beba8eb084e7993c9de6c6.tar.gz
coinos-ui-0edd59257216470862beba8eb084e7993c9de6c6.zip
map marker clustering
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/components/LocationsMap.svelte470
1 files changed, 416 insertions, 54 deletions
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<string> = $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<number>();
+ 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<string, string> = {
+ restaurant: `<path fill="white" d="M68 88V40a12 12 0 0 1 24 0v48a12 12 0 0 1-24 0m152-48v184a12 12 0 0 1-24 0v-44h-44a12 12 0 0 1-12-12a273.2 273.2 0 0 1 7.33-57.82c10.09-41.76 29.43-69.85 55.94-81.18A12 12 0 0 1 220 40m-24 22.92C182.6 77 175 98 170.77 115.38a254.4 254.4 0 0 0-6.22 40.62H196ZM128 39a12 12 0 0 0-24 2l4 47.46a28 28 0 0 1-56 0L56 41a12 12 0 1 0-24-2l-4 48v1a52.1 52.1 0 0 0 40 50.59V224a12 12 0 0 0 24 0v-85.41A52.1 52.1 0 0 0 132 88v-1Z"/>`,
+ cafe: `<path fill="white" d="M212 76H32a12 12 0 0 0-12 12v48a100.24 100.24 0 0 0 26.73 68H32a12 12 0 0 0 0 24h176a12 12 0 0 0 0-24h-14.73a100.8 100.8 0 0 0 20-32A44 44 0 0 0 256 128v-8a44.05 44.05 0 0 0-44-44m-16 60a76.27 76.27 0 0 1-42 68H86a76.27 76.27 0 0 1-42-68v-36h152Zm36-8a20 20 0 0 1-12.57 18.55A97 97 0 0 0 220 136v-34.32A20 20 0 0 1 232 120ZM68 48V24a12 12 0 0 1 24 0v24a12 12 0 0 1-24 0m40 0V24a12 12 0 0 1 24 0v24a12 12 0 0 1-24 0m40 0V24a12 12 0 0 1 24 0v24a12 12 0 0 1-24 0"/>`,
+ bar: `<path fill="white" d="m248.49 39.51l-32-32a12 12 0 0 0-18.55 15L147 60.77l-37.33 7.46a12.1 12.1 0 0 0-6.14 3.28L20.2 154.83a28 28 0 0 0 0 39.6l41.37 41.37a28 28 0 0 0 39.6 0l83.32-83.31a12.1 12.1 0 0 0 3.28-6.14l7.46-37.35l38.23-51a12 12 0 0 0 15-18.55ZM112 191l-47-47l31-31l47 47Zm-30.63 29a4 4 0 0 1-2.83-1.17l-41.37-41.37a4 4 0 0 1 0-5.66L48 161l47 47l-10.8 10.83a4 4 0 0 1-2.83 1.17m93-123.2a12 12 0 0 0-2.17 4.85l-7.2 36.43l-5 4.92l-47-47l5-5l36.43-7.28a12 12 0 0 0 4.85-2.17l55.67-41.76l1.29 1.29Z"/>`,
+ "fast-food": `<path fill="white" d="m227.9 152.72l-39.7 14.44l-35.74-14.3a12 12 0 0 0-8.92 0L108 167.08l-35.54-14.22a12 12 0 0 0-8.56-.14l-44 16a12 12 0 0 0 8.2 22.56l8.12-2.95A44.06 44.06 0 0 0 80 228h96a44.05 44.05 0 0 0 44-44v-2.87l16.1-5.85a12 12 0 0 0-8.2-22.56M176 204H80a20 20 0 0 1-20-20v-4.32l7.8-2.84l35.74 14.3a12 12 0 0 0 8.92 0L148 176.92l35.54 14.22a12 12 0 0 0 8.56.14l2.89-1.06A20 20 0 0 1 176 204M12 128a12 12 0 0 1 12-12h208a12 12 0 0 1 0 24H24a12 12 0 0 1-12-12m36.2-24h159.6a20.36 20.36 0 0 0 16.38-8.29a19.59 19.59 0 0 0 2.88-17.65C216.12 43.88 175.39 20 128 20S39.89 43.87 28.94 78.05a19.56 19.56 0 0 0 2.88 17.65A20.32 20.32 0 0 0 48.2 104M128 44c33.7 0 63.61 14.85 74 36H54c10.4-21.15 40.31-36 74-36"/>`,
+ grocery: `<path fill="white" d="M233.21 56.31A12 12 0 0 0 224 52H66l-5.47-30.15A12 12 0 0 0 48.73 12H24a12 12 0 0 0 0 24h14.71l24.91 137a28 28 0 0 0 4.07 10.21A32 32 0 1 0 123 196h34a32 32 0 1 0 31-24H91.17a4 4 0 0 1-3.93-3.28L84.92 156H196.1a28 28 0 0 0 27.55-23l12.16-66.86a12 12 0 0 0-2.6-9.83M100 204a8 8 0 1 1-8-8a8 8 0 0 1 8 8m88 8a8 8 0 1 1 8-8a8 8 0 0 1-8 8m12-83.28a4 4 0 0 1-3.9 3.28H80.56L70.38 76h139.24Z"/>`,
+ hairdresser: `<path fill="white" d="M238.78 183.79L98.28 87.65A40.2 40.2 0 0 0 100 76a40 40 0 1 0-15.29 31.45l30 20.56l-30 20.56a40 40 0 1 0 3.57 59.74A39.73 39.73 0 0 0 100 180a40.2 40.2 0 0 0-1.72-11.66l37.72-25.8l89.22 61.06a12 12 0 0 0 13.56-19.81m-167.47 7.54A16 16 0 1 1 76 180a16 16 0 0 1-4.69 11.33M48.69 87.3a16 16 0 1 1 22.62 0a16 16 0 0 1-22.62 0m112.82 23.24a12 12 0 0 1 3.13-16.68l60.58-41.46a12 12 0 0 1 13.56 19.81l-60.59 41.46a12 12 0 0 1-16.68-3.13"/>`,
+ hotel: `<path fill="white" d="M212 68H36V48a12 12 0 0 0-24 0v160a12 12 0 0 0 24 0v-28h196v28a12 12 0 0 0 24 0v-96a44.05 44.05 0 0 0-44-44m-112 88H36V92h64Zm132 0H124V92h88a20 20 0 0 1 20 20Z"/>`,
+ shop: `<path fill="white" d="M236 96a12 12 0 0 0-.44-3.3L221.2 42.51A20.08 20.08 0 0 0 202 28H54a20.08 20.08 0 0 0-19.2 14.51L20.46 92.7A12 12 0 0 0 20 96v16a43.94 43.94 0 0 0 16 33.92V216a12 12 0 0 0 12 12h160a12 12 0 0 0 12-12v-70.08A43.94 43.94 0 0 0 236 112zM57.05 52H199l9.14 32H47.91Zm91 56v4a20 20 0 0 1-40 0v-4ZM53 128.71A20 20 0 0 1 44 112v-4h40v4a20 20 0 0 1-20 20a19.76 19.76 0 0 1-9.07-2.2a11.5 11.5 0 0 0-1.93-1.09M196 204H60v-48.19c1.32.12 2.65.19 4 .19a43.86 43.86 0 0 0 32-13.85a43.89 43.89 0 0 0 64 0A43.86 43.86 0 0 0 192 156c1.35 0 2.68-.07 4-.19Zm16-92a20 20 0 0 1-9 16.71a11.7 11.7 0 0 0-1.88 1.09A20 20 0 0 1 172 112v-4h40Z"/>`,
+ atm: `<path fill="white" d="M152 116h-12V60h4a28 28 0 0 1 28 28a12 12 0 0 0 24 0a52.06 52.06 0 0 0-52-52h-4V24a12 12 0 0 0-24 0v12h-4a52 52 0 0 0 0 104h4v56h-12a28 28 0 0 1-28-28a12 12 0 0 0-24 0a52.06 52.06 0 0 0 52 52h12v12a12 12 0 0 0 24 0v-12h12a52 52 0 0 0 0-104m-40 0a28 28 0 0 1 0-56h4v56Zm40 80h-12v-56h12a28 28 0 0 1 0 56"/>`,
+ health: `<path fill="white" d="M216 52h-36v-8a28 28 0 0 0-28-28h-48a28 28 0 0 0-28 28v8H40a20 20 0 0 0-20 20v128a20 20 0 0 0 20 20h176a20 20 0 0 0 20-20V72a20 20 0 0 0-20-20m-116-8a4 4 0 0 1 4-4h48a4 4 0 0 1 4 4v8h-56Zm112 152H44V76h168Zm-48-60a12 12 0 0 1-12 12h-12v12a12 12 0 0 1-24 0v-12h-12a12 12 0 0 1 0-24h12v-12a12 12 0 0 1 24 0v12h12a12 12 0 0 1 12 12"/>`,
+ office: `<path fill="white" d="M240 204h-12V96a20 20 0 0 0-20-20h-36V32a20 20 0 0 0-28.45-18.12l-104 48.54A20.06 20.06 0 0 0 28 80.55V204H16a12 12 0 0 0 0 24h224a12 12 0 0 0 0-24m-36-104v104h-32V100ZM52 83.09l96-44.79V204H52ZM132 112v12a12 12 0 0 1-24 0v-12a12 12 0 0 1 24 0m-40 0v12a12 12 0 0 1-24 0v-12a12 12 0 0 1 24 0m0 52v12a12 12 0 0 1-24 0v-12a12 12 0 0 1 24 0m40 0v12a12 12 0 0 1-24 0v-12a12 12 0 0 1 24 0"/>`,
+ default: `<path fill="white" d="M185.08 114.46A48 48 0 0 0 160 37.52V24a12 12 0 0 0-24 0v12h-16V24a12 12 0 0 0-24 0v12H72a12 12 0 0 0 0 24h4v128h-4a12 12 0 0 0 0 24h24v12a12 12 0 0 0 24 0v-12h16v12a12 12 0 0 0 24 0v-12a52 52 0 0 0 25.08-97.54M172 84a24 24 0 0 1-24 24h-48V60h48a24 24 0 0 1 24 24m-12 104h-60v-56h60a28 28 0 0 1 0 56"/>`,
+ };
+
+ 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="46" viewBox="0 0 28 36">
+ <path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 22 14 22s14-11.5 14-22C28 6.27 21.73 0 14 0z" fill="#F7931A"/>
+ <svg x="4" y="2.5" width="20" height="20" viewBox="0 0 256 256">${icon}</svg>
+ </svg>`;
+ 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;
+ 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: [-123.05, 49.26],
- zoom: 10.5,
- });
+ 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);
+ 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",
+ },
+ });
- // Fetch more on pan/zoom
- map.on("moveend", fetchOnMove);
+ map.addLayer({
+ id: "unclustered-point",
+ type: "circle",
+ source: "locations-source",
+ filter: ["!", ["has", "point_count"]],
+ paint: {
+ "circle-radius": 1,
+ "circle-opacity": 0,
+ },
+ });
- // 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.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("mousedown", stopFlying);
- map.on("touchstart", stopFlying);
- map.on("wheel", stopFlying);
- map.on("dragstart", stopFlying);
+ map.on("mouseenter", "clusters", () => {
+ map.getCanvas().style.cursor = "pointer";
+ });
+ map.on("mouseleave", "clusters", () => {
+ map.getCanvas().style.cursor = "";
+ });
- let resize = () => map && map.resize();
+ map.on("moveend", () => updateLabelVisibility());
- document.addEventListener("fullscreenchange", resize, false);
- document.addEventListener("webkitfullscreenchange", resize, false);
- document.addEventListener("mozfullscreenchange", resize, false);
- document.addEventListener("MSFullscreenChange", resize, false);
+ // Initial fetch for starting position
+ fetchNearby(start.center[1], start.center[0], 500);
+
+ // 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<string, any[]>();
+ 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;
+ });
</script>
<div class="w-screen h-dvh flex" id="map" bind:this={mapWrapper}>
@@ -345,38 +646,99 @@
</button>
</div>
<div class="relative">
- {#if !showList}
+ {#if showList}
<div
class="space-y-1 text-sm w-[300px] max-w-[calc(100vw*0.5)] h-[300px] max-h-[calc(100vh*0.4)] overflow-y-scroll p-4 bg-base-100/90 absolute right-4 bottom-8 rounded-2xl shadow text-ellipsis overflow-x-hidden"
>
- <div class="relative mb-4 sticky top-0 bg-base-100 z-10">
- <input bind:value={search} class="w-full" />
- <div class="flex gap-1 absolute right-6 top-3">
+ <div class="relative mb-4 sticky top-0 bg-base-100/90 z-10">
+ <input bind:value={search} placeholder="Search locations..." class="w-full border border-base-300 rounded-lg pl-3 pr-8 py-2" />
+ <div class="flex items-center absolute right-2 top-1/2 -translate-y-1/2">
{#if search}
<button type="button" onclick={clearSearch} aria-label="Clear">
- <iconify-icon noobserver icon="ph:x-bold" width="32"></iconify-icon>
+ <iconify-icon noobserver icon="ph:x-bold" width="20"></iconify-icon>
</button>
{:else}
- <iconify-icon noobserver icon="ph:search-bold" width="32"></iconify-icon>
+ <iconify-icon noobserver icon="ph:magnifying-glass-bold" width="20" class="opacity-40"></iconify-icon>
{/if}
</div>
</div>
- {#each list as marker, i}
+ {#each groupedList as group}
+ {@const active = activeFilters.has(group.slug)}
<button
- onclick={() => select(marker)}
- class:font-bold={selected === marker}
- class:selected={selected === marker}
- class="block whitespace-nowrap text-ellipsis text-left w-full overflow-hidden"
+ onclick={() => toggleCategory(group.slug)}
+ data-category={group.slug}
+ class="flex items-center gap-1.5 w-full pt-3 pb-1 font-bold text-xs uppercase tracking-wide hover:opacity-100 transition-opacity"
+ class:opacity-30={!active}
+ class:opacity-70={active}
>
- {marker.tags.name}
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="18" viewBox="0 0 28 36">
+ <path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 22 14 22s14-11.5 14-22C28 6.27 21.73 0 14 0z" fill={active ? "#F7931A" : "#ccc"}/>
+ <svg x="4" y="2.5" width="20" height="20" viewBox="0 0 256 256">{@html categoryIcons[group.slug]}</svg>
+ </svg>
+ {group.name}
+ <span class="font-normal">({group.markers.length})</span>
</button>
+ {#each group.markers as marker}
+ <button
+ onclick={() => select(marker)}
+ class:font-bold={selected === marker}
+ class:selected={selected === marker}
+ class="block whitespace-nowrap text-ellipsis text-left w-full overflow-hidden pl-5 transition-opacity"
+ class:opacity-30={!active}
+ disabled={!active}
+ >
+ {marker.tags.name}
+ </button>
+ {/each}
{/each}
</div>
{/if}
+ {#if showFilters}
+ <div
+ class="w-[300px] max-w-[calc(100vw*0.5)] max-h-[calc(100vh*0.4)] overflow-y-auto p-4 bg-base-100/90 absolute right-4 bottom-8 rounded-2xl shadow"
+ >
+ <div class="flex gap-2 mb-3">
+ <button
+ onclick={setAllFilters}
+ class="text-xs px-3 py-1 rounded-full border border-primary bg-primary text-white"
+ >All</button>
+ <button
+ onclick={clearAllFilters}
+ class="text-xs px-3 py-1 rounded-full border border-primary"
+ >None</button>
+ </div>
+ <div class="flex flex-wrap gap-2">
+ {#each presentCategories as cat}
+ <button
+ onclick={() => toggleCategory(cat.slug)}
+ class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs border transition-colors"
+ class:bg-primary={activeFilters.has(cat.slug)}
+ class:text-white={activeFilters.has(cat.slug)}
+ class:border-primary={activeFilters.has(cat.slug)}
+ class:border-base-300={!activeFilters.has(cat.slug)}
+ class:opacity-50={!activeFilters.has(cat.slug)}
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 28 36">
+ <path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 22 14 22s14-11.5 14-22C28 6.27 21.73 0 14 0z" fill="#F7931A"/>
+ <svg x="4" y="2.5" width="20" height="20" viewBox="0 0 256 256">{@html categoryIcons[cat.slug]}</svg>
+ </svg>
+ {cat.name}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
<div class="absolute flex top-2 right-2 gap-2">
<button
class="rounded-full border-2 border-primary bg-base-100/80 w-16 h-16 flex items-center justify-center"
+ onclick={toggleFilters}
+ aria-label="Filter categories"
+ >
+ <iconify-icon noobserver icon="ph:funnel-bold" width="24"></iconify-icon>
+ </button>
+ <button
+ class="rounded-full border-2 border-primary bg-base-100/80 w-16 h-16 flex items-center justify-center"
onclick={toggleList}
aria-label="Toggle location list"
>