feat: Add event clusters visualization with lake-like zones
- Add EventClustersLayer component with convex hull algorithm - Clusters appear as organic shapes around member posts - Only visible when Clusters filter is selected - Add fetchEventClusters API function - Update ContentFilters with cluster filter option - Filter posts by cluster membership when filter active Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
da4fde9185
commit
e635d0e7f6
|
|
@ -39,11 +39,11 @@ export default function App() {
|
||||||
const [selectedPost, setSelectedPost] = useState(null);
|
const [selectedPost, setSelectedPost] = useState(null);
|
||||||
|
|
||||||
// Content filter (shared between map and sociowall) - persisted
|
// 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(() => {
|
const [contentFilter, setContentFilter] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(CONTENT_FILTER_KEY);
|
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;
|
return saved;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Weather API
|
||||||
export async function fetchWeatherByCity(city) {
|
export async function fetchWeatherByCity(city) {
|
||||||
const url = `${apiBase()}/weather?city=${encodeURIComponent(city)}`;
|
const url = `${apiBase()}/weather?city=${encodeURIComponent(city)}`;
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-filter-btn.filter-event i {
|
.content-filter-btn.filter-cluster i {
|
||||||
color: #f59e0b;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep white icon when active */
|
/* Keep white icon when active */
|
||||||
|
|
@ -99,9 +99,9 @@
|
||||||
box-shadow: 0 2px 8px rgba(6, 182, 212, 0.4);
|
box-shadow: 0 2px 8px rgba(6, 182, 212, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-filter-btn.filter-event.active {
|
.content-filter-btn.filter-cluster.active {
|
||||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
background: linear-gradient(135deg, #a855f7, #9333ea);
|
||||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
|
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blue theme (default dark) */
|
/* Blue theme (default dark) */
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const FILTERS = [
|
||||||
{ key: "image", icon: "fa-solid fa-camera", label: "Photos" },
|
{ key: "image", icon: "fa-solid fa-camera", label: "Photos" },
|
||||||
{ key: "text", icon: "fa-solid fa-pen-to-square", label: "Blogs" },
|
{ key: "text", icon: "fa-solid fa-pen-to-square", label: "Blogs" },
|
||||||
{ key: "live", icon: "fa-solid fa-tower-broadcast", label: "Live" },
|
{ 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 }) {
|
export default function ContentFilters({ activeFilter, onFilterChange }) {
|
||||||
|
|
@ -35,7 +35,6 @@ const KNOWN_CONTENT_TYPES = new Set([
|
||||||
"camera", "live", "stream",
|
"camera", "live", "stream",
|
||||||
"post", "text", "blog",
|
"post", "text", "blog",
|
||||||
"news",
|
"news",
|
||||||
"event",
|
|
||||||
"weather", "traffic"
|
"weather", "traffic"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -66,8 +65,9 @@ export function matchesContentFilter(post, filter) {
|
||||||
case "live":
|
case "live":
|
||||||
// Only live content: cameras (quebec511), live streams - NOT recorded videos
|
// Only live content: cameras (quebec511), live streams - NOT recorded videos
|
||||||
return ct === "live" || ct === "stream" || ct === "camera";
|
return ct === "live" || ct === "stream" || ct === "camera";
|
||||||
case "event":
|
case "cluster":
|
||||||
return cat === "event" || cat === "events" || ct === "event";
|
// Clusters filter shows all posts - the cluster zones overlay them
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import UserProfile from "../Profile/UserProfile";
|
||||||
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
import DayNightTerminator from "./DayNightTerminator";
|
import DayNightTerminator from "./DayNightTerminator";
|
||||||
|
import EventClustersLayer from "./EventClustersLayer";
|
||||||
|
|
||||||
const CONTENT_CREATOR_MODES = [
|
const CONTENT_CREATOR_MODES = [
|
||||||
{
|
{
|
||||||
|
|
@ -287,6 +288,7 @@ export default function MapView({
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const skipAutoZoomRef = useRef(false);
|
const skipAutoZoomRef = useRef(false);
|
||||||
const [clusterFocus, setClusterFocus] = useState(null);
|
const [clusterFocus, setClusterFocus] = useState(null);
|
||||||
|
const [clusterPostIds, setClusterPostIds] = useState(new Set());
|
||||||
const [forceFullPostId, setForceFullPostId] = useState(null);
|
const [forceFullPostId, setForceFullPostId] = useState(null);
|
||||||
|
|
||||||
// Profile state (selectedIsland is now passed from App.jsx)
|
// Profile state (selectedIsland is now passed from App.jsx)
|
||||||
|
|
@ -544,6 +546,7 @@ export default function MapView({
|
||||||
subFilter,
|
subFilter,
|
||||||
subFilters,
|
subFilters,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
|
clusterPostIds,
|
||||||
timeHours,
|
timeHours,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
clusterFocus,
|
clusterFocus,
|
||||||
|
|
@ -2059,6 +2062,25 @@ export default function MapView({
|
||||||
<div className="map-stage" ref={stageRef}>
|
<div className="map-stage" ref={stageRef}>
|
||||||
<div ref={containerRef} className="map-container" />
|
<div ref={containerRef} className="map-container" />
|
||||||
<DayNightTerminator map={mapRef.current} enabled={weatherViewEnabled} opacity={0.15} />
|
<DayNightTerminator map={mapRef.current} enabled={weatherViewEnabled} opacity={0.15} />
|
||||||
|
{viewParams.center && (
|
||||||
|
<EventClustersLayer
|
||||||
|
map={mapRef.current}
|
||||||
|
enabled={contentFilter === "cluster"}
|
||||||
|
onClusterPostIds={setClusterPostIds}
|
||||||
|
onClusterClick={(cluster) => {
|
||||||
|
// 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 && <div className="map-status">{status}</div>}
|
{status && <div className="map-status">{status}</div>}
|
||||||
{debugEnabled && (
|
{debugEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,7 @@ export function usePostsEngine({
|
||||||
subFilter,
|
subFilter,
|
||||||
subFilters = [],
|
subFilters = [],
|
||||||
contentFilter = "all",
|
contentFilter = "all",
|
||||||
|
clusterPostIds = new Set(),
|
||||||
timeHours,
|
timeHours,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
clusterFocus,
|
clusterFocus,
|
||||||
|
|
@ -878,8 +879,16 @@ export function usePostsEngine({
|
||||||
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
||||||
|
|
||||||
return posts.filter((p) => {
|
return posts.filter((p) => {
|
||||||
// Apply content filter (links, video, image, live, event)
|
// Apply content filter (links, video, image, live, cluster)
|
||||||
if (!matchesContentFilter(p, contentFilter)) return false;
|
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
|
// Apply time filter
|
||||||
if (tf && tf > 0) {
|
if (tf && tf > 0) {
|
||||||
|
|
@ -905,7 +914,7 @@ export function usePostsEngine({
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
[mainFilter, subFilter, contentFilter, clusterPostIds, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshVisibleAndMarkers = useCallback(
|
const refreshVisibleAndMarkers = useCallback(
|
||||||
|
|
@ -971,7 +980,7 @@ export function usePostsEngine({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingFilterRef.current) return;
|
if (pendingFilterRef.current) return;
|
||||||
refreshVisibleAndMarkers(timeHours);
|
refreshVisibleAndMarkers(timeHours);
|
||||||
}, [timeHours, mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
}, [timeHours, mainFilter, subFilter, contentFilter, clusterPostIds, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clustersEnabled) return;
|
if (!clustersEnabled) return;
|
||||||
|
|
@ -1117,14 +1126,14 @@ export function usePostsEngine({
|
||||||
|
|
||||||
// Map contentFilter to content_type for API
|
// Map contentFilter to content_type for API
|
||||||
let contentTypeParam = undefined;
|
let contentTypeParam = undefined;
|
||||||
if (contentFilter && contentFilter !== "all") {
|
if (contentFilter && contentFilter !== "all" && contentFilter !== "cluster") {
|
||||||
if (contentFilter === "live") contentTypeParam = "live,camera,stream";
|
if (contentFilter === "live") contentTypeParam = "live,camera,stream";
|
||||||
else if (contentFilter === "video") contentTypeParam = "video";
|
else if (contentFilter === "video") contentTypeParam = "video";
|
||||||
else if (contentFilter === "image") contentTypeParam = "image";
|
else if (contentFilter === "image") contentTypeParam = "image";
|
||||||
else if (contentFilter === "text") contentTypeParam = "post";
|
else if (contentFilter === "text") contentTypeParam = "post";
|
||||||
else if (contentFilter === "links") contentTypeParam = "news";
|
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
|
// Abort any previous in-flight request
|
||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue