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, timeFilter, 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: "", timeFilter: "", 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}|${timeFilter}`; if (map) { map.__swForceFetchAt = Date.now(); setTimeout(() => { try { map.fire("moveend"); } catch {} }, 60); } }, [mainFilter, subFilter, timeFilter, 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] ); 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; const skipTimeFilter = lastFetchRef.current?.timeFilter === tf; if (!skipTimeFilter && !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) { v = filterByBounds(vAll, map); if (v.length > MAX_VISIBLE_POSTS) { 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 {} 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(timeFilter); }, [timeFilter, 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(timeFilter, true); }, [clusterFocus, timeFilter, refreshVisibleAndMarkers, clustersEnabled]); // On view change: re-filter markers/cards to current bounds even without fetch useEffect(() => { refreshVisibleAndMarkers(timeFilter); }, [viewParams, refreshVisibleAndMarkers, timeFilter]); // 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}|${timeFilter}`; 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.timeFilter === timeFilter) { 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_filter: timeFilter || 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 (timeFilter) normalized._swTimeFilter = timeFilter; 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, timeFilter, bounds }; if (pendingFilterRef.current === currentKey) { pendingFilterRef.current = ""; } // Smooth update: sync markers/clusters + wall without clearing everything refreshVisibleAndMarkers(timeFilter); const visible = computeVisible(timeFilter); 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_filter: timeFilter || "", }); } if ( !autoWidenRef.current && timeFilter && timeFilter !== "PAST" && Array.isArray(newPosts) && newPosts.length > 0 && visible.length === 0 ) { autoWidenRef.current = true; if (typeof onAutoWidenTimeFilter === "function") { onAutoWidenTimeFilter("PAST"); } } setLoadingPosts(false); } catch (err) { setLoadingPosts(false); setLoadError("fetch failed"); 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, timeFilter, 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(timeFilter); trackEvent("search_filter_apply", { query_len: q.length, results: posts.length, context: "map", }); } catch {} } runSearch(); return () => { cancelled = true; }; }, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]); 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(timeFilter)); const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT); updateClusterData(map, clusterRemainder); }; map.on("styledata", handleStyle); return () => map.off("styledata", handleStyle); }, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, 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), timeFilter)) return; if (!matchesSearch(p, searchQuery)) return; if (clusterModeRef.current) { refreshVisibleAndMarkers(timeFilter); 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, timeFilter, 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(timeFilter, 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, timeFilter] ); return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate }; }