Major UX improvements: Smart search, navigation, fixed markers
1. Smart Search Integration (MapView.jsx, SmartSearchBar.jsx):
- Replace simple search bar with SmartSearchBar component
- Suggestions appear on focus (popular cities/regions)
- Suggestions update as user types (debounced 150ms)
- Click suggestion → fly to location with appropriate zoom
- Search triggers even with empty query for popular results
2. Navigation to Cities (MapView.jsx):
- New handlePickSuggestion() function
- Extracts coords from suggestion (supports coords array or lat/lon)
- Uses map.flyTo() with smart zoom levels per type:
* Cities: zoom 11
* Regions: zoom 7
* Posts: zoom 16
* Profiles: zoom 14
- Clears search after navigation
3. Fixed Marker Anchoring (markerManager.js):
- Changed anchor: "center" → "bottom"
- Markers now stay fixed at geographic position
- Pin design: circle image + triangular point (52px height)
- Point positioned at exact lat/lon location
- No more drifting when map moves!
4. Improved Pin Design:
- Circle (32px) with category color background
- White border + shadow for visibility
- Triangular point (16px) below circle
- Image inside circle if available
- Hover card positioned above pin (bottom: 58px)
- Smooth transitions on hover/click
Mobile-friendly: Works on cellphone without console, visual feedback
🤖 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
069072dc14
commit
28e760d616
|
|
@ -17,6 +17,7 @@ import { usePostsEngine } from "./usePostsEngine";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import FilterButtons from "../Filters/FilterButtons";
|
import FilterButtons from "../Filters/FilterButtons";
|
||||||
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
||||||
|
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
|
|
@ -208,6 +209,35 @@ export default function MapView({
|
||||||
map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 });
|
map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePickSuggestion = (item, { zoom }) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// Extract coords from item
|
||||||
|
let coords = null;
|
||||||
|
if (Array.isArray(item.coords) && item.coords.length === 2) {
|
||||||
|
coords = item.coords; // [lon, lat]
|
||||||
|
} else if (typeof item.lon === 'number' && typeof item.lat === 'number') {
|
||||||
|
coords = [item.lon, item.lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coords) {
|
||||||
|
console.warn('[MapView] No coords for suggestion:', item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MapView] Flying to:', item.title, coords, 'zoom:', zoom);
|
||||||
|
map.flyTo({
|
||||||
|
center: coords,
|
||||||
|
zoom: zoom || 12,
|
||||||
|
speed: 1.4,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear search query after navigation
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const handleOpenCreate = () => {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
alert("You must be logged in to place a wire.");
|
alert("You must be logged in to place a wire.");
|
||||||
|
|
@ -413,38 +443,15 @@ export default function MapView({
|
||||||
<div ref={containerRef} className="map-container" />
|
<div ref={containerRef} className="map-container" />
|
||||||
{status && <div className="map-status">{status}</div>}
|
{status && <div className="map-status">{status}</div>}
|
||||||
|
|
||||||
{/* TOP: Search à gauche, 2 icônes à droite, sur une seule ligne */}
|
{/* TOP: Search avec suggestions intelligentes */}
|
||||||
<div className="map-overlay map-overlay-top">
|
<div className="map-overlay map-overlay-top">
|
||||||
<div className="sw-top-row">
|
<div className="sw-top-row">
|
||||||
<form
|
<SmartSearchBar
|
||||||
className="sw-searchbar sw-top-search"
|
placeholder="Search cities, places, posts..."
|
||||||
onSubmit={handleSearchSubmit}
|
onPick={handlePickSuggestion}
|
||||||
role="search"
|
onMySpot={handleFlyToMe}
|
||||||
aria-label="Search"
|
onPlaceTourWire={handleOpenCreate}
|
||||||
>
|
|
||||||
<i className="fa-solid fa-magnifying-glass sw-search-icon" />
|
|
||||||
<input
|
|
||||||
className="sw-search-input"
|
|
||||||
type="search"
|
|
||||||
placeholder="Look at… (city, topic, @user)"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
/>
|
||||||
{searchQuery ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-search-clear"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
aria-label="Clear search"
|
|
||||||
title="Clear"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="map-top-actions">
|
<div className="map-top-actions">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -479,8 +479,16 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
root.style.position = "relative";
|
root.style.position = "relative";
|
||||||
root.style.cursor = "pointer";
|
root.style.cursor = "pointer";
|
||||||
|
|
||||||
// Marqueur circulaire simple
|
// Marqueur pin avec image en haut et pointe en bas
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
|
<div class="sw-simple-pin-container" style="
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
">
|
||||||
<div class="sw-simple-marker" style="
|
<div class="sw-simple-marker" style="
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
@ -493,6 +501,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
">
|
">
|
||||||
${img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
${img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||||
<div style="
|
<div style="
|
||||||
|
|
@ -504,9 +513,20 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
"></div>
|
"></div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sw-simple-pin-point" style="
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-top: 16px solid ${color};
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
|
margin-top: -2px;
|
||||||
|
z-index: 1;
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
<div class="sw-simple-hover-card" style="
|
<div class="sw-simple-hover-card" style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 40px;
|
bottom: 58px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) scale(0.9);
|
transform: translateX(-50%) scale(0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -521,6 +541,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(90, 190, 255, 0.7);
|
border: 1px solid rgba(90, 190, 255, 0.7);
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||||
|
white-space: normal;
|
||||||
">
|
">
|
||||||
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
||||||
${escapeHtml(post?.title || "Untitled")}
|
${escapeHtml(post?.title || "Untitled")}
|
||||||
|
|
@ -576,7 +597,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
|
|
||||||
const marker = new maplibregl.Marker({
|
const marker = new maplibregl.Marker({
|
||||||
element: root,
|
element: root,
|
||||||
anchor: "center",
|
anchor: "bottom", // La pointe du pin est à la position exacte
|
||||||
})
|
})
|
||||||
.setLngLat(lngLat)
|
.setLngLat(lngLat)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export default function SmartSearchBar({
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [active, setActive] = useState(-1);
|
const [active, setActive] = useState(-1);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
const abortRef = useRef(null);
|
const abortRef = useRef(null);
|
||||||
const boxRef = useRef(null);
|
const boxRef = useRef(null);
|
||||||
|
|
@ -80,32 +81,23 @@ export default function SmartSearchBar({
|
||||||
return () => document.removeEventListener("mousedown", onDocDown);
|
return () => document.removeEventListener("mousedown", onDocDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Fonction pour déclencher une recherche
|
||||||
const query = q.trim();
|
const triggerSearch = async (searchQuery) => {
|
||||||
setErr("");
|
|
||||||
|
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
|
||||||
// Allow empty query to get popular suggestions
|
|
||||||
// if (!query) {
|
|
||||||
// setItems([]);
|
|
||||||
// setLoading(false);
|
|
||||||
// setActive(-1);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setErr("");
|
||||||
|
|
||||||
const t = setTimeout(async () => {
|
|
||||||
try {
|
try {
|
||||||
console.log('[SmartSearchBar] Searching for:', query);
|
console.log('[SmartSearchBar] Searching for:', searchQuery);
|
||||||
const res = await recoSearch(query, { signal: ctrl.signal, limit: 12 });
|
const res = await recoSearch(searchQuery, { signal: ctrl.signal, limit: 12 });
|
||||||
console.log('[SmartSearchBar] Got results:', res?.length || 0, res);
|
console.log('[SmartSearchBar] Got results:', res?.length || 0, res);
|
||||||
setItems(res);
|
setItems(res);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setActive(res.length ? 0 : -1);
|
setActive(res.length ? 0 : -1);
|
||||||
|
setHasSearched(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
console.error('[SmartSearchBar] Search error:', e);
|
console.error('[SmartSearchBar] Search error:', e);
|
||||||
|
|
@ -114,11 +106,20 @@ export default function SmartSearchBar({
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) setLoading(false);
|
if (!ctrl.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
}, 150); // Réduit de 220ms à 150ms pour réactivité accrue
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
triggerSearch(query);
|
||||||
|
}, 150); // Debounce 150ms
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
ctrl.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
};
|
};
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
|
|
@ -164,7 +165,13 @@ export default function SmartSearchBar({
|
||||||
className="sw-search__input"
|
className="sw-search__input"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
|
setOpen(true);
|
||||||
|
// Si pas encore cherché et query vide, charger suggestions populaires
|
||||||
|
if (!hasSearched && !q.trim()) {
|
||||||
|
triggerSearch("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue