sw-fe/src/components/Map/EventClustersLayer.jsx

451 lines
13 KiB
JavaScript

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;
}