fix load map

This commit is contained in:
Your Name 2025-12-13 14:32:03 -05:00
parent bb7414a793
commit caeaea97e8
6 changed files with 874 additions and 74 deletions

427
fix.sh Normal file
View File

@ -0,0 +1,427 @@
#!/usr/bin/env bash
set -euo pipefail
# Run from frontend repo root
# (the folder that contains src/)
test -d "src" || { echo "❌ Run this from the FRONTEND repo root (where ./src exists)"; exit 1; }
TS="$(date +%Y%m%d-%H%M%S)"
echo "✅ Patching SocioWire frontend (timestamp: $TS)"
# backup
for f in \
src/components/Map/useMapCore.js \
src/components/Map/usePostsEngine.js
do
if [ -f "$f" ]; then
cp -a "$f" "$f.bak.$TS"
echo "📦 Backup: $f -> $f.bak.$TS"
else
echo "⚠️ Missing file: $f (will be created)"
fi
done
# -----------------------------
# FILE: src/components/Map/useMapCore.js
# -----------------------------
mkdir -p src/components/Map
cat > src/components/Map/useMapCore.js <<'EOF'
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import { LAST_VIEW_KEY } from "./mapConfig";
import { getViewFromMap } from "./mapGeo";
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
// MapLibre base style by theme
function getBaseStyle(theme) {
const t = theme || "dark";
let tiles;
switch (t) {
case "light":
tiles = ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"];
break;
case "blue":
tiles = ["https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"];
break;
case "dark":
default:
tiles = ["https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"];
break;
}
return {
version: 8,
sources: {
"carto-base": {
type: "raster",
tiles,
tileSize: 256,
attribution: "© OpenStreetMap contributors © CARTO",
},
},
layers: [
{
id: "carto-base-layer",
type: "raster",
source: "carto-base",
},
],
};
}
export function useMapCore(theme) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const expandedElRef = useRef(null);
// default large radius
const [viewParams, setViewParams] = useState({
center: null,
radiusKm: 750,
});
const [hasLastView, setHasLastView] = useState(false);
function closeExpandedIfAny() {
const el = expandedElRef.current;
if (el && el.__renderCompact) el.__renderCompact();
expandedElRef.current = null;
}
function safeUpdateViewParams(map) {
try {
const vp = getViewFromMap(map);
if (vp && Array.isArray(vp.center) && vp.center.length === 2) {
setViewParams(vp);
}
} catch {
// ignore
}
}
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: getBaseStyle(theme || "dark"),
center: [-95, 40],
zoom: 3.5,
pitch: 0,
bearing: 0,
antialias: true,
});
// Restore last view if available
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
if (raw) {
const v = JSON.parse(raw);
if (
typeof v.lat === "number" &&
typeof v.lon === "number" &&
typeof v.zoom === "number"
) {
map.setCenter([v.lon, v.lat]);
map.setZoom(v.zoom);
hadLastView = true;
}
}
} catch {}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
// Re-occlude markers when post expanded
const reOcclude = () => {
const el = expandedElRef.current;
if (el) applyOcclusionForExpanded(markersRef, el);
};
map.on("move", reOcclude);
map.on("zoom", reOcclude);
// Close expanded when clicking/dragging map
map.on("click", () => closeExpandedIfAny());
map.on("dragstart", () => closeExpandedIfAny());
// ✅ CRITICAL FIX:
// Set view params ASAP and also on load/idle/moveend.
// On some devices/prod, relying on 'load' only can miss initial fetch triggers.
const update = () => safeUpdateViewParams(map);
map.on("load", update);
map.on("idle", update);
map.on("moveend", () => {
update();
try {
const vp = getViewFromMap(map);
const center = vp.center;
localStorage.setItem(
LAST_VIEW_KEY,
JSON.stringify({
lat: center[1],
lon: center[0],
zoom: map.getZoom(),
})
);
} catch {}
});
// Also do an early update next frame (helps first load)
requestAnimationFrame(update);
return () => {
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Theme style switch
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// setStyle triggers style reload; we still want viewParams updated after
map.setStyle(getBaseStyle(theme || "dark"));
// update view params after style swap (next frame)
requestAnimationFrame(() => {
try {
safeUpdateViewParams(map);
} catch {}
});
}, [theme]);
return {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
};
}
EOF
# -----------------------------
# FILE: src/components/Map/usePostsEngine.js
# -----------------------------
cat > src/components/Map/usePostsEngine.js <<'EOF'
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client"; // keep existing endpoint for now
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
/**
* Posts engine:
* - keeps a cache (allPostsRef)
* - applies filters (cat/sub/time) for map + Sociowall
* - fetches by zone
*
* ✅ FIX: first page load must fetch even if viewParams.center is null.
* We fallback to map.getCenter() and default radius 750.
*/
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({
center: null,
radiusKm: null,
filterKey: "",
});
const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
const rebuildMarkersForFilters = useCallback(
(tf) => {
const map = mapRef.current;
if (!map) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true;
});
visible.forEach((post) => {
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
});
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
},
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
);
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
async function load() {
// ✅ fallback center/radius if viewParams not ready yet
const centerArr =
(viewParams && Array.isArray(viewParams.center) && viewParams.center.length === 2)
? viewParams.center
: (() => {
const c = map.getCenter();
return [c.lng, c.lat];
})();
const radiusKm =
(viewParams && typeof viewParams.radiusKm === "number" && viewParams.radiusKm > 0)
? viewParams.radiusKm
: 750;
const [lng, lat] = centerArr;
const filterKey = `${mainFilter}|${subFilter}|${timeFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
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.25;
const minMoveKm = Math.max(50, radiusKm * 0.25);
if (!radiusChanged && distKm < minMoveKm) {
return;
}
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
? subFilter
: "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
byId.set(p.id, p);
}
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = {
center: [...centerArr],
radiusKm,
filterKey,
};
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("Error loading posts.");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [viewParams, mainFilter, subFilter, timeFilter, mapRef, rebuildMarkersForFilters, markersRef, expandedElRef]);
const handleIncomingPost = useCallback(
(p) => {
allPostsRef.current = [...allPostsRef.current, p];
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(p.created_at || p.CreatedAt, timeFilter)) return;
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
);
return {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
};
}
EOF
echo "✅ Files written."
echo ""
echo "Next:"
echo " npm run dev"
echo "or build/deploy as you do normally."
echo ""
echo "Tip: open DevTools Network -> you should see /api/posts on first page load."

10
package-lock.json generated
View File

@ -58,7 +58,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -1075,7 +1074,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1126,7 +1124,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1249,7 +1246,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -1551,7 +1547,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -2772,7 +2767,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2858,7 +2852,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -2868,7 +2861,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3171,7 +3163,6 @@
"integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/runtime": "0.97.0", "@oxc-project/runtime": "0.97.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -3294,7 +3285,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -4,7 +4,7 @@ import { LAST_VIEW_KEY } from "./mapConfig";
import { getViewFromMap } from "./mapGeo"; import { getViewFromMap } from "./mapGeo";
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager"; import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
// renvoie le style MapLibre en fonction du thème // MapLibre base style by theme
function getBaseStyle(theme) { function getBaseStyle(theme) {
const t = theme || "dark"; const t = theme || "dark";
let tiles; let tiles;
@ -49,21 +49,31 @@ export function useMapCore(theme) {
const markersRef = useRef([]); const markersRef = useRef([]);
const expandedElRef = useRef(null); const expandedElRef = useRef(null);
// default plus large // default large radius
const [viewParams, setViewParams] = useState({ const [viewParams, setViewParams] = useState({
center: null, center: null,
radiusKm: 750, radiusKm: 750,
}); });
const [hasLastView, setHasLastView] = useState(false); const [hasLastView, setHasLastView] = useState(false);
function closeExpandedIfAny() { function closeExpandedIfAny() {
const el = expandedElRef.current; const el = expandedElRef.current;
if (el && el.__setExpanded) { if (el && el.__renderCompact) el.__renderCompact();
el.__setExpanded(false);
}
expandedElRef.current = null; expandedElRef.current = null;
} }
function safeUpdateViewParams(map) {
try {
const vp = getViewFromMap(map);
if (vp && Array.isArray(vp.center) && vp.center.length === 2) {
setViewParams(vp);
}
} catch {
// ignore
}
}
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@ -77,7 +87,7 @@ export function useMapCore(theme) {
antialias: true, antialias: true,
}); });
// Reprise dernière vue // Restore last view if available
let hadLastView = false; let hadLastView = false;
try { try {
const raw = localStorage.getItem(LAST_VIEW_KEY); const raw = localStorage.getItem(LAST_VIEW_KEY);
@ -99,7 +109,7 @@ export function useMapCore(theme) {
map.addControl(new maplibregl.NavigationControl(), "top-right"); map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map; mapRef.current = map;
// If a post is expanded, re-apply occlusion on zoom/move (only hides overlapping ones) // Re-occlude markers when post expanded
const reOcclude = () => { const reOcclude = () => {
const el = expandedElRef.current; const el = expandedElRef.current;
if (el) applyOcclusionForExpanded(markersRef, el); if (el) applyOcclusionForExpanded(markersRef, el);
@ -107,24 +117,21 @@ export function useMapCore(theme) {
map.on("move", reOcclude); map.on("move", reOcclude);
map.on("zoom", reOcclude); map.on("zoom", reOcclude);
// Close expanded post when clicking on the map (outside markers) // Close expanded when clicking/dragging map
map.on("click", () => { map.on("click", () => closeExpandedIfAny());
const el = expandedElRef.current; map.on("dragstart", () => closeExpandedIfAny());
if (el && el.__renderCompact) {
el.__renderCompact();
expandedElRef.current = null;
}
});
map.on("load", () => { // ✅ CRITICAL FIX:
const vp = getViewFromMap(map); // Set view params ASAP and also on load/idle/moveend.
setViewParams(vp); // On some devices/prod, relying on 'load' only can miss initial fetch triggers.
}); const update = () => safeUpdateViewParams(map);
map.on("load", update);
map.on("idle", update);
map.on("moveend", () => { map.on("moveend", () => {
const vp = getViewFromMap(map); update();
setViewParams(vp);
try { try {
const vp = getViewFromMap(map);
const center = vp.center; const center = vp.center;
localStorage.setItem( localStorage.setItem(
LAST_VIEW_KEY, LAST_VIEW_KEY,
@ -137,9 +144,8 @@ export function useMapCore(theme) {
} catch {} } catch {}
}); });
// IMPORTANT: click/drag sur la map => fermer le gros post // Also do an early update next frame (helps first load)
map.on("click", () => closeExpandedIfAny()); requestAnimationFrame(update);
map.on("dragstart", () => closeExpandedIfAny());
return () => { return () => {
clearAllMarkers(markersRef, expandedElRef); clearAllMarkers(markersRef, expandedElRef);
@ -149,10 +155,20 @@ export function useMapCore(theme) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Theme style switch
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
// setStyle triggers style reload; we still want viewParams updated after
map.setStyle(getBaseStyle(theme || "dark")); map.setStyle(getBaseStyle(theme || "dark"));
// update view params after style swap (next frame)
requestAnimationFrame(() => {
try {
safeUpdateViewParams(map);
} catch {}
});
}, [theme]); }, [theme]);
return { return {

View File

@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import { LAST_VIEW_KEY } from "./mapConfig";
import { getViewFromMap } from "./mapGeo";
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
// renvoie le style MapLibre en fonction du thème
function getBaseStyle(theme) {
const t = theme || "dark";
let tiles;
switch (t) {
case "light":
tiles = ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"];
break;
case "blue":
tiles = ["https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"];
break;
case "dark":
default:
tiles = ["https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"];
break;
}
return {
version: 8,
sources: {
"carto-base": {
type: "raster",
tiles,
tileSize: 256,
attribution: "© OpenStreetMap contributors © CARTO",
},
},
layers: [
{
id: "carto-base-layer",
type: "raster",
source: "carto-base",
},
],
};
}
export function useMapCore(theme) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const expandedElRef = useRef(null);
// ✅ default plus large
const [viewParams, setViewParams] = useState({
center: null,
radiusKm: 750,
});
const [hasLastView, setHasLastView] = useState(false);
function closeExpandedIfAny() {
const el = expandedElRef.current;
if (el && el.__setExpanded) {
el.__setExpanded(false);
}
expandedElRef.current = null;
}
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: getBaseStyle(theme || "dark"),
center: [-95, 40],
zoom: 3.5,
pitch: 0,
bearing: 0,
antialias: true,
});
// Reprise dernière vue
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
if (raw) {
const v = JSON.parse(raw);
if (
typeof v.lat === "number" &&
typeof v.lon === "number" &&
typeof v.zoom === "number"
) {
map.setCenter([v.lon, v.lat]);
map.setZoom(v.zoom);
hadLastView = true;
}
}
} catch {}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
// If a post is expanded, re-apply occlusion on zoom/move (only hides overlapping ones)
const reOcclude = () => {
const el = expandedElRef.current;
if (el) applyOcclusionForExpanded(markersRef, el);
};
map.on("move", reOcclude);
map.on("zoom", reOcclude);
// Close expanded post when clicking on the map (outside markers)
map.on("click", () => {
const el = expandedElRef.current;
if (el && el.__renderCompact) {
el.__renderCompact();
expandedElRef.current = null;
}
});
map.on("load", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
});
map.on("moveend", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
try {
const center = vp.center;
localStorage.setItem(
LAST_VIEW_KEY,
JSON.stringify({
lat: center[1],
lon: center[0],
zoom: map.getZoom(),
})
);
} catch {}
});
// IMPORTANT: click/drag sur la map => fermer le gros post
map.on("click", () => closeExpandedIfAny());
map.on("dragstart", () => closeExpandedIfAny());
return () => {
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
map.setStyle(getBaseStyle(theme || "dark"));
}, [theme]);
return {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
};
}

View File

@ -1,14 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client"; import { fetchPosts } from "../../api/client"; // keep existing endpoint for now
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter"; import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo"; import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager"; import { createMarkerForPost, clearAllMarkers } from "./markerManager";
/** /**
* Gère : * Posts engine:
* - posts en mémoire (allPostsRef) * - keeps a cache (allPostsRef)
* - filtres (cat / sous-cat / temps) appliqués sur la map + Sociowall * - applies filters (cat/sub/time) for map + Sociowall
* - fetch par zone (backend) * - fetches by zone
*
* FIX: first page load must fetch even if viewParams.center is null.
* We fallback to map.getCenter() and default radius 750.
*/ */
export function usePostsEngine({ export function usePostsEngine({
mapRef, mapRef,
@ -20,6 +23,7 @@ export function usePostsEngine({
expandedElRef, expandedElRef,
}) { }) {
const allPostsRef = useRef([]); const allPostsRef = useRef([]);
const lastFetchRef = useRef({ const lastFetchRef = useRef({
center: null, center: null,
radiusKm: null, radiusKm: null,
@ -31,7 +35,6 @@ export function usePostsEngine({
const [loadingPosts, setLoadingPosts] = useState(false); const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
// Applique tous les filtres sur ce qu'on a déjà
const rebuildMarkersForFilters = useCallback( const rebuildMarkersForFilters = useCallback(
(tf) => { (tf) => {
const map = mapRef.current; const map = mapRef.current;
@ -43,14 +46,11 @@ export function usePostsEngine({
const catCode = categoryCode(mainFilter); const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => { const visible = posts.filter((p) => {
// catégorie
if (catCode) { if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase(); const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false; if (pc !== catCode) return false;
} }
// sous-catégorie
if (!matchesSubFilter(p, subFilter)) return false; if (!matchesSubFilter(p, subFilter)) return false;
// temps
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false; if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true; return true;
}); });
@ -65,39 +65,46 @@ export function usePostsEngine({
[mapRef, markersRef, expandedElRef, mainFilter, subFilter] [mapRef, markersRef, expandedElRef, mainFilter, subFilter]
); );
// Changement de cat / sous-cat / temps → filtre instantané
useEffect(() => { useEffect(() => {
rebuildMarkersForFilters(timeFilter); rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]); }, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
// Fetch backend quand la vue bouge assez (OU quand le zoom change beaucoup le radius)
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
if (!viewParams.center) return;
let cancelled = false; let cancelled = false;
async function load() { async function load() {
const [lng, lat] = viewParams.center; // ✅ fallback center/radius if viewParams not ready yet
const radiusKm = viewParams.radiusKm || 750; const centerArr =
const filterKey = `${mainFilter}|${subFilter}`; (viewParams && Array.isArray(viewParams.center) && viewParams.center.length === 2)
? viewParams.center
: (() => {
const c = map.getCenter();
return [c.lng, c.lat];
})();
const radiusKm =
(viewParams && typeof viewParams.radiusKm === "number" && viewParams.radiusKm > 0)
? viewParams.radiusKm
: 750;
const [lng, lat] = centerArr;
const filterKey = `${mainFilter}|${subFilter}|${timeFilter}`;
const last = lastFetchRef.current; const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) { if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center; const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng); const distKm = haversineKm(lastLat, lastLng, lat, lng);
// ✅ refetch si le radius change pas mal (zoom in/out)
const lastR = last.radiusKm || 0; const lastR = last.radiusKm || 0;
const radiusChanged = const radiusChanged =
!lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25; // 25% !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
// move threshold moins agressif
const minMoveKm = Math.max(50, radiusKm * 0.25); const minMoveKm = Math.max(50, radiusKm * 0.25);
// Si ni zoom ni move significatif => skip
if (!radiusChanged && distKm < minMoveKm) { if (!radiusChanged && distKm < minMoveKm) {
return; return;
} }
@ -129,11 +136,8 @@ export function usePostsEngine({
const byId = new Map(); const byId = new Map();
for (const p of existing) { for (const p of existing) {
if (p && typeof p.id !== "undefined") { if (p && typeof p.id !== "undefined") byId.set(p.id, p);
byId.set(p.id, p);
}
} }
if (Array.isArray(newPosts)) { if (Array.isArray(newPosts)) {
for (const p of newPosts) { for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) { if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
@ -145,12 +149,11 @@ export function usePostsEngine({
allPostsRef.current = Array.from(byId.values()); allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { lastFetchRef.current = {
center: [...viewParams.center], center: [...centerArr],
radiusKm, radiusKm,
filterKey, filterKey,
}; };
// Après réponse du backend → on ré-applique les filtres
rebuildMarkersForFilters(timeFilter); rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false); setLoadingPosts(false);
} catch (err) { } catch (err) {
@ -168,31 +171,19 @@ export function usePostsEngine({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [ }, [viewParams, mainFilter, subFilter, timeFilter, mapRef, rebuildMarkersForFilters, markersRef, expandedElRef]);
viewParams,
mainFilter,
subFilter,
mapRef,
rebuildMarkersForFilters,
timeFilter,
]);
// Nouveau post reçu via WebSocket
const handleIncomingPost = useCallback( const handleIncomingPost = useCallback(
(p) => { (p) => {
// ajoute au cache global
allPostsRef.current = [...allPostsRef.current, p]; allPostsRef.current = [...allPostsRef.current, p];
// si ça matche les filtres courants → on l'affiche
const catCode = categoryCode(mainFilter); const catCode = categoryCode(mainFilter);
if (catCode) { if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase(); const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return; if (pc !== catCode) return;
} }
if (!matchesSubFilter(p, subFilter)) return; if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) { if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return;
return;
}
createMarkerForPost(p, mapRef, markersRef, expandedElRef); createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]); setVisiblePosts((current) => [...current, p]);

View File

@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
/**
* Gère :
* - posts en mémoire (allPostsRef)
* - filtres (cat / sous-cat / temps) appliqués sur la map + Sociowall
* - fetch par zone (backend)
*/
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({
center: null,
radiusKm: null,
filterKey: "",
});
const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
// Applique tous les filtres sur ce qu'on a déjà
const rebuildMarkersForFilters = useCallback(
(tf) => {
const map = mapRef.current;
if (!map) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
// catégorie
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
// sous-catégorie
if (!matchesSubFilter(p, subFilter)) return false;
// temps
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true;
});
visible.forEach((post) => {
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
});
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
},
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
);
// Changement de cat / sous-cat / temps → filtre instantané
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
// Fetch backend quand la vue bouge assez (OU quand le zoom change beaucoup le radius)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng);
// ✅ refetch si le radius change pas mal (zoom in/out)
const lastR = last.radiusKm || 0;
const radiusChanged =
!lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25; // 25%
// move threshold moins agressif
const minMoveKm = Math.max(50, radiusKm * 0.25);
// Si ni zoom ni move significatif => skip
if (!radiusChanged && distKm < minMoveKm) {
return;
}
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
? subFilter
: "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") {
byId.set(p.id, p);
}
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
byId.set(p.id, p);
}
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = {
center: [...viewParams.center],
radiusKm,
filterKey,
};
// Après réponse du backend → on ré-applique les filtres
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("Error loading posts.");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [
viewParams,
mainFilter,
subFilter,
mapRef,
rebuildMarkersForFilters,
timeFilter,
]);
// Nouveau post reçu via WebSocket
const handleIncomingPost = useCallback(
(p) => {
// ajoute au cache global
allPostsRef.current = [...allPostsRef.current, p];
// si ça matche les filtres courants → on l'affiche
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(p.created_at || p.CreatedAt, timeFilter)) {
return;
}
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
);
return {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
};
}