import { useEffect, useRef, useCallback } from "react"; import { fetchEventClusters } from "../../api/client"; const SOURCE_ID = "sw-event-clusters"; const FILL_LAYER_ID = "sw-event-cluster-fill"; const LINE_LAYER_ID = "sw-event-cluster-line"; const LABEL_LAYER_ID = "sw-event-cluster-label"; const CENTER_SOURCE_ID = "sw-event-cluster-centers"; // Cross product of vectors OA and OB (O is origin) function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); } // Convex hull using Andrew's monotone chain algorithm function convexHull(points) { if (points.length < 3) return points; // Sort by x, then by y const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); // Build lower hull const lower = []; for (const p of sorted) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) { lower.pop(); } lower.push(p); } // Build upper hull const upper = []; for (let i = sorted.length - 1; i >= 0; i--) { const p = sorted[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) { upper.pop(); } upper.push(p); } // Remove last point of each half (it's repeated) lower.pop(); upper.pop(); return lower.concat(upper); } // Add buffer/padding around hull points and smooth corners function bufferHull(hull, padding) { if (hull.length < 3) return hull; const result = []; const n = hull.length; for (let i = 0; i < n; i++) { const curr = hull[i]; const prev = hull[(i - 1 + n) % n]; const next = hull[(i + 1) % n]; // Direction vectors const dx1 = curr[0] - prev[0]; const dy1 = curr[1] - prev[1]; const dx2 = next[0] - curr[0]; const dy2 = next[1] - curr[1]; // Normalize and get perpendicular (outward) const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1) || 1; const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1; // Outward normal (average of the two edge normals) const nx = (-dy1 / len1 - dy2 / len2) / 2; const ny = (dx1 / len1 + dx2 / len2) / 2; const nlen = Math.sqrt(nx * nx + ny * ny) || 1; // Push vertex outward const buffered = [ curr[0] + (nx / nlen) * padding, curr[1] + (ny / nlen) * padding ]; result.push(buffered); } return result; } // Smooth polygon by adding midpoints (Chaikin's algorithm - 1 iteration) function smoothPolygon(points) { if (points.length < 3) return points; const result = []; const n = points.length; for (let i = 0; i < n; i++) { const curr = points[i]; const next = points[(i + 1) % n]; // Add point at 25% and 75% along each edge result.push([ curr[0] * 0.75 + next[0] * 0.25, curr[1] * 0.75 + next[1] * 0.25 ]); result.push([ curr[0] * 0.25 + next[0] * 0.75, curr[1] * 0.25 + next[1] * 0.75 ]); } return result; } // Create organic "lake" shape from cluster member posts function clusterToPolygon(cluster) { const { id, title, post_count, member_posts, bounding_box } = cluster; // Get valid points from member posts let points = []; if (member_posts && member_posts.length > 0) { points = member_posts .filter(p => p.lat && p.lon && p.lat !== 0 && p.lon !== 0) .map(p => [p.lon, p.lat]); } // Fallback to bounding box center if no points if (points.length === 0 && bounding_box) { const { min_lat, max_lat, min_lon, max_lon } = bounding_box; const centerLon = (min_lon + max_lon) / 2; const centerLat = (min_lat + max_lat) / 2; const radius = Math.max((max_lon - min_lon), (max_lat - min_lat)) / 2 || 0.02; // Create a circle for (let i = 0; i < 12; i++) { const angle = (i / 12) * Math.PI * 2; points.push([ centerLon + Math.cos(angle) * radius, centerLat + Math.sin(angle) * radius * 0.7 // Squish for lat/lon ratio ]); } } if (points.length < 3) { // Not enough points - create a small circle around cluster center const centerLon = cluster.lon || (bounding_box ? (bounding_box.min_lon + bounding_box.max_lon) / 2 : 0); const centerLat = cluster.lat || (bounding_box ? (bounding_box.min_lat + bounding_box.max_lat) / 2 : 0); const radius = 0.02; points = []; for (let i = 0; i < 12; i++) { const angle = (i / 12) * Math.PI * 2; points.push([ centerLon + Math.cos(angle) * radius, centerLat + Math.sin(angle) * radius * 0.7 ]); } } // Compute convex hull let hull = convexHull(points); // Calculate padding based on spread of points const lons = points.map(p => p[0]); const lats = points.map(p => p[1]); const lonSpread = Math.max(...lons) - Math.min(...lons); const latSpread = Math.max(...lats) - Math.min(...lats); const padding = Math.max(lonSpread, latSpread) * 0.15 + 0.005; // 15% + minimum // Buffer the hull outward hull = bufferHull(hull, padding); // Smooth the polygon twice for organic look hull = smoothPolygon(hull); hull = smoothPolygon(hull); // Close the ring if (hull.length > 0) { hull.push(hull[0]); } return { type: "Feature", id, properties: { id, title, post_count, }, geometry: { type: "Polygon", coordinates: [hull], }, }; } // Create center points for labels function clusterToPoint(cluster) { return { type: "Feature", properties: { id: cluster.id, title: cluster.title?.slice(0, 35) + (cluster.title?.length > 35 ? "..." : "") || "Cluster", post_count: cluster.post_count, }, geometry: { type: "Point", coordinates: [cluster.lon, cluster.lat], }, }; } /** * EventClustersLayer - Renders news cluster impact zones on the map */ export default function EventClustersLayer({ map, enabled = true, onClusterClick, onClusterPostIds }) { const clustersRef = useRef([]); const initializedRef = useRef(false); const fetchTimeoutRef = useRef(null); const lastBoundsRef = useRef(null); // Update GeoJSON data in existing sources const updateData = useCallback(() => { if (!map) return; try { const polygons = clustersRef.current .filter(c => c.bounding_box || (c.member_posts && c.member_posts.length > 0)) .map(c => clusterToPolygon(c)); const points = clustersRef.current.map(clusterToPoint); const polygonSource = map.getSource(SOURCE_ID); if (polygonSource) { polygonSource.setData({ type: "FeatureCollection", features: polygons }); } const pointSource = map.getSource(CENTER_SOURCE_ID); if (pointSource) { pointSource.setData({ type: "FeatureCollection", features: points }); } } catch (err) { console.warn("EventClustersLayer updateData error:", err); } }, [map]); // Fetch clusters from API const doFetch = useCallback(async () => { if (!map) return; try { const bounds = map.getBounds(); // Use less precision to avoid too frequent fetches, but still catch significant moves const boundsKey = `${bounds.getWest().toFixed(0)},${bounds.getSouth().toFixed(0)},${bounds.getEast().toFixed(0)},${bounds.getNorth().toFixed(0)}`; if (lastBoundsRef.current === boundsKey) return; lastBoundsRef.current = boundsKey; const data = await fetchEventClusters({ minLat: bounds.getSouth(), maxLat: bounds.getNorth(), minLon: bounds.getWest(), maxLon: bounds.getEast(), minPosts: 2, limit: 40, }); clustersRef.current = data.clusters || []; updateData(); // Report post IDs to parent if (onClusterPostIds) { const postIds = new Set(); for (const cluster of clustersRef.current) { if (cluster.member_posts) { for (const post of cluster.member_posts) { postIds.add(post.id); } } } onClusterPostIds(postIds); } } catch (err) { console.warn("EventClustersLayer fetch error:", err); } }, [map, updateData, onClusterPostIds]); // Debounced fetch const scheduleFetch = useCallback(() => { if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current); fetchTimeoutRef.current = setTimeout(doFetch, 400); }, [doFetch]); // Initialize sources and layers once useEffect(() => { if (!map) return; const init = () => { if (initializedRef.current) return; // Check if style is loaded try { if (!map.isStyleLoaded()) { // Retry in 100ms setTimeout(init, 100); return; } } catch { setTimeout(init, 100); return; } if (map.getSource(SOURCE_ID)) { initializedRef.current = true; doFetch(); map.on("moveend", scheduleFetch); return; } try { // Sources map.addSource(SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addSource(CENTER_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); // Fill layer - semi-transparent lake-like appearance (start hidden) map.addLayer({ id: FILL_LAYER_ID, type: "fill", source: SOURCE_ID, layout: { "visibility": "none", }, paint: { "fill-color": [ "interpolate", ["linear"], ["get", "post_count"], 2, "#60a5fa", // lighter blue 5, "#a78bfa", // softer purple 10, "#f472b6", // softer pink ], "fill-opacity": 0.35, }, }); // Border line - subtle glow effect (start hidden) map.addLayer({ id: LINE_LAYER_ID, type: "line", source: SOURCE_ID, layout: { "visibility": "none", }, paint: { "line-color": [ "interpolate", ["linear"], ["get", "post_count"], 2, "#93c5fd", // even lighter for border 5, "#c4b5fd", 10, "#f9a8d4", ], "line-width": 2, "line-blur": 1, }, }); // Labels (start hidden) map.addLayer({ id: LABEL_LAYER_ID, type: "symbol", source: CENTER_SOURCE_ID, layout: { "visibility": "none", "text-field": ["concat", ["get", "title"], " (", ["to-string", ["get", "post_count"]], ")"], "text-size": 14, "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], "text-anchor": "center", "text-max-width": 15, "text-allow-overlap": false, "text-ignore-placement": false, }, paint: { "text-color": "#ffffff", "text-halo-color": "#1e1b4b", "text-halo-width": 2, "text-halo-blur": 1, }, minzoom: 3, }); // Click handler map.on("click", FILL_LAYER_ID, (e) => { if (e.features?.length && onClusterClick) { const id = e.features[0].properties?.id; const cluster = clustersRef.current.find(c => c.id === id); if (cluster) onClusterClick(cluster); } }); map.on("mouseenter", FILL_LAYER_ID, () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", FILL_LAYER_ID, () => { map.getCanvas().style.cursor = ""; }); initializedRef.current = true; // Initial fetch only if enabled if (enabled) { doFetch(); // Show layers map.setLayoutProperty(FILL_LAYER_ID, "visibility", "visible"); map.setLayoutProperty(LINE_LAYER_ID, "visibility", "visible"); map.setLayoutProperty(LABEL_LAYER_ID, "visibility", "visible"); } // Fetch on map move map.on("moveend", scheduleFetch); } catch (err) { console.warn("EventClustersLayer init error:", err); } }; // Start init - it will retry if style not loaded yet init(); return () => { if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current); }; }, [map, doFetch, scheduleFetch, onClusterClick, enabled]); // Toggle visibility useEffect(() => { if (!map || !initializedRef.current) return; try { const vis = enabled ? "visible" : "none"; if (map.getLayer(FILL_LAYER_ID)) map.setLayoutProperty(FILL_LAYER_ID, "visibility", vis); if (map.getLayer(LINE_LAYER_ID)) map.setLayoutProperty(LINE_LAYER_ID, "visibility", vis); if (map.getLayer(LABEL_LAYER_ID)) map.setLayoutProperty(LABEL_LAYER_ID, "visibility", vis); // Refetch when enabled if (enabled) { lastBoundsRef.current = null; doFetch(); } } catch {} }, [map, enabled, doFetch]); return null; }