diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js
index 0c53710..87bc39b 100644
--- a/src/components/Map/markerManager.js
+++ b/src/components/Map/markerManager.js
@@ -7,6 +7,7 @@ const SW_ANIM_MS = 840;
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
+import { getMarkerColorForCategory } from "./postPriority";
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
@@ -440,3 +441,145 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
if (expandedElRef) expandedElRef.current = null;
});
}
+
+/**
+ * Crée un marqueur simple (juste icône circulaire) pour les posts secondaires
+ * S'ouvre en modal au clic, affiche mini-carte au hover
+ */
+export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
+ const map = mapRef.current;
+ if (!map) return;
+
+ const lat =
+ typeof post.lat === "number"
+ ? post.lat
+ : typeof post.latitude === "number"
+ ? post.latitude
+ : null;
+
+ const lon =
+ typeof post.lon === "number"
+ ? post.lon
+ : typeof post.lng === "number"
+ ? post.lng
+ : null;
+
+ if (lat == null || lon == null) return;
+
+ const lngLat = [lon, lat];
+
+ // Pas d'offset pour les marqueurs simples (ils sont petits)
+ const color = getMarkerColorForCategory(post?.category);
+ const img = post?.image_small || post?.image || post?.image_large || post?.media_url || "";
+
+ const root = document.createElement("div");
+ root.classList.add("sw-appear");
+ requestAnimationFrame(() => root.classList.add("sw-appear-in"));
+ root.className = "post-pin--simple sw-appear sw-appear-in";
+ root.style.position = "relative";
+ root.style.cursor = "pointer";
+
+ // Marqueur circulaire simple
+ root.innerHTML = `
+
+ ${img ? `

` : `
+
+ `}
+
+
+
+ ${escapeHtml(post?.title || "Untitled")}
+
+
+ ${escapeHtml((post?.snippet || "").substring(0, 80))}${(post?.snippet || "").length > 80 ? "..." : ""}
+
+
+ ${normCatLabel(post)} • ${formatRelativeTime(post?.created_at || post?.CreatedAt)}
+
+
+ `;
+
+ const markerEl = root.querySelector(".sw-simple-marker");
+ const hoverCard = root.querySelector(".sw-simple-hover-card");
+
+ // Hover effects
+ root.addEventListener("mouseenter", () => {
+ if (markerEl) {
+ markerEl.style.transform = "scale(1.2)";
+ markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)";
+ }
+ if (hoverCard) {
+ hoverCard.style.opacity = "1";
+ hoverCard.style.transform = "translateX(-50%) scale(1)";
+ }
+ });
+
+ root.addEventListener("mouseleave", () => {
+ if (markerEl) {
+ markerEl.style.transform = "scale(1)";
+ markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
+ }
+ if (hoverCard) {
+ hoverCard.style.opacity = "0";
+ hoverCard.style.transform = "translateX(-50%) scale(0.9)";
+ }
+ });
+
+ // Click ouvre le modal
+ root.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+
+ const existing = map.__swCenteredOverlay;
+ if (existing && existing.postId === post?.id) {
+ closeCenteredOverlay(map);
+ return;
+ }
+
+ openCenteredOverlay({ map, post, theme });
+ if (expandedElRef) expandedElRef.current = null;
+ });
+
+ const marker = new maplibregl.Marker({
+ element: root,
+ anchor: "center",
+ })
+ .setLngLat(lngLat)
+ .addTo(map);
+
+ markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" });
+}
diff --git a/src/components/Map/postPriority.js b/src/components/Map/postPriority.js
new file mode 100644
index 0000000..4bce58e
--- /dev/null
+++ b/src/components/Map/postPriority.js
@@ -0,0 +1,211 @@
+/**
+ * postPriority.js
+ *
+ * Système de priorité et clustering pour afficher intelligemment les posts:
+ * - Posts importants => cartes complètes (mini)
+ * - Posts secondaires => marqueurs simples (icône + hover)
+ */
+
+/**
+ * Calcule le score de priorité d'un post
+ * Score plus élevé = plus important
+ */
+export function calculatePostPriority(post, viewCenter, allPosts) {
+ let score = 0;
+
+ // 1. Score intrinsèque du post (si disponible depuis reco-service)
+ const recoScore = typeof post.score === 'number' ? post.score : 50;
+ score += recoScore * 0.4; // Weight: 40%
+
+ // 2. Catégorie (certaines plus importantes)
+ const category = (post?.category || '').toString().toUpperCase();
+ const categoryBonus = {
+ 'EVENT': 20,
+ 'EVENTS': 20,
+ 'NEWS': 15,
+ 'MARKET': 10,
+ 'FRIENDS': 5,
+ };
+ score += categoryBonus[category] || 10; // Weight: 10-20
+
+ // 3. Fraîcheur (posts récents prioritaires)
+ const createdAt = post?.created_at || post?.CreatedAt || post?.createdAt;
+ if (createdAt) {
+ const ageHours = (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60);
+ if (ageHours < 1) score += 25;
+ else if (ageHours < 6) score += 20;
+ else if (ageHours < 24) score += 15;
+ else if (ageHours < 72) score += 10;
+ else score += 5;
+ }
+
+ // 4. Distance par rapport au centre de la vue
+ if (viewCenter) {
+ const lat = typeof post.lat === 'number' ? post.lat : post.latitude;
+ const lon = typeof post.lon === 'number' ? post.lon : post.lng;
+
+ if (lat != null && lon != null) {
+ const dx = viewCenter.lng - lon;
+ const dy = viewCenter.lat - lat;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ // Plus proche du centre = meilleur score
+ const maxDist = 0.1; // ~10km
+ const distScore = Math.max(0, 20 * (1 - dist / maxDist));
+ score += distScore; // Weight: 0-20
+ }
+ }
+
+ // 5. Engagement (si disponible)
+ const views = post?.views || post?.view_count || 0;
+ const likes = post?.likes || post?.like_count || 0;
+ const comments = post?.comments || post?.comment_count || 0;
+ const engagementScore = Math.min(20, (views / 100) + (likes * 2) + (comments * 5));
+ score += engagementScore; // Weight: 0-20
+
+ return Math.round(score);
+}
+
+/**
+ * Détecte les clusters de posts proches
+ * Retourne Map
+ */
+export function clusterPosts(posts, clusterRadius = 0.001) {
+ const clusters = new Map();
+ const processed = new Set();
+ let clusterId = 0;
+
+ for (const post of posts) {
+ if (processed.has(post.id)) continue;
+
+ const lat = typeof post.lat === 'number' ? post.lat : post.latitude;
+ const lon = typeof post.lon === 'number' ? post.lon : post.lng;
+
+ if (lat == null || lon == null) continue;
+
+ // Trouve tous les posts dans le rayon
+ const cluster = [post];
+ processed.add(post.id);
+
+ for (const other of posts) {
+ if (processed.has(other.id)) continue;
+
+ const oLat = typeof other.lat === 'number' ? other.lat : other.latitude;
+ const oLon = typeof other.lon === 'number' ? other.lon : other.lng;
+
+ if (oLat == null || oLon == null) continue;
+
+ const dx = lat - oLat;
+ const dy = lon - oLon;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < clusterRadius) {
+ cluster.push(other);
+ processed.add(other.id);
+ }
+ }
+
+ clusters.set(clusterId++, cluster);
+ }
+
+ return clusters;
+}
+
+/**
+ * Décide quels posts afficher comme cartes complètes vs marqueurs simples
+ * Retourne { fullCardPosts: [], simpleMarkerPosts: [] }
+ */
+export function prioritizePosts(posts, viewCenter, options = {}) {
+ const {
+ maxFullCards = 15, // Max de cartes complètes à afficher
+ clusterRadius = 0.001, // Rayon de clustering (~100m)
+ minScoreForCard = 40, // Score minimum pour carte complète
+ } = options;
+
+ // 1. Calculer les scores
+ const postsWithScores = posts.map(post => ({
+ post,
+ priority: calculatePostPriority(post, viewCenter, posts),
+ }));
+
+ // 2. Trier par priorité descendante
+ postsWithScores.sort((a, b) => b.priority - a.priority);
+
+ // 3. Détecter les clusters
+ const clusters = clusterPosts(posts, clusterRadius);
+
+ // 4. Sélectionner les posts pour cartes complètes
+ const fullCardPosts = [];
+ const simpleMarkerPosts = [];
+ const clusterRepresentatives = new Set();
+
+ for (const [clusterId, clusterPosts] of clusters.entries()) {
+ if (clusterPosts.length === 1) {
+ // Pas de cluster, décision simple basée sur le score
+ const item = postsWithScores.find(p => p.post.id === clusterPosts[0].id);
+ if (item && item.priority >= minScoreForCard && fullCardPosts.length < maxFullCards) {
+ fullCardPosts.push(item.post);
+ } else {
+ simpleMarkerPosts.push(clusterPosts[0]);
+ }
+ } else {
+ // Cluster: prendre le meilleur post pour carte complète, autres en marqueurs simples
+ const sortedCluster = clusterPosts
+ .map(p => postsWithScores.find(ps => ps.post.id === p.id))
+ .filter(Boolean)
+ .sort((a, b) => b.priority - a.priority);
+
+ // Le meilleur du cluster devient carte si score suffisant
+ if (sortedCluster[0] &&
+ sortedCluster[0].priority >= minScoreForCard &&
+ fullCardPosts.length < maxFullCards) {
+ fullCardPosts.push(sortedCluster[0].post);
+ clusterRepresentatives.add(sortedCluster[0].post.id);
+ }
+
+ // Les autres deviennent marqueurs simples
+ for (let i = (clusterRepresentatives.has(sortedCluster[0]?.post.id) ? 1 : 0); i < sortedCluster.length; i++) {
+ simpleMarkerPosts.push(sortedCluster[i].post);
+ }
+ }
+ }
+
+ // 5. Si pas assez de cartes complètes, ajouter les meilleurs marqueurs simples
+ if (fullCardPosts.length < maxFullCards) {
+ const remaining = postsWithScores
+ .filter(p =>
+ !fullCardPosts.find(fp => fp.id === p.post.id) &&
+ !simpleMarkerPosts.find(sp => sp.id === p.post.id)
+ )
+ .slice(0, maxFullCards - fullCardPosts.length);
+
+ for (const item of remaining) {
+ fullCardPosts.push(item.post);
+ const idx = simpleMarkerPosts.findIndex(p => p.id === item.post.id);
+ if (idx >= 0) simpleMarkerPosts.splice(idx, 1);
+ }
+ }
+
+ return {
+ fullCardPosts,
+ simpleMarkerPosts,
+ clusters,
+ };
+}
+
+/**
+ * Génère une couleur pour un marqueur simple basée sur la catégorie
+ */
+export function getMarkerColorForCategory(category) {
+ const cat = (category || '').toString().toUpperCase();
+
+ const colors = {
+ 'EVENT': '#8b5cf6', // violet
+ 'EVENTS': '#8b5cf6',
+ 'NEWS': '#3b82f6', // bleu
+ 'MARKET': '#10b981', // vert
+ 'FRIENDS': '#f59e0b', // orange
+ };
+
+ return colors[cat] || '#6b7280'; // gris par défaut
+}
diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js
index 20dbc3b..63522e3 100644
--- a/src/components/Map/usePostsEngine.js
+++ b/src/components/Map/usePostsEngine.js
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSmartFeed } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
-import { createMarkerForPost } from "./markerManager";
+import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
+import { prioritizePosts } from "./postPriority";
function getId(p) {
return p?.id ?? p?._id ?? null;
@@ -81,10 +82,25 @@ export function usePostsEngine({
// If user is reading an expanded overlay, don't touch markers
if (expandedElRef?.current) 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 { fullCardPosts, simpleMarkerPosts } = prioritizePosts(visible, viewCenter, {
+ maxFullCards: 15, // Max 15 cartes complètes
+ clusterRadius: 0.0015, // ~150m clustering radius
+ minScoreForCard: 35, // Score minimum pour carte complète
+ });
+
+ // Combine both types for "wanted" tracking
const wanted = new Map();
for (const post of visible) {
const id = getId(post);
- if (id != null) wanted.set(id, post);
+ if (id != null) {
+ // Tag avec le type de marqueur souhaité
+ const isFullCard = fullCardPosts.some(p => getId(p) === id);
+ wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
+ }
}
const cur = markersRef.current || [];
@@ -116,11 +132,15 @@ export function usePostsEngine({
const have = new Set(next.map((x) => x.id));
- // add missing markers
- for (const post of visible) {
- const id = getId(post);
- if (id == null || have.has(id)) continue;
- createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
+ // 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]
diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx
index af8ef79..e7340c7 100644
--- a/src/components/Search/SmartSearchBar.jsx
+++ b/src/components/Search/SmartSearchBar.jsx
@@ -98,7 +98,7 @@ export default function SmartSearchBar({
const t = setTimeout(async () => {
try {
- const res = await recoSearch(query, { signal: ctrl.signal, limit: 10 });
+ const res = await recoSearch(query, { signal: ctrl.signal, limit: 12 });
setItems(res);
setOpen(true);
setActive(res.length ? 0 : -1);
@@ -109,7 +109,7 @@ export default function SmartSearchBar({
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
- }, 220);
+ }, 150); // Réduit de 220ms à 150ms pour réactivité accrue
return () => {
clearTimeout(t);