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);