Add map clustering with priority pins

This commit is contained in:
Your Name 2025-12-24 14:59:14 -05:00
parent eca8c6c270
commit 40bc03d90d
3 changed files with 312 additions and 8 deletions

View File

@ -75,6 +75,7 @@ export default function MapView({
markersRef,
expandedElRef,
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
onSelectPost,
});
const [isCreating, setIsCreating] = useState(false);

View File

@ -433,7 +433,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
markersRef.current.push({ id: post.id, marker, el: root, post, type: "full" });
root.addEventListener("click", (ev) => {
ev.stopPropagation();

View File

@ -81,6 +81,30 @@ function hasPostsInRadius(posts, centerLat, centerLon, radiusKm) {
return false;
}
function filterByBounds(posts, map) {
if (!map || !Array.isArray(posts) || posts.length === 0) return posts || [];
let bounds;
try { bounds = map.getBounds(); } catch {}
if (!bounds) return posts;
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
if (!sw || !ne) return posts;
const padLat = Math.abs(ne.lat - sw.lat) * 0.12;
const padLon = Math.abs(ne.lng - sw.lng) * 0.12;
const minLat = sw.lat - padLat;
const maxLat = ne.lat + padLat;
const minLon = sw.lng - padLon;
const maxLon = ne.lng + padLon;
return posts.filter((p) => {
const coords = getCoords(p);
if (!coords) return false;
const [lon, lat] = coords;
return lat >= minLat && lat <= maxLat && lon >= minLon && lon <= maxLon;
});
}
export function usePostsEngine({
mapRef,
viewParams,
@ -91,6 +115,7 @@ export function usePostsEngine({
markersRef,
expandedElRef,
onAutoWidenTimeFilter,
onSelectPost,
theme = "blue",
}) {
const allPostsRef = useRef([]);
@ -98,6 +123,14 @@ export function usePostsEngine({
const delayedFetchRef = useRef(null);
const autoWidenRef = useRef(false);
const [mapReady, setMapReady] = useState(false);
const clusterModeRef = useRef(false);
const CLUSTER_THRESHOLD = 140;
const SIMPLE_MARKER_LIMIT = 40;
const CLUSTER_SOURCE_ID = "sw-posts";
const CLUSTER_LAYER_ID = "sw-clusters";
const CLUSTER_COUNT_ID = "sw-cluster-count";
const UNCLUSTERED_ID = "sw-unclustered";
useEffect(() => {
if (mapReady) return;
@ -116,6 +149,238 @@ export function usePostsEngine({
};
}, [mapReady, mapRef]);
const priorityOpts = {
maxFullCards: 12,
clusterRadius: 0.002,
minScoreForCard: 50,
};
const ensureClusterLayers = useCallback((map) => {
if (!map) return;
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
if (map.getSource(CLUSTER_SOURCE_ID)) return;
map.addSource(CLUSTER_SOURCE_ID, {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
cluster: true,
clusterMaxZoom: 12,
clusterRadius: 46,
});
map.addLayer({
id: CLUSTER_LAYER_ID,
type: "circle",
source: CLUSTER_SOURCE_ID,
filter: ["has", "point_count"],
paint: {
"circle-color": [
"step",
["get", "point_count"],
"#22d3ee",
30,
"#3b82f6",
100,
"#1d4ed8",
],
"circle-radius": [
"step",
["get", "point_count"],
16,
30,
22,
100,
28,
],
"circle-opacity": 0.85,
"circle-stroke-color": "#e2e8f0",
"circle-stroke-width": 1.2,
},
});
map.addLayer({
id: CLUSTER_COUNT_ID,
type: "symbol",
source: CLUSTER_SOURCE_ID,
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-size": 12,
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
},
paint: {
"text-color": "#0b1220",
},
});
map.addLayer({
id: UNCLUSTERED_ID,
type: "circle",
source: CLUSTER_SOURCE_ID,
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": "#38bdf8",
"circle-radius": 6,
"circle-opacity": 0.9,
"circle-stroke-color": "#0b1220",
"circle-stroke-width": 1,
},
});
if (!map.__swClusterHandlers) {
map.__swClusterHandlers = true;
map.on("click", CLUSTER_LAYER_ID, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] });
const clusterFeature = features && features[0];
if (!clusterFeature) return;
const clusterId = clusterFeature.properties?.cluster_id;
const source = map.getSource(CLUSTER_SOURCE_ID);
if (!source || clusterId == null || !source.getClusterExpansionZoom) return;
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({ center: clusterFeature.geometry.coordinates, zoom, duration: 520 });
});
});
map.on("click", UNCLUSTERED_ID, (e) => {
const f = e.features && e.features[0];
if (!f || !f.properties) return;
const raw = f.properties.post_json;
if (!raw) return;
let post = null;
try { post = JSON.parse(raw); } catch {}
if (post && typeof onSelectPost === "function") onSelectPost(post);
});
map.on("mouseenter", CLUSTER_LAYER_ID, () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", CLUSTER_LAYER_ID, () => {
map.getCanvas().style.cursor = "";
});
map.on("mouseenter", UNCLUSTERED_ID, () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", UNCLUSTERED_ID, () => {
map.getCanvas().style.cursor = "";
});
}
}, [onSelectPost]);
const setClusterVisibility = useCallback((map, visible) => {
if (!map) return;
const v = visible ? "visible" : "none";
if (map.getLayer(CLUSTER_LAYER_ID)) map.setLayoutProperty(CLUSTER_LAYER_ID, "visibility", v);
if (map.getLayer(CLUSTER_COUNT_ID)) map.setLayoutProperty(CLUSTER_COUNT_ID, "visibility", v);
if (map.getLayer(UNCLUSTERED_ID)) map.setLayoutProperty(UNCLUSTERED_ID, "visibility", v);
}, []);
const updateClusterData = useCallback((map, posts) => {
if (!map) return;
const src = map.getSource(CLUSTER_SOURCE_ID);
if (!src || !src.setData) return;
const features = [];
for (const p of posts || []) {
const coords = getCoords(p);
if (!coords) continue;
const [lon, lat] = coords;
const payload = {
id: getId(p),
title: p.title || p.Title || "",
snippet: p.snippet || "",
category: p.category || p.Category || "",
sub_category: p.sub_category || p.subCategory || "",
lat,
lon,
url: p.url || "",
source: p.source || "",
created_at: p.created_at || p.CreatedAt || p.createdAt || "",
published_at: p.published_at || "",
url_hash: p.url_hash || p.urlHash || "",
};
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: [lon, lat] },
properties: {
id: payload.id || 0,
post_json: JSON.stringify(payload),
},
});
}
src.setData({ type: "FeatureCollection", features });
}, []);
const splitForClusters = useCallback(
(visible) => {
const map = mapRef.current;
if (!map) return { fullCardPosts: [], clusterPosts: visible || [] };
const center = map.getCenter();
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(
visible || [],
viewCenter,
priorityOpts
);
return { fullCardPosts, clusterPosts: simpleMarkerPosts };
},
[mapRef, priorityOpts]
);
const syncPriorityMarkers = useCallback(
(fullList, simpleList) => {
const map = mapRef.current;
if (!map) return;
if (expandedElRef?.current) return;
const wanted = new Map();
for (const post of fullList || []) {
const id = getId(post);
if (id != null) wanted.set(id, { post, type: "full" });
}
for (const post of simpleList || []) {
const id = getId(post);
if (id != null && !wanted.has(id)) wanted.set(id, { post, type: "simple" });
}
const cur = markersRef.current || [];
const next = [];
for (const item of cur) {
const id = item?.id;
const want = id != null ? wanted.get(id) : null;
const haveType = item?.type || "full";
if (!want || haveType !== want.type) {
try {
const el = item?.el;
if (el) {
el.classList.add("sw-disappear");
el.style.pointerEvents = "none";
}
} catch {}
setTimeout(() => {
try { if (item?.el && item.el.__swUnmount) item.el.__swUnmount(); } catch {}
try { item?.marker?.remove?.(); } catch {}
}, 840);
} else {
next.push(item);
}
}
markersRef.current = next;
const have = new Set(next.map((x) => x.id));
for (const [id, entry] of wanted.entries()) {
if (have.has(id)) continue;
if (entry.type === "simple") {
createSimpleMarkerForPost(entry.post, mapRef, markersRef, expandedElRef, theme);
} else {
createMarkerForPost(entry.post, mapRef, markersRef, expandedElRef, theme);
}
}
},
[mapRef, markersRef, expandedElRef, theme]
);
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
@ -220,7 +485,9 @@ export function usePostsEngine({
const refreshVisibleAndMarkers = useCallback(
(tf) => {
const v = computeVisible(tf);
const vAll = computeVisible(tf);
const map = mapRef.current;
const v = filterByBounds(vAll, map);
// reduce wall blink: don't update if same ids
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
@ -228,9 +495,27 @@ export function usePostsEngine({
// keep status silent (no annoying bubbles)
setStatus("");
if (map && v.length >= CLUSTER_THRESHOLD) {
const { fullCardPosts, clusterPosts } = splitForClusters(v);
const simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT);
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
clusterModeRef.current = true;
ensureClusterLayers(map);
syncPriorityMarkers(fullCardPosts, simpleVisible);
updateClusterData(map, clusterRemainder);
setClusterVisibility(map, true);
return;
}
if (map && clusterModeRef.current) {
setClusterVisibility(map, false);
updateClusterData(map, []);
clusterModeRef.current = false;
}
syncMarkers(v);
},
[computeVisible, syncMarkers]
[computeVisible, syncMarkers, mapRef, ensureClusterLayers, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers]
);
// On filter/search changes: recompute + sync (NO clear-all rebuild)
@ -364,11 +649,9 @@ export function usePostsEngine({
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter };
// Smooth update: sync markers + wall without clearing everything
// Smooth update: sync markers/clusters + wall without clearing everything
refreshVisibleAndMarkers(timeFilter);
const visible = computeVisible(timeFilter);
setVisiblePosts((prev) => (sameIdList(prev, visible) ? prev : visible));
setStatus("");
syncMarkers(visible);
if (
!autoWidenRef.current &&
timeFilter &&
@ -399,6 +682,21 @@ export function usePostsEngine({
};
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handleStyle = () => {
if (!clusterModeRef.current) return;
ensureClusterLayers(map);
setClusterVisibility(map, true);
const { clusterPosts } = splitForClusters(computeVisible(timeFilter));
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
updateClusterData(map, clusterRemainder);
};
map.on("styledata", handleStyle);
return () => map.off("styledata", handleStyle);
}, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, splitForClusters]);
const handleIncomingPost = useCallback(
(p) => {
const key = getPostKey(p);
@ -417,6 +715,11 @@ export function usePostsEngine({
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
if (!matchesSearch(p, searchQuery)) return;
if (clusterModeRef.current) {
refreshVisibleAndMarkers(timeFilter);
return;
}
// add marker if missing
const id = getId(p);
const have = new Set((markersRef.current || []).map((x) => x.id));
@ -426,7 +729,7 @@ export function usePostsEngine({
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme]
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };