Add map clustering with priority pins
This commit is contained in:
parent
eca8c6c270
commit
40bc03d90d
|
|
@ -75,6 +75,7 @@ export default function MapView({
|
|||
markersRef,
|
||||
expandedElRef,
|
||||
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||
onSelectPost,
|
||||
});
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue