Add intelligent post clustering and simple markers
Implement smart prioritization system to reduce map clutter: - Create priority scoring based on reco score, category, freshness, distance, engagement - Cluster nearby posts and show best post as full card, others as simple markers - Simple markers: circular icons with category colors, hover preview, click to expand - Max 15 full cards displayed, rest shown as compact markers - Optimize search debounce: 220ms → 150ms for faster suggestions New files: - postPriority.js: Scoring and clustering algorithm Updated: - markerManager.js: Add createSimpleMarkerForPost() with hover cards - usePostsEngine.js: Use prioritizePosts() to separate full cards vs simple markers - SmartSearchBar.jsx: Faster debounce (150ms), increased limit to 12 suggestions Result: Less visual clutter, better UX with prioritized content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d94b6ac12
commit
76adb74833
|
|
@ -7,6 +7,7 @@ const SW_ANIM_MS = 840;
|
||||||
import CardRenderer from "../Cards/CardRenderer";
|
import CardRenderer from "../Cards/CardRenderer";
|
||||||
import { cardTokens } from "../../theme/cardTokens";
|
import { cardTokens } from "../../theme/cardTokens";
|
||||||
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
|
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
|
||||||
|
import { getMarkerColorForCategory } from "./postPriority";
|
||||||
|
|
||||||
export function clearAllMarkers(markersRef, expandedElRef) {
|
export function clearAllMarkers(markersRef, expandedElRef) {
|
||||||
if (markersRef.current) {
|
if (markersRef.current) {
|
||||||
|
|
@ -440,3 +441,145 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
if (expandedElRef) expandedElRef.current = null;
|
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 = `
|
||||||
|
<div class="sw-simple-marker" style="
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${color};
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
">
|
||||||
|
${img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||||
|
<div style="
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
opacity: 0.9;
|
||||||
|
"></div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="sw-simple-hover-card" style="
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 280px;
|
||||||
|
background: rgba(8, 20, 40, 0.97);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(90, 190, 255, 0.7);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||||
|
">
|
||||||
|
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
||||||
|
${escapeHtml(post?.title || "Untitled")}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 10px; color: rgba(232, 245, 255, 0.8);">
|
||||||
|
${escapeHtml((post?.snippet || "").substring(0, 80))}${(post?.snippet || "").length > 80 ? "..." : ""}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
||||||
|
${normCatLabel(post)} • ${formatRelativeTime(post?.created_at || post?.CreatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<clusterId, posts[]>
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchSmartFeed } from "../../api/client";
|
import { fetchSmartFeed } from "../../api/client";
|
||||||
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
||||||
import { haversineKm } from "./mapGeo";
|
import { haversineKm } from "./mapGeo";
|
||||||
import { createMarkerForPost } from "./markerManager";
|
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
||||||
|
import { prioritizePosts } from "./postPriority";
|
||||||
|
|
||||||
function getId(p) {
|
function getId(p) {
|
||||||
return p?.id ?? p?._id ?? null;
|
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 user is reading an expanded overlay, don't touch markers
|
||||||
if (expandedElRef?.current) return;
|
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();
|
const wanted = new Map();
|
||||||
for (const post of visible) {
|
for (const post of visible) {
|
||||||
const id = getId(post);
|
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 || [];
|
const cur = markersRef.current || [];
|
||||||
|
|
@ -116,11 +132,15 @@ export function usePostsEngine({
|
||||||
|
|
||||||
const have = new Set(next.map((x) => x.id));
|
const have = new Set(next.map((x) => x.id));
|
||||||
|
|
||||||
// add missing markers
|
// add missing markers - use appropriate type based on priority
|
||||||
for (const post of visible) {
|
for (const [id, { post, type }] of wanted.entries()) {
|
||||||
const id = getId(post);
|
if (have.has(id)) continue;
|
||||||
if (id == null || have.has(id)) continue;
|
|
||||||
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
|
if (type === 'full') {
|
||||||
|
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
|
||||||
|
} else {
|
||||||
|
createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mapRef, markersRef, expandedElRef, theme]
|
[mapRef, markersRef, expandedElRef, theme]
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export default function SmartSearchBar({
|
||||||
|
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await recoSearch(query, { signal: ctrl.signal, limit: 10 });
|
const res = await recoSearch(query, { signal: ctrl.signal, limit: 12 });
|
||||||
setItems(res);
|
setItems(res);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setActive(res.length ? 0 : -1);
|
setActive(res.length ? 0 : -1);
|
||||||
|
|
@ -109,7 +109,7 @@ export default function SmartSearchBar({
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) setLoading(false);
|
if (!ctrl.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
}, 220);
|
}, 150); // Réduit de 220ms à 150ms pour réactivité accrue
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue