diff --git a/src/App.jsx b/src/App.jsx index fc2a546..12484d9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -39,11 +39,11 @@ export default function App() { const [selectedPost, setSelectedPost] = useState(null); // Content filter (shared between map and sociowall) - persisted - // "all" | "links" | "video" | "image" | "text" | "live" | "event" + // "all" | "links" | "video" | "image" | "text" | "live" | "cluster" const [contentFilter, setContentFilter] = useState(() => { try { const saved = localStorage.getItem(CONTENT_FILTER_KEY); - if (saved && ["all", "links", "video", "image", "text", "live", "event"].includes(saved)) { + if (saved && ["all", "links", "video", "image", "text", "live", "cluster"].includes(saved)) { return saved; } } catch {} diff --git a/src/api/client.js b/src/api/client.js index 7b8794a..343d7fb 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1077,6 +1077,34 @@ export function getBrowserTimezone() { } } +/** + * Fetch event clusters for map visualization + * Returns clusters with bounding boxes for impact zones + */ +export async function fetchEventClusters(params = {}) { + const qs = new URLSearchParams(); + + if (typeof params.minLat === "number") qs.set("min_lat", String(params.minLat)); + if (typeof params.maxLat === "number") qs.set("max_lat", String(params.maxLat)); + if (typeof params.minLon === "number") qs.set("min_lon", String(params.minLon)); + if (typeof params.maxLon === "number") qs.set("max_lon", String(params.maxLon)); + if (typeof params.minPosts === "number") qs.set("min_posts", String(params.minPosts)); + if (typeof params.limit === "number") qs.set("limit", String(params.limit)); + + const url = `${apiBase()}/events?${qs.toString()}`; + + try { + const data = await fetchJsonCached(url, { ttlMs: 30000 }); + return { + clusters: Array.isArray(data?.clusters) ? data.clusters : [], + count: data?.count || 0, + }; + } catch (err) { + console.warn("fetchEventClusters error:", err); + return { clusters: [], count: 0 }; + } +} + // Weather API export async function fetchWeatherByCity(city) { const url = `${apiBase()}/weather?city=${encodeURIComponent(city)}`; diff --git a/src/components/Filters/ContentFilters.css b/src/components/Filters/ContentFilters.css index 37a0563..cc6539a 100644 --- a/src/components/Filters/ContentFilters.css +++ b/src/components/Filters/ContentFilters.css @@ -69,8 +69,8 @@ color: #ef4444; } -.content-filter-btn.filter-event i { - color: #f59e0b; +.content-filter-btn.filter-cluster i { + color: #a855f7; } /* Keep white icon when active */ @@ -99,9 +99,9 @@ box-shadow: 0 2px 8px rgba(6, 182, 212, 0.4); } -.content-filter-btn.filter-event.active { - background: linear-gradient(135deg, #f59e0b, #d97706); - box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4); +.content-filter-btn.filter-cluster.active { + background: linear-gradient(135deg, #a855f7, #9333ea); + box-shadow: 0 2px 8px rgba(168, 85, 247, 0.4); } /* Blue theme (default dark) */ diff --git a/src/components/Filters/ContentFilters.jsx b/src/components/Filters/ContentFilters.jsx index 8a6364a..0484aa8 100644 --- a/src/components/Filters/ContentFilters.jsx +++ b/src/components/Filters/ContentFilters.jsx @@ -7,7 +7,7 @@ const FILTERS = [ { key: "image", icon: "fa-solid fa-camera", label: "Photos" }, { key: "text", icon: "fa-solid fa-pen-to-square", label: "Blogs" }, { key: "live", icon: "fa-solid fa-tower-broadcast", label: "Live" }, - { key: "event", icon: "fa-regular fa-calendar-days", label: "Events" }, + { key: "cluster", icon: "fa-solid fa-draw-polygon", label: "Clusters" }, ]; export default function ContentFilters({ activeFilter, onFilterChange }) { @@ -35,7 +35,6 @@ const KNOWN_CONTENT_TYPES = new Set([ "camera", "live", "stream", "post", "text", "blog", "news", - "event", "weather", "traffic" ]); @@ -66,8 +65,9 @@ export function matchesContentFilter(post, filter) { case "live": // Only live content: cameras (quebec511), live streams - NOT recorded videos return ct === "live" || ct === "stream" || ct === "camera"; - case "event": - return cat === "event" || cat === "events" || ct === "event"; + case "cluster": + // Clusters filter shows all posts - the cluster zones overlay them + return true; default: return true; } diff --git a/src/components/Map/EventClustersLayer.jsx b/src/components/Map/EventClustersLayer.jsx new file mode 100644 index 0000000..937cb2c --- /dev/null +++ b/src/components/Map/EventClustersLayer.jsx @@ -0,0 +1,450 @@ +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; +} diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index a6bcd6f..6966bad 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -25,6 +25,7 @@ import UserProfile from "../Profile/UserProfile"; import { fetchPostById, fetchPostByUrl } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; import DayNightTerminator from "./DayNightTerminator"; +import EventClustersLayer from "./EventClustersLayer"; const CONTENT_CREATOR_MODES = [ { @@ -287,6 +288,7 @@ export default function MapView({ const [searchQuery, setSearchQuery] = useState(""); const skipAutoZoomRef = useRef(false); const [clusterFocus, setClusterFocus] = useState(null); + const [clusterPostIds, setClusterPostIds] = useState(new Set()); const [forceFullPostId, setForceFullPostId] = useState(null); // Profile state (selectedIsland is now passed from App.jsx) @@ -544,6 +546,7 @@ export default function MapView({ subFilter, subFilters, contentFilter, + clusterPostIds, timeHours, searchQuery, clusterFocus, @@ -2059,6 +2062,25 @@ export default function MapView({
+ {viewParams.center && ( + { + // Pan to cluster and potentially show details + const map = mapRef.current; + if (map && cluster.bounding_box) { + const { min_lat, max_lat, min_lon, max_lon } = cluster.bounding_box; + map.fitBounds([[min_lon, min_lat], [max_lon, max_lat]], { + padding: 50, + maxZoom: 12, + duration: 800, + }); + } + }} + /> + )} {status &&
{status}
} {debugEnabled && ( <> diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 94997ec..9923986 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -377,6 +377,7 @@ export function usePostsEngine({ subFilter, subFilters = [], contentFilter = "all", + clusterPostIds = new Set(), timeHours, searchQuery, clusterFocus, @@ -878,8 +879,16 @@ export function usePostsEngine({ const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0); return posts.filter((p) => { - // Apply content filter (links, video, image, live, event) - if (!matchesContentFilter(p, contentFilter)) return false; + // Apply content filter (links, video, image, live, cluster) + if (contentFilter === "cluster") { + // Only show posts that are part of clusters + const postId = getId(p); + if (!clusterPostIds || clusterPostIds.size === 0 || !clusterPostIds.has(postId)) { + return false; + } + } else if (!matchesContentFilter(p, contentFilter)) { + return false; + } // Apply time filter if (tf && tf > 0) { @@ -905,7 +914,7 @@ export function usePostsEngine({ return true; }); }, - [mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled] + [mainFilter, subFilter, contentFilter, clusterPostIds, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled] ); const refreshVisibleAndMarkers = useCallback( @@ -971,7 +980,7 @@ export function usePostsEngine({ useEffect(() => { if (pendingFilterRef.current) return; refreshVisibleAndMarkers(timeHours); - }, [timeHours, mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]); + }, [timeHours, mainFilter, subFilter, contentFilter, clusterPostIds, searchQuery, clusterFocus, refreshVisibleAndMarkers]); useEffect(() => { if (!clustersEnabled) return; @@ -1117,14 +1126,14 @@ export function usePostsEngine({ // Map contentFilter to content_type for API let contentTypeParam = undefined; - if (contentFilter && contentFilter !== "all") { + if (contentFilter && contentFilter !== "all" && contentFilter !== "cluster") { if (contentFilter === "live") contentTypeParam = "live,camera,stream"; else if (contentFilter === "video") contentTypeParam = "video"; else if (contentFilter === "image") contentTypeParam = "image"; else if (contentFilter === "text") contentTypeParam = "post"; else if (contentFilter === "links") contentTypeParam = "news"; - else if (contentFilter === "event") contentTypeParam = "event"; } + // For "cluster" filter, fetch all posts and filter client-side by clusterPostIds // Abort any previous in-flight request if (abortControllerRef.current) {