diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 8b4cfcf..6a1800e 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -1,59 +1,59 @@ -import React, { useEffect, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; +import React, { useEffect, useState } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import "../../styles/mapMarkers.css"; import "../../styles/mapLayout.css"; -import { fetchPosts, fetchIpLocation, createPost } from "../../api/client"; - +import { createPost } from "../../api/client"; import { - LAST_POS_KEY, - LAST_VIEW_KEY, FILTER_MAIN_CATEGORIES, FILTER_CATEGORY_MAP, CREATE_CATEGORY_MAP, } from "./mapConfig"; -import { getViewFromMap, haversineKm } from "./mapGeo"; -import { - categoryCode, - matchesSubFilter, - matchesTimeFilter, -} from "./mapFilter"; -import { createMarkerForPost, clearAllMarkers } from "./markerManager"; +import { useMapCore } from "./useMapCore"; +import { useUserPosition } from "./useUserPosition"; +import { usePostsEngine } from "./usePostsEngine"; import PostList from "../Posts/PostList"; export default function MapView() { - const containerRef = useRef(null); - const mapRef = useRef(null); - const markersRef = useRef([]); // [{ id, marker, el, ... }] - const expandedElRef = useRef(null); + // MAP + refs + const { + containerRef, + mapRef, + markersRef, + expandedElRef, + viewParams, + hasLastView, + } = useMapCore(); - // posts bruts accumulés (tous ceux qu'on a déjà chargés) - const allPostsRef = useRef([]); - - // dernière zone fetchée - const lastFetchRef = useRef({ - center: null, // [lng, lat] - radiusKm: null, - filterKey: "", - }); - - const [status, setStatus] = useState("Loading posts..."); - - const [mainFilter, setMainFilter] = useState("All"); // All/News/Friends/Events/Market - const [timeFilter, setTimeFilter] = useState("RECENT"); // NOW/TODAY/RECENT/PAST + // FILTRES + const [mainFilter, setMainFilter] = useState("All"); + const [timeFilter, setTimeFilter] = useState("RECENT"); const [subFilter, setSubFilter] = useState("All"); - const [userPosition, setUserPosition] = useState(null); // [lng, lat] + // USER POSITION + const userPosition = useUserPosition(mapRef, hasLastView); - const [viewParams, setViewParams] = useState({ - center: null, - radiusKm: 250, + // POSTS ENGINE (markers + Sociowall) + const { + status, + visiblePosts, + loadingPosts, + loadError, + handleIncomingPost, + } = usePostsEngine({ + mapRef, + viewParams, + mainFilter, + subFilter, + timeFilter, + markersRef, + expandedElRef, }); - const [hasLastView, setHasLastView] = useState(false); + // SOCIOWALL selection + const [selectedPost, setSelectedPost] = useState(null); - // NEW POST + // NEW POST (overlay) const [isCreating, setIsCreating] = useState(false); const [draftTitle, setDraftTitle] = useState(""); const [draftBody, setDraftBody] = useState(""); @@ -65,177 +65,6 @@ export default function MapView() { const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(""); - // SOCIOWALL - const [visiblePosts, setVisiblePosts] = useState([]); - const [selectedPost, setSelectedPost] = useState(null); - const [loadingPosts, setLoadingPosts] = useState(false); - const [loadError, setLoadError] = useState(""); - - /* ================= INIT MAP ================= */ - - useEffect(() => { - if (!containerRef.current) return; - - const map = new maplibregl.Map({ - container: containerRef.current, - style: { - version: 8, - sources: { - "carto-dark": { - type: "raster", - tiles: [ - "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", - ], - tileSize: 256, - attribution: "© OpenStreetMap contributors © CARTO", - }, - }, - layers: [ - { - id: "carto-dark-layer", - type: "raster", - source: "carto-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 { - // ignore - } - setHasLastView(hadLastView); - - map.addControl(new maplibregl.NavigationControl(), "top-right"); - mapRef.current = map; - - 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 { - // ignore - } - }); - - return () => { - clearAllMarkers(markersRef, expandedElRef); - map.remove(); - mapRef.current = null; - }; - }, []); - - /* ================= POSITION UTILISATEUR (GPS / IP) ================= */ - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - let cancelled = false; - - function saveUserPos(lat, lon) { - if (cancelled) return; - const coords = [lon, lat]; - setUserPosition(coords); - try { - localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon })); - } catch { - // ignore - } - - if (!hasLastView) { - map.flyTo({ - center: coords, - zoom: 9, - speed: 1.2, - curve: 1.5, - essential: true, - }); - } - } - - // 1) dernière position user - try { - const raw = localStorage.getItem(LAST_POS_KEY); - if (raw) { - const p = JSON.parse(raw); - if (typeof p.lat === "number" && typeof p.lon === "number") { - setUserPosition([p.lon, p.lat]); - } - } - } catch { - // ignore - } - - // 2) GPS → 3) IP backend - function viaIpFallback() { - (async () => { - const ipCoords = await fetchIpLocation(); - if (!ipCoords || cancelled) return; - const [lon, lat] = ipCoords; - saveUserPos(lat, lon); - })(); - } - - if ("geolocation" in navigator) { - navigator.geolocation.getCurrentPosition( - (pos) => { - if (cancelled) return; - saveUserPos(pos.coords.latitude, pos.coords.longitude); - }, - () => { - if (cancelled) return; - viaIpFallback(); - }, - { - enableHighAccuracy: true, - timeout: 8000, - maximumAge: 30000, - } - ); - } else { - viaIpFallback(); - } - - return () => { - cancelled = true; - }; - }, [hasLastView]); - /* ================= FILTRES (bas) ================= */ const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"]; @@ -247,146 +76,6 @@ export default function MapView() { } }, [mainFilter, bottomCategories, subFilter]); - /* ================= MARKERS & FILTERS ================= */ - - // Applique TOUS les filtres (catégorie + sous-cat + temps) sur ce qu'on a déjà en mémoire. - const rebuildMarkersForFilters = (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."); - }; - - // ⚡ Dès qu'on change de catégorie ou sous-catégorie, - // on filtre immédiatement ce qui est déjà chargé - useEffect(() => { - rebuildMarkersForFilters(timeFilter); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mainFilter, subFilter]); - - /* ================= FETCH PAR ZONE (ACCUMULATIF) ================= */ - - 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 || 250; - 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); - - const minMoveKm = Math.max(100, radiusKm * 0.4); - if (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, - }; - - // Une fois le backend répondu, on met à jour les markers + Sociowall - 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; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewParams, mainFilter, subFilter]); - - // Quand on change le filtre temps, on réapplique directement - useEffect(() => { - rebuildMarkersForFilters(timeFilter); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeFilter]); - /* ================= WEBSOCKET ================= */ useEffect(() => { @@ -410,24 +99,7 @@ export default function MapView() { try { const msg = JSON.parse(ev.data); if (msg.type === "new_post" && msg.post) { - const p = msg.post; - - // On ajoute à la liste globale - allPostsRef.current = [...allPostsRef.current, p]; - - // Et si le nouveau post 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]); + handleIncomingPost(msg.post); } } catch { // ignore @@ -443,7 +115,7 @@ export default function MapView() { ws.close(); } }; - }, [mainFilter, subFilter, timeFilter]); + }, [handleIncomingPost]); /* ================= ACTIONS UI ================= */ diff --git a/src/components/Map/useMapCore.js b/src/components/Map/useMapCore.js new file mode 100644 index 0000000..9e426480 --- /dev/null +++ b/src/components/Map/useMapCore.js @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; +import maplibregl from "maplibre-gl"; +import { LAST_VIEW_KEY } from "./mapConfig"; +import { getViewFromMap } from "./mapGeo"; +import { clearAllMarkers } from "./markerManager"; + +export function useMapCore() { + const containerRef = useRef(null); + const mapRef = useRef(null); + + // pour les markers (utilisé aussi par usePostsEngine + MapView) + const markersRef = useRef([]); + const expandedElRef = useRef(null); + + const [viewParams, setViewParams] = useState({ + center: null, + radiusKm: 250, + }); + const [hasLastView, setHasLastView] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: { + version: 8, + sources: { + "carto-dark": { + type: "raster", + tiles: [ + "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + ], + tileSize: 256, + attribution: "© OpenStreetMap contributors © CARTO", + }, + }, + layers: [ + { + id: "carto-dark-layer", + type: "raster", + source: "carto-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 { + // ignore + } + setHasLastView(hadLastView); + + map.addControl(new maplibregl.NavigationControl(), "top-right"); + mapRef.current = map; + + 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 { + // ignore + } + }); + + return () => { + clearAllMarkers(markersRef, expandedElRef); + map.remove(); + mapRef.current = null; + }; + }, []); + + return { + containerRef, + mapRef, + markersRef, + expandedElRef, + viewParams, + hasLastView, + }; +} diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js new file mode 100644 index 0000000..985448d --- /dev/null +++ b/src/components/Map/usePostsEngine.js @@ -0,0 +1,195 @@ +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 + 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 || 250; + 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); + + const minMoveKm = Math.max(100, radiusKm * 0.4); + if (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, + }; +} diff --git a/src/components/Map/useUserPosition.js b/src/components/Map/useUserPosition.js new file mode 100644 index 0000000..5cff856 --- /dev/null +++ b/src/components/Map/useUserPosition.js @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { LAST_POS_KEY } from "./mapConfig"; +import { fetchIpLocation } from "../../api/client"; + +export function useUserPosition(mapRef, hasLastView) { + const [userPosition, setUserPosition] = useState(null); // [lng, lat] + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + let cancelled = false; + + function saveUserPos(lat, lon) { + if (cancelled) return; + const coords = [lon, lat]; + setUserPosition(coords); + try { + localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon })); + } catch { + // ignore + } + + if (!hasLastView) { + map.flyTo({ + center: coords, + zoom: 9, + speed: 1.2, + curve: 1.5, + essential: true, + }); + } + } + + // 1) dernière position user + try { + const raw = localStorage.getItem(LAST_POS_KEY); + if (raw) { + const p = JSON.parse(raw); + if (typeof p.lat === "number" && typeof p.lon === "number") { + setUserPosition([p.lon, p.lat]); + } + } + } catch { + // ignore + } + + // 2) GPS → 3) IP backend + function viaIpFallback() { + (async () => { + const ipCoords = await fetchIpLocation(); + if (!ipCoords || cancelled) return; + const [lon, lat] = ipCoords; + saveUserPos(lat, lon); + })(); + } + + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + if (cancelled) return; + saveUserPos(pos.coords.latitude, pos.coords.longitude); + }, + () => { + if (cancelled) return; + viaIpFallback(); + }, + { + enableHighAccuracy: true, + timeout: 8000, + maximumAge: 30000, + } + ); + } else { + viaIpFallback(); + } + + return () => { + cancelled = true; + }; + }, [mapRef, hasLastView]); + + return userPosition; +}