Auto deploy: 2025-12-09 04:27:52

This commit is contained in:
Your Name 2025-12-09 04:27:55 +00:00
parent ecf2e54778
commit b86efc525a
6 changed files with 317 additions and 301 deletions

View File

@ -1,165 +1,23 @@
import React, { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import "maplibregl/dist/maplibre-gl.css";
import "../../styles/maplibre.css"; // CSS locale copiée via bash
import { fetchPosts, fetchIpLocation, createPost } from "../../api/client";
/* ------------ CONSTANTES LOCALSTORAGE -------------- */
const LAST_POS_KEY = "sociowire:lastPosition"; // position utilisateur (GPS/IP)
const LAST_VIEW_KEY = "sociowire:lastView"; // dernière vue carte (centre+zoom)
/* ------------ UTILS GEOMETRIE -------------- */
// Haversine pour calculer un rayon approx (km)
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lat2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// centre + rayon (km) de la vue actuelle
function getViewFromMap(map) {
const bounds = map.getBounds();
const center = bounds.getCenter();
const ne = bounds.getNorthEast();
let radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
if (!Number.isFinite(radiusKm) || radiusKm <= 0) {
radiusKm = 250;
}
radiusKm = Math.min(Math.max(radiusKm, 10), 1000); // clamp 101000 km
return {
center: [center.lng, center.lat],
radiusKm,
};
}
// extraction [lng, lat] depuis un post
function getCoords(post) {
const lat =
post.lat ??
post.latitude ??
post.location?.lat ??
(Array.isArray(post.coords) ? post.coords[1] : null);
const lon =
post.lon ??
post.lng ??
post.location?.lng ??
(Array.isArray(post.coords) ? post.coords[0] : null);
if (typeof lat === "number" && typeof lon === "number") return [lon, lat];
return null;
}
/* ------------ UTILS CATEGORIES & TEMPS -------------- */
// Boutons de filtre principaux (droite)
const FILTER_MAIN_CATEGORIES = ["All", "News", "Friends", "Events", "Market"];
// pour le panneau du bas (sous-cats) AVEC "All"
const FILTER_CATEGORY_MAP = {
All: ["All"],
News: ["All", "World", "Politics", "Tech", "Finance", "Local"],
Friends: ["All", "Nearby", "Chats", "Groups", "Stories", "Favs"],
Events: ["All", "Today", "Weekend", "Music", "Sports", "Local"],
Market: ["All", "Actu", "Tech", "Finan", "Sport", "Deals"],
};
// pour NEW POST seulement (PAS de "All")
const CREATE_CATEGORY_MAP = {
News: ["World", "Politics", "Tech", "Finance", "Local"],
Friends: ["Nearby", "Chats", "Groups", "Stories", "Favs"],
Events: ["Today", "Weekend", "Music", "Sports", "Local"],
Market: ["Actu", "Tech", "Finan", "Sport", "Deals"],
};
// map bouton -> code backend
function categoryCode(mainFilter) {
switch (mainFilter) {
case "News":
return "NEWS";
case "Friends":
return "FRIENDS";
case "Events":
return "EVENT"; // backend: EVENT
case "Market":
return "MARKET";
case "All":
default:
return "";
}
}
// Transforme un post en petits textes pour les bulles
function extract(post) {
const title = post.title || post.text || post.body || "Post";
const body =
post.snippet ||
post.body ||
post.content ||
post.text ||
"No additional details.";
const short = title.length > 28 ? title.slice(0, 25) + "..." : title;
return { title, body, short };
}
// sous-catégorie: "All" = tout
function matchesSubFilter(post, subFilter) {
if (!subFilter || subFilter.toUpperCase() === "ALL" || subFilter === "All") {
return true;
}
const raw = post.sub_category ?? post.subCategory ?? "";
const sub = raw.toString().toLowerCase();
return sub === subFilter.toLowerCase();
}
// time filter local règles :
// Live/NOW = 10 minutes
// Today/TODAY = 24 heures
// Recently/RECENT = 4 heures
// Past/PAST = plus vieux que 24 h
function matchesTimeFilter(createdAt, timeFilter) {
if (!timeFilter) return true;
if (!createdAt) return true;
let d;
if (typeof createdAt === "string") {
if (createdAt.includes(" ")) {
d = new Date(createdAt.replace(" ", "T") + "Z");
} else {
d = new Date(createdAt);
}
} else {
d = new Date(createdAt);
}
if (Number.isNaN(d.getTime())) return true;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = diffMs / 60000;
const diffHours = diffMs / 3600000;
switch (timeFilter) {
case "NOW": // Live = 10 minutes
return diffMin <= 10;
case "TODAY": // 24h
return diffHours <= 24;
case "RECENT": // 4h
return diffHours <= 4;
case "PAST": // plus vieux que 24h
return diffHours > 24;
default:
return true;
}
}
import {
LAST_POS_KEY,
LAST_VIEW_KEY,
FILTER_MAIN_CATEGORIES,
FILTER_CATEGORY_MAP,
CREATE_CATEGORY_MAP,
} from "./mapConfig";
import { getViewFromMap } from "./mapGeo";
import {
categoryCode,
matchesSubFilter,
matchesTimeFilter,
} from "./mapFilter";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
/* =====================================================
COMPONENT
@ -167,11 +25,10 @@ function matchesTimeFilter(createdAt, timeFilter) {
export default function MapView() {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]); // [{ id, marker, el, compactHTML, expandedHTML }]
const markersRef = useRef([]); // [{ id, marker, el, ... }]
const expandedElRef = useRef(null);
// posts bruts (sans filtre temps) pour la zone + cat + sous-cat
const allPostsRef = useRef([]);
const allPostsRef = useRef([]); // posts bruts (sans filtre temps)
const [status, setStatus] = useState("Loading posts...");
@ -196,7 +53,7 @@ export default function MapView() {
const [draftSubCategory, setDraftSubCategory] = useState(
CREATE_CATEGORY_MAP.News[0]
);
const [draftCoords, setDraftCoords] = useState(null); // [lng, lat]
const [draftCoords, setDraftCoords] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
@ -206,7 +63,6 @@ export default function MapView() {
const map = new maplibregl.Map({
container: containerRef.current,
// Thème nuit bleu CARTO + villes
style: {
version: 8,
sources: {
@ -234,7 +90,7 @@ export default function MapView() {
antialias: true,
});
// si on a une dernière vue en localStorage, on la remet ici
// Reprise dernière vue
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
@ -250,9 +106,7 @@ export default function MapView() {
hadLastView = true;
}
}
} catch (_) {
// ignore
}
} catch (_) {}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
@ -264,7 +118,7 @@ export default function MapView() {
setViewParams(vp);
});
// à chaque fin de move/zoom :
// update viewParams à chaque move
map.on("moveend", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
@ -282,8 +136,7 @@ export default function MapView() {
});
return () => {
markersRef.current.forEach((m) => m.marker.remove());
markersRef.current = [];
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
@ -304,7 +157,6 @@ export default function MapView() {
localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon }));
} catch (_) {}
// si on N'A PAS de lastView, on utilise le geo pour centrer
if (!hasLastView) {
map.flyTo({
center: coords,
@ -316,7 +168,7 @@ export default function MapView() {
}
}
// 1) dernière position utilisateur (pas la vue)
// 1) dernière position user
try {
const raw = localStorage.getItem(LAST_POS_KEY);
if (raw) {
@ -372,111 +224,12 @@ export default function MapView() {
}
}, [mainFilter, bottomCategories, subFilter]);
/* ------------ CRÉATION D'UN MARKER POUR UN POST -------------- */
const createMarkerForPost = (post) => {
const map = mapRef.current;
if (!map) return;
const coords = getCoords(post);
if (!coords) return;
// évite les doublons (même id déjà affiché)
if (
typeof post.id === "number" &&
markersRef.current.some((m) => m.id === post.id)
) {
return;
}
const [lngP, latP] = coords;
if (
typeof lngP !== "number" ||
typeof latP !== "number" ||
Number.isNaN(lngP) ||
Number.isNaN(latP)
) {
return;
}
const { title, body, short } = extract(post);
const compactHTML = `
<div class="marker-inner">
<div class="marker-dot"></div>
<span class="marker-text">${short}</span>
</div>
`;
const expandedHTML = `
<div class="marker-expanded-inner">
<div class="marker-expanded-header">
<div class="marker-dot"></div>
<span class="marker-expanded-title">${title}</span>
</div>
<div class="marker-expanded-body">
${body}
</div>
</div>
`;
const el = document.createElement("div");
el.className = "post-marker post-marker-compact";
el.innerHTML = compactHTML;
const marker = new maplibregl.Marker({
element: el,
anchor: "bottom",
})
.setLngLat(coords)
.addTo(map);
const meta = {
id: typeof post.id === "number" ? post.id : null,
marker,
el,
compactHTML,
expandedHTML,
};
markersRef.current.push(meta);
const toggle = () => {
if (expandedElRef.current && expandedElRef.current !== el) {
const prev = markersRef.current.find(
(m) => m.el === expandedElRef.current
);
if (prev) {
prev.el.className = "post-marker post-marker-compact";
prev.el.innerHTML = prev.compactHTML;
}
}
if (expandedElRef.current === el) {
el.className = "post-marker post-marker-compact";
el.innerHTML = compactHTML;
expandedElRef.current = null;
} else {
el.className = "post-marker post-marker-expanded";
el.innerHTML = expandedHTML;
expandedElRef.current = el;
}
};
el.addEventListener("click", toggle);
el.addEventListener("touchend", (e) => {
e.preventDefault();
toggle();
});
};
/* ------------ RECONSTRUIRE LES MARKERS SELON LE TIME FILTER -------------- */
const rebuildMarkersForTimeFilter = (tf) => {
const map = mapRef.current;
if (!map) return;
// on enlève tous les markers
markersRef.current.forEach((m) => m.marker.remove());
markersRef.current = [];
expandedElRef.current = null;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
@ -485,7 +238,7 @@ export default function MapView() {
);
visible.forEach((post) => {
createMarkerForPost(post);
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
});
setStatus(visible.length ? "" : "No posts found.");
@ -512,7 +265,7 @@ export default function MapView() {
? subFilter
: "";
// on laisse "time" vide -> le backend renvoie tout pour cette zone/cat/sous-cat
// time="" -> backend renvoie TOUT pour zone + cat + sous-cat
const posts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
@ -526,7 +279,6 @@ export default function MapView() {
allPostsRef.current = Array.isArray(posts) ? posts : [];
// Puis on reconstruit les markers avec le timeFilter ACTUEL
rebuildMarkersForTimeFilter(timeFilter);
} catch (err) {
console.error("Erreur chargement posts:", err);
@ -572,7 +324,7 @@ export default function MapView() {
if (msg.type === "new_post" && msg.post) {
const p = msg.post;
// 1) catégorie
// Filtrage live: cat + sous-cat + temps + zone
const catCode = categoryCode(mainFilter);
if (
catCode &&
@ -582,28 +334,12 @@ export default function MapView() {
return;
}
// 2) sous-catégorie
if (!matchesSubFilter(p, subFilter)) return;
// 3) temps (10 min / 24h / 4h / >24h)
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) {
return;
}
// 4) zone actuelle
if (viewParams.center && viewParams.radiusKm) {
const coords = getCoords(p);
if (coords) {
const [lngP, latP] = coords;
const [lngC, latC] = viewParams.center;
const dKm = haversineKm(latC, lngC, latP, lngP);
if (dKm > viewParams.radiusKm * 1.2) {
return;
}
}
}
// 5) éviter doublon (dans la liste brute)
// évite doublon dans la liste
const id = typeof p.id === "number" ? p.id : null;
if (
id !== null &&
@ -612,15 +348,12 @@ export default function MapView() {
return;
}
// 6) ajouter à la liste brute
allPostsRef.current = [...allPostsRef.current, p];
// 7) et si il match le timeFilter courant, on l'ajoute visuellement
createMarkerForPost(p);
// et on lajoute visuellement
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
}
} catch (_) {
// ignore
}
} catch (_) {}
};
ws.onerror = (e) => {
@ -661,7 +394,6 @@ export default function MapView() {
setDraftTitle("");
setDraftBody("");
// pour NEW POST on n'a PAS de catégorie "All"
const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
@ -714,7 +446,6 @@ export default function MapView() {
await createPost(payload);
setIsSaving(false);
setIsCreating(false);
// le WS va pousser le nouveau post si il match les filtres
} catch (e) {
console.error("createPost error:", e);
setIsSaving(false);

View File

@ -0,0 +1,28 @@
export const LAST_POS_KEY = "sociowire:lastPosition"; // position GPS/IP user
export const LAST_VIEW_KEY = "sociowire:lastView"; // centre+zoom carte
// Boutons de filtre principaux (droite)
export const FILTER_MAIN_CATEGORIES = [
"All",
"News",
"Friends",
"Events",
"Market",
];
// Sous-catégories pour les filtres (UI du bas) AVEC "All"
export const FILTER_CATEGORY_MAP = {
All: ["All"],
News: ["All", "World", "Politics", "Tech", "Finance", "Local"],
Friends: ["All", "Nearby", "Chats", "Groups", "Stories", "Favs"],
Events: ["All", "Today", "Weekend", "Music", "Sports", "Local"],
Market: ["All", "Actu", "Tech", "Finan", "Sport", "Deals"],
};
// Sous-catégories pour la création de post SANS "All"
export const CREATE_CATEGORY_MAP = {
News: ["World", "Politics", "Tech", "Finance", "Local"],
Friends: ["Nearby", "Chats", "Groups", "Stories", "Favs"],
Events: ["Today", "Weekend", "Music", "Sports", "Local"],
Market: ["Actu", "Tech", "Finan", "Sport", "Deals"],
};

View File

@ -0,0 +1,99 @@
import { haversineKm } from "./mapGeo";
// map bouton -> code backend
export function categoryCode(mainFilter) {
switch (mainFilter) {
case "News":
return "NEWS";
case "Friends":
return "FRIENDS";
case "Events":
return "EVENT"; // backend: EVENT
case "Market":
return "MARKET";
case "All":
default:
return "";
}
}
// Transforme un post en petits textes pour les bulles
export function extractPostText(post) {
const title = post.title || post.text || post.body || "Post";
const body =
post.snippet ||
post.body ||
post.content ||
post.text ||
"No additional details.";
const short = title.length > 28 ? title.slice(0, 25) + "..." : title;
return { title, body, short };
}
// sous-catégorie: "All" = tout
export function matchesSubFilter(post, subFilter) {
if (!subFilter || subFilter.toUpperCase() === "ALL" || subFilter === "All") {
return true;
}
const raw = post.sub_category ?? post.subCategory ?? "";
const sub = raw.toString().toLowerCase();
return sub === subFilter.toLowerCase();
}
// time filter local règles :
// Live/NOW = 10 minutes
// Today/TODAY = 24 heures
// Recently/RECENT = 4 heures
// Past/PAST = plus vieux que 24 h
export function matchesTimeFilter(createdAt, timeFilter) {
if (!timeFilter) return true;
if (!createdAt) return true;
let d;
if (typeof createdAt === "string") {
if (createdAt.includes(" ")) {
d = new Date(createdAt.replace(" ", "T") + "Z");
} else {
d = new Date(createdAt);
}
} else {
d = new Date(createdAt);
}
if (Number.isNaN(d.getTime())) return true;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = diffMs / 60000;
const diffHours = diffMs / 3600000;
switch (timeFilter) {
case "NOW": // Live = 10 minutes
return diffMin <= 10;
case "TODAY": // 24h
return diffHours <= 24;
case "RECENT": // 4h
return diffHours <= 4;
case "PAST": // plus vieux que 24h
return diffHours > 24;
default:
return true;
}
}
// Vérifie si un post est dans le rayon courant
export function isInViewRadius(post, center, radiusKm) {
if (!center || !radiusKm) return true;
const [lngC, latC] = center;
const coords = post.coords || [post.lon ?? post.lng, post.lat ?? post.latitude];
const [lngP, latP] = coords;
if (
typeof lngP !== "number" ||
typeof latP !== "number" ||
Number.isNaN(lngP) ||
Number.isNaN(latP)
) {
return true;
}
const dKm = haversineKm(latC, lngC, latP, lngP);
return dKm <= radiusKm * 1.2;
}

View File

@ -0,0 +1,49 @@
// Haversine pour calculer un rayon approx (km)
export function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// centre + rayon (km) de la vue actuelle
export function getViewFromMap(map) {
const bounds = map.getBounds();
const center = bounds.getCenter();
const ne = bounds.getNorthEast();
let radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
if (!Number.isFinite(radiusKm) || radiusKm <= 0) {
radiusKm = 250;
}
radiusKm = Math.min(Math.max(radiusKm, 10), 1000); // clamp 101000 km
return {
center: [center.lng, center.lat],
radiusKm,
};
}
// extraction [lng, lat] depuis un post
export function getCoords(post) {
const lat =
post.lat ??
post.latitude ??
post.location?.lat ??
(Array.isArray(post.coords) ? post.coords[1] : null);
const lon =
post.lon ??
post.lng ??
post.location?.lng ??
(Array.isArray(post.coords) ? post.coords[0] : null);
if (typeof lat === "number" && typeof lon === "number") return [lon, lat];
return null;
}

View File

@ -0,0 +1,108 @@
import maplibregl from "maplibre-gl";
import { getCoords } from "./mapGeo";
import { extractPostText } from "./mapFilter";
// Supprime tous les markers actuels
export function clearAllMarkers(markersRef, expandedElRef) {
markersRef.current.forEach((m) => m.marker.remove());
markersRef.current = [];
if (expandedElRef) {
expandedElRef.current = null;
}
}
// Crée un marker pour un post
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
const map = mapRef.current;
if (!map) return;
const coords = getCoords(post);
if (!coords) return;
// évite les doublons (même id déjà affiché)
if (
typeof post.id === "number" &&
markersRef.current.some((m) => m.id === post.id)
) {
return;
}
const [lngP, latP] = coords;
if (
typeof lngP !== "number" ||
typeof latP !== "number" ||
Number.isNaN(lngP) ||
Number.isNaN(latP)
) {
return;
}
const { title, body, short } = extractPostText(post);
const compactHTML = `
<div class="marker-inner">
<div class="marker-dot"></div>
<span class="marker-text">${short}</span>
</div>
`;
const expandedHTML = `
<div class="marker-expanded-inner">
<div class="marker-expanded-header">
<div class="marker-dot"></div>
<span class="marker-expanded-title">${title}</span>
</div>
<div class="marker-expanded-body">
${body}
</div>
</div>
`;
const el = document.createElement("div");
el.className = "post-marker post-marker-compact";
el.innerHTML = compactHTML;
const marker = new maplibregl.Marker({
element: el,
anchor: "bottom",
})
.setLngLat(coords)
.addTo(map);
const meta = {
id: typeof post.id === "number" ? post.id : null,
marker,
el,
compactHTML,
expandedHTML,
};
markersRef.current.push(meta);
const toggle = () => {
if (expandedElRef.current && expandedElRef.current !== el) {
const prev = markersRef.current.find(
(m) => m.el === expandedElRef.current
);
if (prev) {
prev.el.className = "post-marker post-marker-compact";
prev.el.innerHTML = prev.compactHTML;
}
}
if (expandedElRef.current === el) {
el.className = "post-marker post-marker-compact";
el.innerHTML = compactHTML;
expandedElRef.current = null;
} else {
el.className = "post-marker post-marker-expanded";
el.innerHTML = expandedHTML;
expandedElRef.current = el;
}
};
el.addEventListener("click", toggle);
el.addEventListener("touchend", (e) => {
e.preventDefault();
toggle();
});
}

1
src/styles/maplibre.css Normal file

File diff suppressed because one or more lines are too long