sw-fe/src/components/Map/usePostsEngine.js

1392 lines
46 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch } from "../../api/client";
import { categoryCode, matchesSubFilter } 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]);
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]
);
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 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 (!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 (!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 };
}