1394 lines
46 KiB
JavaScript
1394 lines
46 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch } from "../../api/client";
|
|
import { categoryCode, matchesSubFilter, matchesTimeFilter, effectivePostTime } from "./mapFilter";
|
|
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
|
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
|
import { getTemplateKeyForPost } from "./templateSpecs";
|
|
import { prioritizePosts } from "./postPriority";
|
|
import { trackEvent } from "../../utils/analytics";
|
|
|
|
function getId(p) {
|
|
return p?.id ?? p?._id ?? null;
|
|
}
|
|
|
|
function normalizePostId(post) {
|
|
if (!post || typeof post !== "object") return post;
|
|
const pid = Number(post?.post_id ?? post?.postId ?? post?.postID ?? 0);
|
|
if (Number.isFinite(pid) && pid > 0 && Number(post?.id) !== pid) {
|
|
return { ...post, reco_id: post?.id ?? post?.reco_id, id: pid };
|
|
}
|
|
return post;
|
|
}
|
|
|
|
function getPostKey(p) {
|
|
const id = getId(p);
|
|
if (id != null && id !== 0) return `id:${id}`;
|
|
const urlHash = (p?.url_hash || p?.urlHash || "").toString().trim();
|
|
if (urlHash) return `urlhash:${urlHash}`;
|
|
const url = (p?.url || "").toString().trim();
|
|
if (url) return `url:${url}`;
|
|
const title = (p?.title || p?.Title || "").toString().trim();
|
|
const created = (p?.created_at || p?.CreatedAt || p?.createdAt || "").toString().trim();
|
|
if (title || created) return `t:${title}|${created}`;
|
|
return `fallback:${JSON.stringify(p)}`;
|
|
}
|
|
|
|
function sameIdList(a, b) {
|
|
if (a === b) return true;
|
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (getId(a[i]) !== getId(b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function asText(v) {
|
|
if (v == null) return "";
|
|
return String(v);
|
|
}
|
|
|
|
function getTemplateKey(post) {
|
|
return getTemplateKeyForPost(post);
|
|
}
|
|
|
|
function resolvePostImage(post) {
|
|
return (
|
|
post?.image_small ||
|
|
post?.image_medium ||
|
|
post?.image_large ||
|
|
post?.image ||
|
|
post?.media_url ||
|
|
post?.image_url ||
|
|
""
|
|
).toString();
|
|
}
|
|
|
|
function postHaystack(p) {
|
|
const title = asText(p?.title || p?.text || p?.body || "");
|
|
const snippet = asText(p?.snippet || p?.summary || p?.content || "");
|
|
const author = asText(p?.author || p?.Author || p?.user || p?.username || "");
|
|
const city = asText(p?.city || p?.location_name || p?.place || "");
|
|
const cat = asText(p?.category || p?.Category || "");
|
|
const sub = asText(p?.sub_category || p?.subCategory || "");
|
|
return `${title} ${snippet} ${author} ${city} ${cat} ${sub}`.toLowerCase();
|
|
}
|
|
|
|
function matchesSearch(p, qRaw) {
|
|
const q = (qRaw || "").toString().trim().toLowerCase();
|
|
if (!q) return true;
|
|
|
|
const author = asText(p?.author || p?.Author || p?.user || p?.username || "").toLowerCase();
|
|
const hay = postHaystack(p);
|
|
|
|
const terms = q.split(/\s+/).map((t) => t.trim()).filter(Boolean);
|
|
for (const t of terms) {
|
|
if (t.startsWith("@")) {
|
|
const want = t.slice(1).trim();
|
|
if (!want) continue;
|
|
if (!author.includes(want)) return false;
|
|
continue;
|
|
}
|
|
if (!hay.includes(t)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function hasPostsInRadius(posts, centerLat, centerLon, radiusKm) {
|
|
if (!Array.isArray(posts) || posts.length === 0) return false;
|
|
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) return true;
|
|
const limit = Math.max(radiusKm || 0, 1);
|
|
for (const p of posts) {
|
|
const coords = getCoords(p);
|
|
if (!coords) continue;
|
|
const [lon, lat] = coords;
|
|
const dKm = haversineKm(centerLat, centerLon, lat, lon);
|
|
if (dKm <= limit) return true;
|
|
}
|
|
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.15;
|
|
const padLon = Math.abs(ne.lng - sw.lng) * 0.15;
|
|
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;
|
|
});
|
|
}
|
|
|
|
|
|
function getPostTimeMs(p) {
|
|
const raw =
|
|
p?.published_at ||
|
|
p?.publishedAt ||
|
|
p?.PublishedAt ||
|
|
p?.created_at ||
|
|
p?.CreatedAt ||
|
|
p?.createdAt;
|
|
if (!raw) return 0;
|
|
const d = raw instanceof Date ? raw : new Date(String(raw));
|
|
if (Number.isNaN(d.getTime())) return 0;
|
|
return d.getTime();
|
|
}
|
|
|
|
function isClusterMainPost(p) {
|
|
const clusterPostId = Number(p?.cluster_post_id);
|
|
const postId = Number(p?.post_id || p?.id);
|
|
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
|
|
return clusterPostId === postId;
|
|
}
|
|
const key = (p?.cluster_key || p?.clusterKey || "").toString().trim();
|
|
const members = Array.isArray(p?.cluster_members) ? p.cluster_members : [];
|
|
if (key && members.length > 0) return true;
|
|
const url = (p?.url || "").toString();
|
|
return url.includes("/cluster/");
|
|
}
|
|
|
|
function clusterColor(post) {
|
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
|
if (c === "EVENT" || c === "EVENTS") return "#a855f7";
|
|
if (c === "FRIENDS") return "#22c55e";
|
|
if (c === "MARKET") return "#facc15";
|
|
return "#38bdf8";
|
|
}
|
|
|
|
function clusterLineColor(post) {
|
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
|
if (c === "EVENT" || c === "EVENTS") return "rgba(168,85,247,0.7)";
|
|
if (c === "FRIENDS") return "rgba(34,197,94,0.7)";
|
|
if (c === "MARKET") return "rgba(250,204,21,0.8)";
|
|
return "rgba(56,189,248,0.75)";
|
|
}
|
|
|
|
function convexHull(points) {
|
|
if (!Array.isArray(points) || points.length < 3) return points || [];
|
|
const pts = [...points].sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
|
|
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
const lower = [];
|
|
for (const p of pts) {
|
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
|
lower.pop();
|
|
}
|
|
lower.push(p);
|
|
}
|
|
const upper = [];
|
|
for (let i = pts.length - 1; i >= 0; i--) {
|
|
const p = pts[i];
|
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
|
upper.pop();
|
|
}
|
|
upper.push(p);
|
|
}
|
|
upper.pop();
|
|
lower.pop();
|
|
return lower.concat(upper);
|
|
}
|
|
|
|
function chaikinSmooth(points, iterations = 2) {
|
|
let pts = points.slice();
|
|
if (pts.length < 4) return pts;
|
|
for (let i = 0; i < iterations; i++) {
|
|
const next = [];
|
|
for (let j = 0; j < pts.length - 1; j++) {
|
|
const p0 = pts[j];
|
|
const p1 = pts[j + 1];
|
|
const q = [0.75 * p0[0] + 0.25 * p1[0], 0.75 * p0[1] + 0.25 * p1[1]];
|
|
const r = [0.25 * p0[0] + 0.75 * p1[0], 0.25 * p0[1] + 0.75 * p1[1]];
|
|
next.push(q, r);
|
|
}
|
|
next.push(next[0]);
|
|
pts = next;
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
function circlePolygon(center, radiusKm, steps = 24) {
|
|
const [lon, lat] = center;
|
|
const latDeg = radiusKm / 111;
|
|
const lonDeg = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
|
|
const coords = [];
|
|
for (let i = 0; i <= steps; i++) {
|
|
const angle = (Math.PI * 2 * i) / steps;
|
|
coords.push([lon + Math.cos(angle) * lonDeg, lat + Math.sin(angle) * latDeg]);
|
|
}
|
|
return coords;
|
|
}
|
|
|
|
function buildClusterAreaFeatures(posts) {
|
|
const features = [];
|
|
for (const p of posts || []) {
|
|
if (!isClusterMainPost(p)) continue;
|
|
const items = Array.isArray(p?.cluster_items) ? p.cluster_items : [];
|
|
const pts = items
|
|
.map((it) => [Number(it?.lon), Number(it?.lat)])
|
|
.filter((v) => Number.isFinite(v[0]) && Number.isFinite(v[1]));
|
|
if (pts.length === 0) continue;
|
|
|
|
let ring = [];
|
|
if (pts.length >= 3) {
|
|
ring = convexHull(pts);
|
|
if (ring.length < 3) {
|
|
ring = pts.slice(0, 3);
|
|
}
|
|
ring = [...ring, ring[0]];
|
|
ring = chaikinSmooth(ring, 2);
|
|
// Slight outward nudge so the bubble touches all points
|
|
const center = ring.slice(0, -1).reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]], [0, 0]);
|
|
const count = Math.max(1, ring.length - 1);
|
|
const centerPt = [center[0] / count, center[1] / count];
|
|
ring = ring.map((p, idx) => {
|
|
if (idx === ring.length - 1) return p;
|
|
const dx = p[0] - centerPt[0];
|
|
const dy = p[1] - centerPt[1];
|
|
return [centerPt[0] + dx * 1.32, centerPt[1] + dy * 1.32];
|
|
});
|
|
ring[ring.length - 1] = ring[0];
|
|
} else {
|
|
const centerLon = pts.reduce((s, v) => s + v[0], 0) / pts.length;
|
|
const centerLat = pts.reduce((s, v) => s + v[1], 0) / pts.length;
|
|
let maxKm = 0.0;
|
|
for (const [lon, lat] of pts) {
|
|
maxKm = Math.max(maxKm, haversineKm(centerLat, centerLon, lat, lon));
|
|
}
|
|
const radius = Math.min(80, Math.max(0.4, maxKm * 1.55 || 0.6));
|
|
ring = circlePolygon([centerLon, centerLat], radius, 28);
|
|
}
|
|
|
|
features.push({
|
|
type: "Feature",
|
|
geometry: { type: "Polygon", coordinates: [ring] },
|
|
properties: {
|
|
id: getId(p) || 0,
|
|
color: clusterColor(p),
|
|
line: clusterLineColor(p),
|
|
},
|
|
});
|
|
}
|
|
return features;
|
|
}
|
|
|
|
const MAX_POOL_POSTS = 160;
|
|
const MAX_VISIBLE_POSTS = 45;
|
|
|
|
function expandBounds(bounds, padRatio = 0.18) {
|
|
if (!bounds) return null;
|
|
const spanLat = Math.abs(bounds.maxLat - bounds.minLat);
|
|
const spanLon = Math.abs(bounds.maxLon - bounds.minLon);
|
|
return {
|
|
minLat: bounds.minLat - spanLat * padRatio,
|
|
maxLat: bounds.maxLat + spanLat * padRatio,
|
|
minLon: bounds.minLon - spanLon * padRatio,
|
|
maxLon: bounds.maxLon + spanLon * padRatio,
|
|
};
|
|
}
|
|
|
|
function inBounds(coords, bounds) {
|
|
if (!coords || !bounds) return false;
|
|
const [lon, lat] = coords;
|
|
return (
|
|
Number.isFinite(lat) &&
|
|
Number.isFinite(lon) &&
|
|
lat >= bounds.minLat &&
|
|
lat <= bounds.maxLat &&
|
|
lon >= bounds.minLon &&
|
|
lon <= bounds.maxLon
|
|
);
|
|
}
|
|
|
|
function prunePosts(posts, center, maxCount, bounds) {
|
|
if (!Array.isArray(posts) || posts.length <= maxCount) return posts || [];
|
|
const [centerLon, centerLat] = center || [];
|
|
const now = Date.now();
|
|
const paddedBounds = expandBounds(bounds);
|
|
const inView = [];
|
|
const outView = [];
|
|
|
|
for (const p of posts) {
|
|
const coords = getCoords(p);
|
|
if (paddedBounds && inBounds(coords, paddedBounds)) {
|
|
inView.push(p);
|
|
} else {
|
|
outView.push(p);
|
|
}
|
|
}
|
|
|
|
const scoreList = (arr) => arr.map((p) => {
|
|
const coords = getCoords(p);
|
|
let dist = 99999;
|
|
if (coords && Number.isFinite(centerLat) && Number.isFinite(centerLon)) {
|
|
dist = haversineKm(centerLat, centerLon, coords[1], coords[0]);
|
|
}
|
|
const ageH = Math.max(0, (now - getPostTimeMs(p)) / 3600000);
|
|
const score = dist + ageH * 0.4;
|
|
return { p, score };
|
|
}).sort((a, b) => a.score - b.score);
|
|
|
|
const picked = [];
|
|
const inScored = scoreList(inView);
|
|
for (const it of inScored) {
|
|
if (picked.length >= maxCount) break;
|
|
picked.push(it.p);
|
|
}
|
|
if (picked.length < maxCount) {
|
|
const outScored = scoreList(outView);
|
|
for (const it of outScored) {
|
|
if (picked.length >= maxCount) break;
|
|
picked.push(it.p);
|
|
}
|
|
}
|
|
return picked;
|
|
}
|
|
|
|
export function usePostsEngine({
|
|
mapRef,
|
|
viewParams,
|
|
mainFilter,
|
|
subFilter,
|
|
timeHours,
|
|
searchQuery,
|
|
clusterFocus,
|
|
mainNewsOnly,
|
|
clustersEnabled = true,
|
|
heatmapEnabled = true,
|
|
forceFullPostId = null,
|
|
markersRef,
|
|
expandedElRef,
|
|
onAutoWidenTimeFilter,
|
|
onSelectPost,
|
|
username,
|
|
theme = "blue",
|
|
}) {
|
|
const allPostsRef = useRef([]);
|
|
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeHours: 0, bounds: null });
|
|
const pendingFilterRef = useRef("");
|
|
const requestSeqRef = useRef(0);
|
|
const delayedFetchRef = useRef(null);
|
|
const moveDebounceRef = useRef(null);
|
|
const analyticsDebounceRef = useRef(0);
|
|
const initialLoadRef = useRef(true);
|
|
const initialFetchKickRef = useRef(false);
|
|
const autoWidenRef = useRef(false);
|
|
const searchResultsRef = useRef(false);
|
|
const replacePoolRef = useRef(false);
|
|
const retryRef = useRef({ key: "", count: 0, ts: 0 });
|
|
const [mapReady, setMapReady] = useState(false);
|
|
const clusterModeRef = useRef(false);
|
|
|
|
const CLUSTER_THRESHOLD = 140;
|
|
const SIMPLE_MARKER_LIMIT = 50;
|
|
const CLUSTER_SOURCE_ID = "sw-posts";
|
|
const CLUSTER_LAYER_ID = "sw-clusters";
|
|
const CLUSTER_COUNT_ID = "sw-cluster-count";
|
|
const UNCLUSTERED_ID = "sw-unclustered";
|
|
const CLUSTER_AREA_SOURCE_ID = "sw-cluster-areas";
|
|
const CLUSTER_AREA_FILL_ID = "sw-cluster-area-fill";
|
|
const CLUSTER_AREA_LINE_ID = "sw-cluster-area-line";
|
|
|
|
useEffect(() => {
|
|
if (mapReady) return;
|
|
let cancelled = false;
|
|
const timer = setInterval(() => {
|
|
if (cancelled) return;
|
|
if (mapRef.current) {
|
|
mapRef.current.__swForceFetchAt = Date.now();
|
|
setMapReady(true);
|
|
clearInterval(timer);
|
|
}
|
|
}, 150);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
}, [mapReady, mapRef]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || initialFetchKickRef.current) return;
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
initialFetchKickRef.current = true;
|
|
map.__swForceFetchAt = Date.now();
|
|
setTimeout(() => {
|
|
try {
|
|
map.fire("moveend");
|
|
} catch {}
|
|
}, 120);
|
|
}, [mapReady, mapRef]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
replacePoolRef.current = true;
|
|
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`;
|
|
allPostsRef.current = [];
|
|
setVisiblePosts([]);
|
|
syncMarkers([]);
|
|
if (map && clustersEnabled && heatmapEnabled) {
|
|
updateClusterAreas(map, []);
|
|
}
|
|
if (map) {
|
|
map.__swForceFetchAt = Date.now();
|
|
setTimeout(() => {
|
|
try {
|
|
map.fire("moveend");
|
|
} catch {}
|
|
}, 60);
|
|
}
|
|
}, [mainFilter, subFilter, timeHours, mapRef, syncMarkers, clustersEnabled, heatmapEnabled, updateClusterAreas]);
|
|
|
|
const priorityOpts = {
|
|
maxFullCards: 15,
|
|
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 ensureClusterAreaLayers = useCallback((map) => {
|
|
if (!map) return;
|
|
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
|
if (map.getSource(CLUSTER_AREA_SOURCE_ID)) return;
|
|
|
|
map.addSource(CLUSTER_AREA_SOURCE_ID, {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
});
|
|
|
|
map.addLayer({
|
|
id: CLUSTER_AREA_FILL_ID,
|
|
type: "fill",
|
|
source: CLUSTER_AREA_SOURCE_ID,
|
|
paint: {
|
|
"fill-color": ["get", "color"],
|
|
"fill-opacity": 0.42,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: CLUSTER_AREA_LINE_ID,
|
|
type: "line",
|
|
source: CLUSTER_AREA_SOURCE_ID,
|
|
paint: {
|
|
"line-color": ["get", "line"],
|
|
"line-width": 2,
|
|
"line-opacity": 0.7,
|
|
},
|
|
});
|
|
}, [CLUSTER_AREA_SOURCE_ID, CLUSTER_AREA_FILL_ID, CLUSTER_AREA_LINE_ID]);
|
|
|
|
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 updateClusterAreas = useCallback((map, posts) => {
|
|
if (!map) return;
|
|
const src = map.getSource(CLUSTER_AREA_SOURCE_ID);
|
|
if (!src || !src.setData) return;
|
|
const features = buildClusterAreaFeatures(posts || []);
|
|
src.setData({ type: "FeatureCollection", features });
|
|
}, [CLUSTER_AREA_SOURCE_ID]);
|
|
|
|
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 searchActive = (searchQuery || "").trim().length > 0;
|
|
if (searchActive) {
|
|
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, searchQuery]
|
|
);
|
|
|
|
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);
|
|
const [loadError, setLoadError] = useState("");
|
|
|
|
const syncMarkers = useCallback(
|
|
(visible) => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
map.__swMarkersRef = markersRef;
|
|
|
|
// If user is reading an expanded overlay, don't touch markers
|
|
if (expandedElRef?.current || map.__swCenteredOverlay) return;
|
|
|
|
// Use priority system to decide which posts show full cards vs simple markers
|
|
const center = map.getCenter();
|
|
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
|
|
|
const searchActive = (searchQuery || "").trim().length > 0;
|
|
let fullCardPosts = [];
|
|
let simpleMarkerPosts = [];
|
|
const prioritized = prioritizePosts(visible, viewCenter, {
|
|
maxFullCards: 15, // 10-15 cartes complètes selon le zoom
|
|
clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement)
|
|
minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50)
|
|
});
|
|
fullCardPosts = prioritized.fullCardPosts;
|
|
simpleMarkerPosts = prioritized.simpleMarkerPosts;
|
|
|
|
console.log('[usePostsEngine] Prioritized:', {
|
|
total: visible.length,
|
|
fullCards: fullCardPosts.length,
|
|
simpleMarkers: simpleMarkerPosts.length
|
|
});
|
|
|
|
// Combine both types for "wanted" tracking
|
|
const wanted = new Map();
|
|
for (const post of visible) {
|
|
const id = getId(post);
|
|
if (id != null) {
|
|
// Tag avec le type de marqueur souhaité
|
|
const isFullCard =
|
|
id === forceFullPostId ||
|
|
fullCardPosts.some(p => getId(p) === id);
|
|
wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
|
|
}
|
|
}
|
|
|
|
const cur = markersRef.current || [];
|
|
const next = [];
|
|
|
|
// remove markers not wanted (smooth fade-out)
|
|
for (const item of cur) {
|
|
const id = item?.id;
|
|
if (id == null || !wanted.has(id)) {
|
|
try {
|
|
const el = item?.el;
|
|
if (el) {
|
|
el.classList.add("sw-disappear");
|
|
el.style.pointerEvents = "none";
|
|
}
|
|
} catch {}
|
|
|
|
// let CSS transition play, then remove from map
|
|
setTimeout(() => {
|
|
try { if (item?.el && item.el.__swUnmount) item.el.__swUnmount(); } catch {}
|
|
try { item?.marker?.remove?.(); } catch {}
|
|
}, 840);
|
|
} else {
|
|
const entry = wanted.get(id);
|
|
if (entry?.post) {
|
|
item.post = entry.post;
|
|
if (item.el) {
|
|
item.el.__post = entry.post;
|
|
const nextKey = getTemplateKey(entry.post);
|
|
const nextImg = resolvePostImage(entry.post);
|
|
const nextTitle = (entry.post?.title || "").toString();
|
|
if (
|
|
item.el.__lastTemplateKey !== nextKey ||
|
|
item.el.__lastImage !== nextImg ||
|
|
item.el.__lastTitle !== nextTitle
|
|
) {
|
|
if (item.type === "simple" && item.el.__renderSimple) {
|
|
try { item.el.__renderSimple(); } catch {}
|
|
} else if (item.el.__renderCompact) {
|
|
try { item.el.__renderCompact(); } catch {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
next.push(item);
|
|
}
|
|
}
|
|
|
|
markersRef.current = next;
|
|
|
|
const have = new Set(next.map((x) => x.id));
|
|
|
|
// add missing markers - use appropriate type based on priority
|
|
for (const [id, { post, type }] of wanted.entries()) {
|
|
if (have.has(id)) continue;
|
|
|
|
if (type === 'full') {
|
|
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
|
|
} else {
|
|
createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
|
|
}
|
|
}
|
|
},
|
|
[mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId]
|
|
);
|
|
|
|
const computeVisible = useCallback(
|
|
(tf) => {
|
|
const basePosts = allPostsRef.current || [];
|
|
const extraPosts = Array.isArray(clusterFocus?.posts) ? clusterFocus.posts : [];
|
|
const posts = extraPosts.length
|
|
? Array.from(new Map([...extraPosts, ...basePosts].map((p) => {
|
|
const normalized = normalizePostId(p);
|
|
return [getPostKey(normalized), normalized];
|
|
})).values())
|
|
: basePosts;
|
|
const catCode = categoryCode(mainFilter);
|
|
const clusterIDs = clustersEnabled && Array.isArray(clusterFocus?.ids) ? clusterFocus.ids : null;
|
|
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
|
|
|
return posts.filter((p) => {
|
|
if (isClusterFocus) {
|
|
const id = Number(getId(p));
|
|
const clusterPostId = Number(p?.cluster_post_id);
|
|
if (clusterIDs && !clusterIDs.includes(id) && !clusterIDs.includes(clusterPostId)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (clustersEnabled && mainNewsOnly && !isClusterMainPost(p)) return false;
|
|
if (catCode) {
|
|
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
|
if (pc !== catCode) return false;
|
|
}
|
|
if (!matchesSubFilter(p, subFilter)) return false;
|
|
if (!matchesTimeFilter(effectivePostTime(p), tf)) return false;
|
|
if (!searchResultsRef.current && !matchesSearch(p, searchQuery)) return false;
|
|
return true;
|
|
});
|
|
},
|
|
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
|
);
|
|
|
|
const refreshVisibleAndMarkers = useCallback(
|
|
(tf, force = false) => {
|
|
const vAll = computeVisible(tf);
|
|
const map = mapRef.current;
|
|
const isClusterFocus = !!(clustersEnabled && Array.isArray(clusterFocus?.ids) && clusterFocus.ids.length > 0);
|
|
let v = vAll;
|
|
if (!isClusterFocus) {
|
|
const center = map?.getCenter?.();
|
|
let bounds = null;
|
|
try {
|
|
const b = map?.getBounds?.();
|
|
if (b) {
|
|
const sw = b.getSouthWest();
|
|
const ne = b.getNorthEast();
|
|
bounds = { minLat: sw?.lat, minLon: sw?.lng, maxLat: ne?.lat, maxLon: ne?.lng };
|
|
}
|
|
} catch {}
|
|
if (v.length > MAX_VISIBLE_POSTS) {
|
|
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS, bounds);
|
|
}
|
|
}
|
|
|
|
// reduce wall blink: don't update if same ids
|
|
setVisiblePosts((prev) => {
|
|
if (force) return v;
|
|
return sameIdList(prev, v) ? prev : v;
|
|
});
|
|
|
|
// keep status silent (no annoying bubbles)
|
|
setStatus("");
|
|
|
|
if (map && clustersEnabled && heatmapEnabled) {
|
|
ensureClusterAreaLayers(map);
|
|
updateClusterAreas(map, v);
|
|
}
|
|
|
|
if (clustersEnabled && 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, mapRef, ensureClusterLayers, ensureClusterAreaLayers, updateClusterAreas, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers, viewParams, clustersEnabled, heatmapEnabled, clusterFocus, searchQuery]
|
|
);
|
|
|
|
// On filter/search changes: recompute + sync (NO clear-all rebuild)
|
|
useEffect(() => {
|
|
if (pendingFilterRef.current) return;
|
|
refreshVisibleAndMarkers(timeHours);
|
|
}, [timeHours, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
|
|
|
useEffect(() => {
|
|
if (!clustersEnabled) return;
|
|
if (!clusterFocus) return;
|
|
if (Array.isArray(clusterFocus.posts) && clusterFocus.posts.length > 0) {
|
|
allPostsRef.current = clusterFocus.posts;
|
|
}
|
|
refreshVisibleAndMarkers(timeHours, true);
|
|
}, [clusterFocus, timeHours, refreshVisibleAndMarkers, clustersEnabled]);
|
|
|
|
// On view change: re-filter markers/cards to current bounds even without fetch
|
|
useEffect(() => {
|
|
if (pendingFilterRef.current) return;
|
|
refreshVisibleAndMarkers(timeHours);
|
|
}, [viewParams, refreshVisibleAndMarkers, timeHours]);
|
|
|
|
// Fetch on view change (moveend)
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
let cancelled = false;
|
|
|
|
async function load() {
|
|
const mapObj = mapRef.current;
|
|
const resolvedView =
|
|
viewParams && viewParams.center ? viewParams : getViewFromMap(mapObj);
|
|
if (!resolvedView?.center) return;
|
|
if ((searchQuery || "").trim()) return;
|
|
|
|
// If expanded overlay is open: delay fetch (do NOT disturb UI)
|
|
if (expandedElRef?.current || mapObj?.__swCenteredOverlay) {
|
|
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
|
delayedFetchRef.current = setTimeout(load, 1200);
|
|
return;
|
|
}
|
|
|
|
// Respect ignore window if any
|
|
try {
|
|
const until = mapObj?.__swIgnoreFetchUntil || 0;
|
|
if (until && Date.now() < until) {
|
|
const wait = Math.min(2500, until - Date.now());
|
|
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
|
delayedFetchRef.current = setTimeout(load, wait + 25);
|
|
return;
|
|
}
|
|
} catch {}
|
|
|
|
const [lng, lat] = resolvedView.center;
|
|
const radiusKm = resolvedView.radiusKm || 750;
|
|
const filterKey = `${mainFilter}|${subFilter}`;
|
|
const currentKey = `${filterKey}|${timeHours}`;
|
|
const reqId = ++requestSeqRef.current;
|
|
let bounds = null;
|
|
try {
|
|
const b = mapObj.getBounds();
|
|
if (b) {
|
|
const sw = b.getSouthWest();
|
|
const ne = b.getNorthEast();
|
|
bounds = {
|
|
minLat: sw?.lat,
|
|
minLon: sw?.lng,
|
|
maxLat: ne?.lat,
|
|
maxLon: ne?.lng,
|
|
};
|
|
}
|
|
} catch {}
|
|
|
|
const last = lastFetchRef.current;
|
|
const forceFetchAt = mapObj?.__swForceFetchAt || 0;
|
|
const forceFetch = forceFetchAt && Date.now() - forceFetchAt < 15000;
|
|
if (forceFetch) {
|
|
mapObj.__swForceFetchAt = 0;
|
|
}
|
|
|
|
if (!forceFetch && last.center && last.filterKey === filterKey && last.timeHours === timeHours) {
|
|
const [lastLng, lastLat] = last.center;
|
|
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
|
|
|
const lastR = last.radiusKm || 0;
|
|
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.1;
|
|
|
|
let boundsChanged = false;
|
|
if (last.bounds && bounds) {
|
|
const spanLat = Math.max(0.0001, Math.abs(last.bounds.maxLat - last.bounds.minLat));
|
|
const spanLon = Math.max(0.0001, Math.abs(last.bounds.maxLon - last.bounds.minLon));
|
|
boundsChanged =
|
|
Math.abs(bounds.minLat - last.bounds.minLat) / spanLat > 0.15 ||
|
|
Math.abs(bounds.maxLat - last.bounds.maxLat) / spanLat > 0.15 ||
|
|
Math.abs(bounds.minLon - last.bounds.minLon) / spanLon > 0.15 ||
|
|
Math.abs(bounds.maxLon - last.bounds.maxLon) / spanLon > 0.15;
|
|
}
|
|
|
|
const minMoveKm = Math.max(2, radiusKm * 0.05);
|
|
|
|
if (!radiusChanged && !boundsChanged && distKm < minMoveKm) return;
|
|
}
|
|
|
|
try {
|
|
setLoadingPosts(true);
|
|
setLoadError("");
|
|
|
|
const catCode = categoryCode(mainFilter);
|
|
const subCatParam =
|
|
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
|
|
|
|
// Use smart feed for personalized, trending posts
|
|
const zoom = typeof mapObj?.getZoom === "function" ? mapObj.getZoom() : undefined;
|
|
let limit = 50;
|
|
if (typeof zoom === "number") {
|
|
if (zoom <= 6) limit = 90;
|
|
else if (zoom <= 8) limit = 70;
|
|
else if (zoom <= 10) limit = 60;
|
|
}
|
|
if (radiusKm > 800) {
|
|
limit = Math.max(limit, 80);
|
|
}
|
|
if (limit > 120) limit = 120;
|
|
|
|
let newPosts = await fetchSmartFeed({
|
|
lat,
|
|
lon: lng,
|
|
radius_km: radiusKm,
|
|
limit,
|
|
zoom,
|
|
bounds,
|
|
username: username || undefined,
|
|
time_hours: timeHours || undefined,
|
|
category: catCode || undefined,
|
|
sub_category: subCatParam || undefined,
|
|
});
|
|
|
|
if (cancelled || reqId !== requestSeqRef.current) return;
|
|
|
|
const existing = replacePoolRef.current ? [] : allPostsRef.current || [];
|
|
const byId = new Map();
|
|
for (const p of existing) {
|
|
const normalized = normalizePostId(p);
|
|
const key = getPostKey(normalized);
|
|
if (key) byId.set(key, p);
|
|
}
|
|
if (Array.isArray(newPosts)) {
|
|
for (const p of newPosts) {
|
|
const normalized = normalizePostId(p);
|
|
const key = getPostKey(normalized);
|
|
if (!key) continue;
|
|
if (timeHours) normalized._swTimeHours = timeHours;
|
|
const prior = byId.get(key);
|
|
if (prior) {
|
|
byId.set(key, { ...prior, ...normalized });
|
|
} else {
|
|
byId.set(key, normalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
allPostsRef.current = prunePosts(Array.from(byId.values()), [lng, lat], MAX_POOL_POSTS, bounds);
|
|
replacePoolRef.current = false;
|
|
lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeHours, bounds };
|
|
if (pendingFilterRef.current === currentKey) {
|
|
pendingFilterRef.current = "";
|
|
}
|
|
|
|
// Smooth update: sync markers/clusters + wall without clearing everything
|
|
refreshVisibleAndMarkers(timeHours);
|
|
const visible = computeVisible(timeHours);
|
|
const now = Date.now();
|
|
if (now - analyticsDebounceRef.current > 4000) {
|
|
analyticsDebounceRef.current = now;
|
|
trackEvent("map_fetch", {
|
|
count: Array.isArray(newPosts) ? newPosts.length : 0,
|
|
radius_km: Math.round(radiusKm || 0),
|
|
zoom: typeof zoom === "number" ? Math.round(zoom * 10) / 10 : undefined,
|
|
filter: filterKey,
|
|
time_hours: timeHours || 0,
|
|
});
|
|
}
|
|
if (
|
|
!autoWidenRef.current &&
|
|
timeHours &&
|
|
timeHours > 0 &&
|
|
Array.isArray(newPosts) &&
|
|
newPosts.length > 0 &&
|
|
visible.length === 0
|
|
) {
|
|
autoWidenRef.current = true;
|
|
if (typeof onAutoWidenTimeFilter === "function") {
|
|
onAutoWidenTimeFilter(336);
|
|
}
|
|
}
|
|
|
|
setLoadingPosts(false);
|
|
} catch (err) {
|
|
setLoadingPosts(false);
|
|
setLoadError("fetch failed");
|
|
if (pendingFilterRef.current === currentKey) {
|
|
pendingFilterRef.current = "";
|
|
}
|
|
const key = `${filterKey}|${Math.round(lat * 100)}|${Math.round(lng * 100)}|${Math.round(radiusKm)}`;
|
|
const now = Date.now();
|
|
if (retryRef.current.key !== key || now - retryRef.current.ts > 5000) {
|
|
retryRef.current = { key, count: 0, ts: now };
|
|
}
|
|
if (retryRef.current.count < 1) {
|
|
retryRef.current.count += 1;
|
|
retryRef.current.ts = now;
|
|
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
|
delayedFetchRef.current = setTimeout(load, 650);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (moveDebounceRef.current) {
|
|
clearTimeout(moveDebounceRef.current);
|
|
moveDebounceRef.current = null;
|
|
}
|
|
if (initialLoadRef.current) {
|
|
initialLoadRef.current = false;
|
|
load();
|
|
} else {
|
|
moveDebounceRef.current = setTimeout(load, 300);
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
if (moveDebounceRef.current) {
|
|
try { clearTimeout(moveDebounceRef.current); } catch {}
|
|
moveDebounceRef.current = null;
|
|
}
|
|
if (delayedFetchRef.current) {
|
|
try { clearTimeout(delayedFetchRef.current); } catch {}
|
|
delayedFetchRef.current = null;
|
|
}
|
|
};
|
|
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]);
|
|
|
|
useEffect(() => {
|
|
const q = (searchQuery || "").trim();
|
|
if (!q) return;
|
|
let cancelled = false;
|
|
|
|
async function runSearch() {
|
|
try {
|
|
const map = mapRef.current;
|
|
let lat;
|
|
let lon;
|
|
let radiusKm;
|
|
try {
|
|
const view = map ? getViewFromMap(map) : null;
|
|
if (view?.center) {
|
|
lon = view.center[0];
|
|
lat = view.center[1];
|
|
} else {
|
|
const center = map?.getCenter?.();
|
|
lat = center?.lat;
|
|
lon = center?.lng;
|
|
}
|
|
if (view?.radiusKm) {
|
|
radiusKm = Math.min(Math.max(view.radiusKm * 1.2, 25), 2500);
|
|
}
|
|
} catch {}
|
|
const data = await fetchUnifiedSearch(q, {
|
|
type: "posts",
|
|
lat,
|
|
lon,
|
|
radius_km: radiusKm,
|
|
limit: 60,
|
|
});
|
|
if (cancelled) return;
|
|
const posts = Array.isArray(data?.posts) ? data.posts : [];
|
|
const normalized = posts.map((p) => normalizePostId(p));
|
|
let bounds = null;
|
|
try {
|
|
const b = map?.getBounds?.();
|
|
if (b) {
|
|
const sw = b.getSouthWest();
|
|
const ne = b.getNorthEast();
|
|
bounds = { minLat: sw?.lat, minLon: sw?.lng, maxLat: ne?.lat, maxLon: ne?.lng };
|
|
}
|
|
} catch {}
|
|
allPostsRef.current = prunePosts(normalized, [lon, lat], MAX_POOL_POSTS, bounds);
|
|
searchResultsRef.current = true;
|
|
refreshVisibleAndMarkers(timeHours);
|
|
trackEvent("search_filter_apply", {
|
|
query_len: q.length,
|
|
results: posts.length,
|
|
context: "map",
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
runSearch();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeHours]);
|
|
|
|
useEffect(() => {
|
|
if ((searchQuery || "").trim()) return;
|
|
searchResultsRef.current = false;
|
|
}, [searchQuery]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
const handleStyle = () => {
|
|
if (!clustersEnabled) return;
|
|
if (!clusterModeRef.current) return;
|
|
ensureClusterLayers(map);
|
|
setClusterVisibility(map, true);
|
|
const { clusterPosts } = splitForClusters(computeVisible(timeHours));
|
|
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
|
|
updateClusterData(map, clusterRemainder);
|
|
};
|
|
map.on("styledata", handleStyle);
|
|
return () => map.off("styledata", handleStyle);
|
|
}, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeHours, splitForClusters, clustersEnabled]);
|
|
|
|
const handleIncomingPost = useCallback(
|
|
(p) => {
|
|
const key = getPostKey(p);
|
|
if (key) {
|
|
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
|
|
if (exists) return;
|
|
}
|
|
const map = mapRef.current;
|
|
const center = map?.getCenter?.();
|
|
const normalized = normalizePostId(p);
|
|
let bounds = null;
|
|
try {
|
|
const b = map?.getBounds?.();
|
|
if (b) {
|
|
const sw = b.getSouthWest();
|
|
const ne = b.getNorthEast();
|
|
bounds = { minLat: sw?.lat, minLon: sw?.lng, maxLat: ne?.lat, maxLon: ne?.lng };
|
|
}
|
|
} catch {}
|
|
allPostsRef.current = prunePosts([normalized, ...allPostsRef.current], [center?.lng, center?.lat], MAX_POOL_POSTS, bounds);
|
|
|
|
const catCode = categoryCode(mainFilter);
|
|
if (catCode) {
|
|
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
|
if (pc !== catCode) return;
|
|
}
|
|
if (!matchesSubFilter(p, subFilter)) return;
|
|
if (!matchesTimeFilter(effectivePostTime(p), timeHours)) return;
|
|
if (!matchesSearch(p, searchQuery)) return;
|
|
|
|
if (clusterModeRef.current) {
|
|
refreshVisibleAndMarkers(timeHours);
|
|
return;
|
|
}
|
|
|
|
// add marker if missing
|
|
const id = getId(normalized);
|
|
const have = new Set((markersRef.current || []).map((x) => x.id));
|
|
if (id != null && !have.has(id)) {
|
|
createMarkerForPost(normalized, mapRef, markersRef, expandedElRef, theme);
|
|
}
|
|
|
|
setVisiblePosts((current) => [normalized, ...current]);
|
|
},
|
|
[mainFilter, subFilter, timeHours, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
|
|
);
|
|
|
|
const handlePostUpdate = useCallback(
|
|
(payload) => {
|
|
const postId = payload?.post_id || payload?.postId || payload?.id;
|
|
if (!postId) return;
|
|
|
|
const counts = payload?.counts || null;
|
|
const truth = payload?.truth || null;
|
|
const postPatch =
|
|
payload?.post ||
|
|
payload?.post_data ||
|
|
payload?.postData ||
|
|
payload?.data ||
|
|
null;
|
|
const truthScore = Number(
|
|
truth?.truth_score ??
|
|
truth?.TruthScore ??
|
|
payload?.truth_score ??
|
|
payload?.truthScore
|
|
);
|
|
|
|
const updated = [];
|
|
let changed = false;
|
|
for (const p of allPostsRef.current || []) {
|
|
if ((p?.id || p?.ID) !== postId) {
|
|
updated.push(p);
|
|
continue;
|
|
}
|
|
const next = normalizePostId({ ...p, ...(postPatch || {}) });
|
|
if (counts) {
|
|
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
|
|
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;
|
|
if (Number.isFinite(counts.share_count)) next.share_count = counts.share_count;
|
|
if (Number.isFinite(counts.comment_count)) next.comment_count = counts.comment_count;
|
|
}
|
|
if (Number.isFinite(truthScore)) next.truth_score = truthScore;
|
|
updated.push(next);
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
allPostsRef.current = updated;
|
|
refreshVisibleAndMarkers(timeHours, true);
|
|
}
|
|
|
|
try {
|
|
if (Number.isFinite(truthScore)) {
|
|
const markers = markersRef.current || [];
|
|
for (const item of markers) {
|
|
if (!item || item.id !== postId) continue;
|
|
const el = item.el;
|
|
if (!el) continue;
|
|
const marker = el.querySelector(".sw-truth-mini-marker");
|
|
const scoreEl = el.querySelector(".sw-truth-mini-score");
|
|
if (marker) marker.style.top = `${100 - truthScore}%`;
|
|
if (scoreEl) {
|
|
scoreEl.style.background = `hsl(${Math.round((truthScore / 100) * 120)} 80% 42%)`;
|
|
scoreEl.textContent = `${Math.round(truthScore)}`;
|
|
}
|
|
}
|
|
}
|
|
const map = mapRef.current;
|
|
const ov = map?.__swCenteredOverlay;
|
|
if (ov && ov.postId === postId && ov.modalWrapRef) {
|
|
const wrap = ov.modalWrapRef;
|
|
if (counts) {
|
|
const mapStat = (key, value) => {
|
|
if (!Number.isFinite(value)) return;
|
|
const el = wrap.querySelector(`[data-stat="${key}"] .sw-stat-num`);
|
|
if (el) el.textContent = String(value);
|
|
};
|
|
mapStat("views", counts.view_count);
|
|
mapStat("likes", counts.like_count);
|
|
mapStat("shares", counts.share_count);
|
|
mapStat("comments", counts.comment_count);
|
|
}
|
|
if (Number.isFinite(truthScore)) {
|
|
const marker = wrap.querySelector(".sw-truth-rail-marker");
|
|
const label = wrap.querySelector(".sw-truth-rail-score");
|
|
if (marker) marker.style.top = `${100 - truthScore}%`;
|
|
if (label) label.textContent = `${Math.round(truthScore)}`;
|
|
}
|
|
}
|
|
} catch {}
|
|
},
|
|
[mapRef, refreshVisibleAndMarkers, timeHours]
|
|
);
|
|
|
|
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate };
|
|
}
|