diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index fd3806c..e8438c5 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -75,6 +75,7 @@ export default function MapView({ markersRef, expandedElRef, onAutoWidenTimeFilter: (next) => setTimeFilter(next), + onSelectPost, }); const [isCreating, setIsCreating] = useState(false); diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index c03e94e..bf1f0a3 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -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(); diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 8ec2fe2..4e9d005 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -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 };