Add map clustering with priority pins
This commit is contained in:
parent
eca8c6c270
commit
40bc03d90d
|
|
@ -75,6 +75,7 @@ export default function MapView({
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||||
|
onSelectPost,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
.setLngLat(lngLat)
|
.setLngLat(lngLat)
|
||||||
.addTo(map);
|
.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) => {
|
root.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,30 @@ function hasPostsInRadius(posts, centerLat, centerLon, radiusKm) {
|
||||||
return false;
|
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({
|
export function usePostsEngine({
|
||||||
mapRef,
|
mapRef,
|
||||||
viewParams,
|
viewParams,
|
||||||
|
|
@ -91,6 +115,7 @@ export function usePostsEngine({
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter,
|
onAutoWidenTimeFilter,
|
||||||
|
onSelectPost,
|
||||||
theme = "blue",
|
theme = "blue",
|
||||||
}) {
|
}) {
|
||||||
const allPostsRef = useRef([]);
|
const allPostsRef = useRef([]);
|
||||||
|
|
@ -98,6 +123,14 @@ export function usePostsEngine({
|
||||||
const delayedFetchRef = useRef(null);
|
const delayedFetchRef = useRef(null);
|
||||||
const autoWidenRef = useRef(false);
|
const autoWidenRef = useRef(false);
|
||||||
const [mapReady, setMapReady] = useState(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(() => {
|
useEffect(() => {
|
||||||
if (mapReady) return;
|
if (mapReady) return;
|
||||||
|
|
@ -116,6 +149,238 @@ export function usePostsEngine({
|
||||||
};
|
};
|
||||||
}, [mapReady, mapRef]);
|
}, [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 [status, setStatus] = useState("");
|
||||||
const [visiblePosts, setVisiblePosts] = useState([]);
|
const [visiblePosts, setVisiblePosts] = useState([]);
|
||||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||||
|
|
@ -220,7 +485,9 @@ export function usePostsEngine({
|
||||||
|
|
||||||
const refreshVisibleAndMarkers = useCallback(
|
const refreshVisibleAndMarkers = useCallback(
|
||||||
(tf) => {
|
(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
|
// reduce wall blink: don't update if same ids
|
||||||
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
|
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
|
||||||
|
|
@ -228,9 +495,27 @@ export function usePostsEngine({
|
||||||
// keep status silent (no annoying bubbles)
|
// keep status silent (no annoying bubbles)
|
||||||
setStatus("");
|
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);
|
syncMarkers(v);
|
||||||
},
|
},
|
||||||
[computeVisible, syncMarkers]
|
[computeVisible, syncMarkers, mapRef, ensureClusterLayers, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers]
|
||||||
);
|
);
|
||||||
|
|
||||||
// On filter/search changes: recompute + sync (NO clear-all rebuild)
|
// On filter/search changes: recompute + sync (NO clear-all rebuild)
|
||||||
|
|
@ -364,11 +649,9 @@ export function usePostsEngine({
|
||||||
allPostsRef.current = Array.from(byId.values());
|
allPostsRef.current = Array.from(byId.values());
|
||||||
lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter };
|
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);
|
const visible = computeVisible(timeFilter);
|
||||||
setVisiblePosts((prev) => (sameIdList(prev, visible) ? prev : visible));
|
|
||||||
setStatus("");
|
|
||||||
syncMarkers(visible);
|
|
||||||
if (
|
if (
|
||||||
!autoWidenRef.current &&
|
!autoWidenRef.current &&
|
||||||
timeFilter &&
|
timeFilter &&
|
||||||
|
|
@ -399,6 +682,21 @@ export function usePostsEngine({
|
||||||
};
|
};
|
||||||
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]);
|
}, [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(
|
const handleIncomingPost = useCallback(
|
||||||
(p) => {
|
(p) => {
|
||||||
const key = getPostKey(p);
|
const key = getPostKey(p);
|
||||||
|
|
@ -417,6 +715,11 @@ export function usePostsEngine({
|
||||||
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
|
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
|
||||||
if (!matchesSearch(p, searchQuery)) return;
|
if (!matchesSearch(p, searchQuery)) return;
|
||||||
|
|
||||||
|
if (clusterModeRef.current) {
|
||||||
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// add marker if missing
|
// add marker if missing
|
||||||
const id = getId(p);
|
const id = getId(p);
|
||||||
const have = new Set((markersRef.current || []).map((x) => x.id));
|
const have = new Set((markersRef.current || []).map((x) => x.id));
|
||||||
|
|
@ -426,7 +729,7 @@ export function usePostsEngine({
|
||||||
|
|
||||||
setVisiblePosts((current) => [...current, p]);
|
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 };
|
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue