| 1 | // ----------------------------------------------------------------------------- |
1 | // ----------------------------------------------------------------------------- |
| 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) |
2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | −// search/MapSearch.tsx : recherche synchronisée liste ↔ carte (Ka Maps). |
4 | +// search/MapSearch.tsx : le MODE CARTE (Ka Map System v2) — recherche |
| |
5 | +// synchronisée liste ↔ carte, plein viewport. |
| 5 | // |
6 | // |
| 6 | // UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent |
7 | // UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent |
| 7 | // dans un état unique ; chaque changement déclenche UNE requête /api/search |
8 | // dans un état unique ; chaque changement déclenche UNE requête /api/search |
| 9 | // pareil : le compteur de la liste est par construction égal au nombre de |
10 | // pareil : le compteur de la liste est par construction égal au nombre de |
| 10 | // marqueurs, et l'index d'un point donne directement sa page de liste. |
11 | // marqueurs, et l'index d'un point donne directement sa page de liste. |
| 11 | // |
12 | // |
| 12 | −// · carte → liste : déplacement (fin de geste, avec debounce), case |
13 | +// v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est |
| 13 | −// « Rechercher quand je déplace la carte », bouton « Rechercher dans |
14 | +// une prise de contrôle du viewport. |
| 14 | −// cette zone », polygone dessiné (puce retirable), clic cluster ; |
15 | +// · mobile : carte edge-to-edge, barre supérieure compacte, résultats en |
| 15 | −// · liste → carte : filtres (recadrage animé sauf si l'utilisateur a pris |
16 | +// bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ; |
| 16 | −// la main — bouton « Recadrer sur les résultats »), tri partagé ; |
17 | +// · desktop : split résultats|carte redimensionnable (ratio persisté), |
| 17 | −// · miroirs : survol carte↔liste (< 100 ms, feature-state GPU), clic |
18 | +// carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ; |
| 18 | −// marqueur → défilement de la liste (page ajustée au besoin) + pulsation, |
19 | +// · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation), |
| 19 | −// clic annonce → recentrage doux + mini-fiche ; |
20 | +// « Rechercher dans cette zone » contextuel, mode dessin temporaire avec |
| 20 | −// · mobile : carrousel bas d'écran synchronisé avec les marqueurs ; |
21 | +// CTA « N logements dans cette zone ». |
| 21 | −// · URL : caméra + tri + page + zone dessinée + mode de recherche. |
| |
| 22 | // |
22 | // |
| 23 | −// Ce composant est le modèle réutilisable Groupe-KA (immo-ka, resto-ka, |
23 | +// Ce composant reste le modèle réutilisable Groupe-KA (immo-ka, resto-ka, |
| 24 | // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md. |
24 | // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md. |
| 25 | // ----------------------------------------------------------------------------- |
25 | // ----------------------------------------------------------------------------- |
| 26 | import MetroLignes from "../components/MetroLignes"; |
26 | import MetroLignes from "../components/MetroLignes"; |
| 27 | import { |
27 | import { |
| 28 | useCallback, useEffect, useMemo, useRef, useState, |
28 | useCallback, useEffect, useMemo, useRef, useState, |
| 29 | } from "react"; |
29 | } from "react"; |
| 30 | −import { Link, useNavigate } from "react-router-dom"; |
30 | +import { useNavigate } from "react-router-dom"; |
| 31 | import "mapbox-gl/dist/mapbox-gl.css"; |
31 | import "mapbox-gl/dist/mapbox-gl.css"; |
| 32 | import "@groupe-ka/ka-maps/styles.css"; |
32 | import "@groupe-ka/ka-maps/styles.css"; |
| 33 | import type { MapProperty } from "@groupe-ka/ka-maps"; |
33 | import type { MapProperty } from "@groupe-ka/ka-maps"; |
| 36 | } from "@groupe-ka/ka-maps"; |
36 | } from "@groupe-ka/ka-maps"; |
| 37 | import type { KaMap } from "@groupe-ka/ka-maps"; |
37 | import type { KaMap } from "@groupe-ka/ka-maps"; |
| 38 | import { |
38 | import { |
| 39 | − DrawControl, KaBrandBadge, KaMapView, LocateControl, PropertyPreview, |
39 | + KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView, |
| 40 | − Tilt3DControl, useKaMap, |
40 | + KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup, |
| |
41 | + KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell, |
| 41 | } from "@groupe-ka/ka-maps/react"; |
42 | } from "@groupe-ka/ka-maps/react"; |
| 42 | import { |
43 | import { |
| 43 | Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort, |
44 | Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort, |
| 44 | fetchListing, fetchSearch, fmtPrice, sourceName, thumb, |
45 | fetchListing, fetchSearch, fmtPrice, sourceName, thumb, |
| 45 | } from "../api"; |
46 | } from "../api"; |
| |
47 | +import { useAccount } from "../account"; |
| 46 | import ListingCard from "../components/ListingCard"; |
48 | import ListingCard from "../components/ListingCard"; |
| 47 | import Pager from "../components/Pager"; |
49 | import Pager from "../components/Pager"; |
| 48 | −import SmartImg from "../components/SmartImg"; |
| |
| 49 | import FairValueBadge from "../components/FairValueBadge"; |
50 | import FairValueBadge from "../components/FairValueBadge"; |
| 50 | −import { IcoSearch } from "../components/Icons"; |
51 | +import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons"; |
| 51 | import { louKaMapTheme } from "../kamaps/theme"; |
52 | import { louKaMapTheme } from "../kamaps/theme"; |
| 52 | import { MAPBOX_TOKEN } from "../kamaps/config"; |
53 | import { MAPBOX_TOKEN } from "../kamaps/config"; |
| 53 | import { seenUids } from "./seen"; |
54 | import { seenUids } from "./seen"; |
| 120 | return null; |
121 | return null; |
| 121 | } |
122 | } |
| 122 | |
123 | |
| 123 | −/** Mini-fiche de marqueur : les points carte sont volontairement compacts, |
124 | +/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */ |
| 124 | − * le détail (photo, titre, dispo) est chargé à la sélection (rapide, mis |
125 | +function SelectionMirror({ selectedUid }: { selectedUid: string | null }) { |
| 125 | − * en cache) — même langage visuel que les cartes de la liste. */ |
126 | + const map = useKaMap(); |
| |
127 | + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]); |
| |
128 | + return null; |
| |
129 | +} |
| |
130 | + |
| |
131 | +/** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser |
| |
132 | + * toute la place à la carte + fiche contextuelle. */ |
| |
133 | +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) { |
| |
134 | + const shell = useKaShell(); |
| |
135 | + const shellRef = useRef(shell); |
| |
136 | + shellRef.current = shell; |
| |
137 | + useEffect(() => { |
| |
138 | + const s = shellRef.current; |
| |
139 | + if (selectedUid && s?.isMobile) s.setSheet("mini"); |
| |
140 | + }, [selectedUid]); |
| |
141 | + return null; |
| |
142 | +} |
| |
143 | + |
| |
144 | +/** Fiche contextuelle : les points carte sont volontairement compacts, le |
| |
145 | + * détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en |
| |
146 | + * cache) — structure ka-prevcard du Ka Map System, contenu Lou-Ka. */ |
| 126 | const previewCache = new Map<string, Listing>(); |
147 | const previewCache = new Map<string, Listing>(); |
| 127 | |
148 | |
| 128 | function LazyPreview({ p }: { p: MapProperty }) { |
149 | function LazyPreview({ p }: { p: MapProperty }) { |
| 129 | const navigate = useNavigate(); |
150 | const navigate = useNavigate(); |
| |
151 | + const { favs, toggleFav } = useAccount(); |
| 130 | const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null); |
152 | const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null); |
| 131 | useEffect(() => { |
153 | useEffect(() => { |
| 132 | if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; } |
154 | if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; } |
| 146 | }, [p.id]); |
168 | }, [p.id]); |
| 147 | |
169 | |
| 148 | const img = l?.images?.[0] ?? null; |
170 | const img = l?.images?.[0] ?? null; |
| |
171 | + const fav = favs.has(p.id); |
| |
172 | + const fiche = `/logement/${encodeURIComponent(p.id)}`; |
| 149 | return ( |
173 | return ( |
| 150 | − <div className="mv-pop"> |
174 | + <> |
| 151 | − {img ? ( |
175 | + <div className="ka-prevcard-media"> |
| 152 | − <img src={thumb(img, 480)} alt="" loading="lazy" /> |
176 | + {img ? ( |
| 153 | − ) : ( |
177 | + <img src={thumb(img, 480)} alt="" loading="lazy" /> |
| 154 | − <div className="mv-noimg" aria-hidden="true">⌂</div> |
178 | + ) : ( |
| 155 | − )} |
179 | + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div> |
| 156 | − <div className="mv-pop-body"> |
180 | + )} |
| 157 | − <div className="mv-pop-price"> |
181 | + </div> |
| |
182 | + <div className="ka-prevcard-body"> |
| |
183 | + <div className="ka-prevcard-price"> |
| 158 | {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"} |
184 | {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"} |
| 159 | − {(l?.price ?? p.price) != null && <small> /mois</small>} |
185 | + {(l?.price ?? p.price) != null && <small>/mois</small>} |
| 160 | {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />} |
186 | {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />} |
| 161 | </div> |
187 | </div> |
| 162 | − <div className="mv-pop-title">{l ? (l.title || l.address) : "Chargement…"}</div> |
188 | + <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Chargement…"}</div> |
| 163 | − <div className="mv-pop-meta"> |
189 | + <div className="ka-prevcard-meta"> |
| 164 | − {[l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", l?.availability] |
190 | + {[ |
| 165 | − .filter(Boolean).join(" · ")} |
191 | + l?.unit_type, |
| |
192 | + l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", |
| |
193 | + l?.availability, |
| |
194 | + l ? sourceName(l.source) : "", |
| |
195 | + ].filter(Boolean).join(" · ")} |
| |
196 | + </div> |
| |
197 | + <div className="ka-prevcard-actions"> |
| |
198 | + <a |
| |
199 | + className="ka-prevcard-cta" |
| |
200 | + href={fiche} |
| |
201 | + onClick={(e) => { e.preventDefault(); navigate(fiche); }} |
| |
202 | + > |
| |
203 | + Voir la fiche → |
| |
204 | + </a> |
| |
205 | + <button |
| |
206 | + type="button" |
| |
207 | + className={`ka-prevcard-fav${fav ? " on" : ""}`} |
| |
208 | + onClick={() => toggleFav(p.id)} |
| |
209 | + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} |
| |
210 | + aria-pressed={fav} |
| |
211 | + > |
| |
212 | + <IcoHeart size={16} filled={fav} /> |
| |
213 | + </button> |
| 166 | </div> |
214 | </div> |
| 167 | − <div className="mv-pop-src">{l ? sourceName(l.source) : ""}</div> |
| |
| 168 | − <a |
| |
| 169 | − className="mv-pop-cta" |
| |
| 170 | − href={`/logement/${encodeURIComponent(p.id)}`} |
| |
| 171 | − onClick={(e) => { e.preventDefault(); navigate(`/logement/${encodeURIComponent(p.id)}`); }} |
| |
| 172 | − > |
| |
| 173 | − Voir la fiche → |
| |
| 174 | − </a> |
| |
| 175 | </div> |
215 | </div> |
| 176 | − </div> |
216 | + </> |
| 177 | ); |
217 | ); |
| 178 | } |
218 | } |
| 179 | |
219 | |
| 181 | filters: ListingFilters; |
221 | filters: ListingFilters; |
| 182 | /** Compteur partagé remonté à l'entête de résultats de la page. */ |
222 | /** Compteur partagé remonté à l'entête de résultats de la page. */ |
| 183 | onTotal?: (total: number | null) => void; |
223 | onTotal?: (total: number | null) => void; |
| |
224 | + /** Quitter le mode carte (retour à la vue liste). */ |
| |
225 | + onExit?: () => void; |
| |
226 | + /** Ouvrir la feuille de filtres de la page (le shell n'a qu'un bouton). */ |
| |
227 | + onOpenFilters?: () => void; |
| |
228 | + /** Nombre de filtres actifs (badge du bouton Filtres). */ |
| |
229 | + filtersCount?: number; |
| 184 | } |
230 | } |
| 185 | |
231 | |
| 186 | interface Query { |
232 | interface Query { |
| 190 | page: number; |
236 | page: number; |
| 191 | } |
237 | } |
| 192 | |
238 | |
| 193 | −export default function MapSearch({ filters, onTotal }: MapSearchProps) { |
239 | +export default function MapSearch({ |
| |
240 | + filters, onTotal, onExit, onOpenFilters, filtersCount = 0, |
| |
241 | +}: MapSearchProps) { |
| 194 | const initialParams = useMemo( |
242 | const initialParams = useMemo( |
| 195 | () => new URLSearchParams(window.location.search), []); |
243 | () => new URLSearchParams(window.location.search), []); |
| 196 | const initialCamera = useMemo( |
244 | const initialCamera = useMemo( |
| 244 | const pointsKeyRef = useRef<string>(""); |
292 | const pointsKeyRef = useRef<string>(""); |
| 245 | const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat |
293 | const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat |
| 246 | const pendingScrollUidRef = useRef<string | null>(null); |
294 | const pendingScrollUidRef = useRef<string | null>(null); |
| 247 | − const carouselDrivenRef = useRef(false); |
| |
| 248 | const listRef = useRef<HTMLDivElement | null>(null); |
295 | const listRef = useRef<HTMLDivElement | null>(null); |
| 249 | − const carouselRef = useRef<HTMLDivElement | null>(null); |
| |
| 250 | |
296 | |
| 251 | const filtersClean = useMemo(() => { |
297 | const filtersClean = useMemo(() => { |
| 252 | const f: Record<string, string> = {}; |
298 | const f: Record<string, string> = {}; |
| 391 | |
437 | |
| 392 | // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la |
438 | // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la |
| 393 | // position et synchroniser la liste sur cette zone --- |
439 | // position et synchroniser la liste sur cette zone --- |
| |
440 | + const syncListToViewport = useCallback((delayMs: number) => { |
| |
441 | + window.setTimeout(() => { |
| |
442 | + const b = engineRef.current?.currentBBox(); |
| |
443 | + if (b) { |
| |
444 | + setDirty(false); |
| |
445 | + setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) })); |
| |
446 | + } |
| |
447 | + }, delayMs); |
| |
448 | + }, []); |
| |
449 | + |
| 394 | useEffect(() => { |
450 | useEffect(() => { |
| 395 | const handler = (e: Event) => { |
451 | const handler = (e: Event) => { |
| 396 | const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail; |
452 | const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail; |
| 399 | setUserLocked(true); |
455 | setUserLocked(true); |
| 400 | userLockedRef.current = true; |
456 | userLockedRef.current = true; |
| 401 | m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 }); |
457 | m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 }); |
| 402 | − window.setTimeout(() => { |
458 | + syncListToViewport(950); |
| 403 | − const b = engineRef.current?.currentBBox(); |
| |
| 404 | − if (b) { |
| |
| 405 | − setDirty(false); |
| |
| 406 | − setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) })); |
| |
| 407 | − } |
| |
| 408 | − }, 950); |
| |
| 409 | }; |
459 | }; |
| 410 | window.addEventListener("louka:geolocate", handler); |
460 | window.addEventListener("louka:geolocate", handler); |
| 411 | return () => window.removeEventListener("louka:geolocate", handler); |
461 | return () => window.removeEventListener("louka:geolocate", handler); |
| 412 | − }, []); |
462 | + }, [syncListToViewport]); |
| 413 | |
463 | |
| 414 | // --- geste sur la carte --- |
464 | // --- geste sur la carte --- |
| 415 | const onMoveEnd = useCallback( |
465 | const onMoveEnd = useCallback( |
| 457 | const widenArea = useCallback(() => { |
507 | const widenArea = useCallback(() => { |
| 458 | const m = engineRef.current; |
508 | const m = engineRef.current; |
| 459 | if (!m) return; |
509 | if (!m) return; |
| 460 | − // « Élargir la zone » : dézoome — le moveend (byUser côté moteur ? non, |
510 | + // « Élargir la zone » : dézoome, puis recherche élargie explicite. |
| 461 | − // easeTo interne) ; on déclenche la recherche élargie explicitement. |
| |
| 462 | − const b = m.currentBBox(); |
| |
| 463 | m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, { |
511 | m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, { |
| 464 | zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650, |
512 | zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650, |
| 465 | }); |
513 | }); |
| 466 | − if (b) { |
514 | + syncListToViewport(700); |
| 467 | − window.setTimeout(() => { |
515 | + }, [syncListToViewport]); |
| 468 | − const nb = engineRef.current?.currentBBox(); |
| |
| 469 | − if (nb) { |
| |
| 470 | − setDirty(false); |
| |
| 471 | − setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(nb) })); |
| |
| 472 | − } |
| |
| 473 | − }, 700); |
| |
| 474 | − } |
| |
| 475 | − }, []); |
| |
| 476 | |
516 | |
| 477 | // --- polygone dessiné --- |
517 | // --- polygone dessiné --- |
| 478 | const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => { |
518 | const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => { |
| 509 | card.classList.add("map-card-pulse"); |
549 | card.classList.add("map-card-pulse"); |
| 510 | } |
550 | } |
| 511 | } |
551 | } |
| 512 | − const carItem = carouselRef.current?.querySelector<HTMLElement>(sel); |
| |
| 513 | − if (carItem && carouselRef.current) { |
| |
| 514 | − carouselRef.current.scrollTo({ |
| |
| 515 | − left: carItem.offsetLeft - 12, behavior: "smooth", |
| |
| 516 | − }); |
| |
| 517 | − } |
| |
| 518 | }, []); |
552 | }, []); |
| 519 | |
553 | |
| 520 | const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => { |
554 | const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => { |
| 521 | const uid = p?.id ?? null; |
555 | const uid = p?.id ?? null; |
| 522 | setSelectedUid(uid); |
556 | setSelectedUid(uid); |
| 523 | if (!uid) return; |
557 | if (!uid) return; |
| 524 | − if (carouselDrivenRef.current) { // déjà au bon endroit |
| |
| 525 | − carouselDrivenRef.current = false; |
| |
| 526 | − return; |
| |
| 527 | − } |
| |
| 528 | const idx = uidIndex.get(uid); |
558 | const idx = uidIndex.get(uid); |
| 529 | if (idx === undefined) return; |
559 | if (idx === undefined) return; |
| 530 | const targetPage = Math.floor(idx / PAGE_SIZE) + 1; |
560 | const targetPage = Math.floor(idx / PAGE_SIZE) + 1; |
| 562 | else setHoverOffscreen(null); |
592 | else setHoverOffscreen(null); |
| 563 | }, [mapHoverUid]); |
593 | }, [mapHoverUid]); |
| 564 | |
594 | |
| 565 | − // --- carrousel mobile : balayage → sélection sur la carte --- |
595 | + // --- clic sur une carte d'annonce : recentre + fiche (1er clic), |
| 566 | − const carouselScrollTimer = useRef<number | null>(null); |
| |
| 567 | − const onCarouselScroll = useCallback(() => { |
| |
| 568 | − if (carouselScrollTimer.current) window.clearTimeout(carouselScrollTimer.current); |
| |
| 569 | − carouselScrollTimer.current = window.setTimeout(() => { |
| |
| 570 | − const el = carouselRef.current; |
| |
| 571 | − if (!el || !res) return; |
| |
| 572 | − const children = Array.from(el.children) as HTMLElement[]; |
| |
| 573 | − const center = el.scrollLeft + el.clientWidth / 2; |
| |
| 574 | − let best = 0, bestD = Infinity; |
| |
| 575 | − children.forEach((c, i) => { |
| |
| 576 | − const d = Math.abs(c.offsetLeft + c.offsetWidth / 2 - center); |
| |
| 577 | − if (d < bestD) { bestD = d; best = i; } |
| |
| 578 | − }); |
| |
| 579 | − const uid = res.listings[best]?.uid; |
| |
| 580 | − if (uid && uid !== selectedUid) { |
| |
| 581 | − carouselDrivenRef.current = true; |
| |
| 582 | − engineRef.current?.select(uid, "app"); |
| |
| 583 | − setSelectedUid(uid); |
| |
| 584 | − } |
| |
| 585 | − }, 140); |
| |
| 586 | − }, [res, selectedUid]); |
| |
| 587 | − |
| |
| 588 | − // --- clic sur une carte d'annonce : recentre + mini-fiche (1er clic), |
| |
| 589 | // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) --- |
596 | // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) --- |
| 590 | const onCardClick = useCallback((e: React.MouseEvent, uid: string) => { |
597 | const onCardClick = useCallback((e: React.MouseEvent, uid: string) => { |
| 591 | if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet |
598 | if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet |
| 594 | engineRef.current?.select(uid, "app"); |
601 | engineRef.current?.select(uid, "app"); |
| 595 | }, [selectedUid]); |
602 | }, [selectedUid]); |
| 596 | |
603 | |
| 597 | − // Amener l'écran sur la recherche à l'ouverture de la vue carte (arrivée |
| |
| 598 | − // directe /?view=carte, géolocalisation, ou clic sur l'onglet Carte) — |
| |
| 599 | − // sinon le héro cache la carte et la synchro se joue sous le pli. |
| |
| 600 | − const rootRef = useRef<HTMLDivElement | null>(null); |
| |
| 601 | − useEffect(() => { |
| |
| 602 | − const t = window.setTimeout(() => { |
| |
| 603 | − rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); |
| |
| 604 | − }, 120); |
| |
| 605 | − return () => window.clearTimeout(t); |
| |
| 606 | − }, []); |
| |
| 607 | − |
| |
| 608 | const listings = res?.listings ?? []; |
604 | const listings = res?.listings ?? []; |
| 609 | const total = res?.total ?? null; |
605 | const total = res?.total ?? null; |
| 610 | const unpositioned = res?.unpositioned ?? 0; |
606 | const unpositioned = res?.unpositioned ?? 0; |
| 611 | const hasZone = qy.poly !== null; |
607 | const hasZone = qy.poly !== null; |
| 612 | |
608 | |
| 613 | − return ( |
609 | + /* ------------------------- volets du shell ------------------------- */ |
| 614 | − <div className="map-split ms" ref={rootRef}> |
610 | + |
| 615 | − {/* ---------------- volet liste (desktop / tablette) ---------------- */} |
611 | + const listHeader = ( |
| 616 | − <div className="map-list ms-list" aria-label="Résultats en liste" ref={listRef}> |
612 | + <div className="ms2-head"> |
| 617 | − <div className="ms-list-head"> |
613 | + <div className="ms2-count" role="status" aria-live="polite"> |
| 618 | − <div className="ms-count" role="status" aria-live="polite"> |
614 | + {total === null ? "…" : ( |
| 619 | − {total === null ? "…" : ( |
615 | + <> |
| 620 | − <> |
616 | + <b>{total.toLocaleString("fr-CA")}</b> |
| 621 | − <b>{total.toLocaleString("fr-CA")}</b> |
617 | + {" "}logement{total !== null && total > 1 ? "s" : ""} |
| 622 | − {" "}logement{total > 1 ? "s" : ""} |
618 | + {hasZone ? " · zone dessinée" : ""} |
| 623 | − {hasZone ? " dans la zone dessinée" : ""} |
619 | + </> |
| 624 | − </> |
| |
| 625 | − )} |
| |
| 626 | − {unpositioned > 0 && ( |
| |
| 627 | − <span |
| |
| 628 | − className="ms-nopos" |
| |
| 629 | − title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement" |
| |
| 630 | − > |
| |
| 631 | − +{unpositioned} sans position |
| |
| 632 | − </span> |
| |
| 633 | − )} |
| |
| 634 | − </div> |
| |
| 635 | − <label className="ms-sort"> |
| |
| 636 | − <span>Tri</span> |
| |
| 637 | − <select |
| |
| 638 | − value={qy.sort} |
| |
| 639 | − onChange={(e) => setQy((q) => ({ |
| |
| 640 | − ...q, page: 1, sort: e.target.value as SearchSort, |
| |
| 641 | − }))} |
| |
| 642 | − > |
| |
| 643 | − {SORT_CHOICES.map((s) => ( |
| |
| 644 | − <option key={s.key} value={s.key}>{s.label}</option> |
| |
| 645 | − ))} |
| |
| 646 | − </select> |
| |
| 647 | − </label> |
| |
| 648 | − </div> |
| |
| 649 | − |
| |
| 650 | − {hasZone && ( |
| |
| 651 | − <div className="pills ms-pills"> |
| |
| 652 | − <button className="pill" onClick={clearZone} aria-label="Retirer la zone dessinée"> |
| |
| 653 | − Zone dessinée <span className="pill-x">✕</span> |
| |
| 654 | − </button> |
| |
| 655 | − </div> |
| |
| 656 | )} |
620 | )} |
| 657 | − {userLocked && total !== null && total > 0 && !hasZone && ( |
621 | + {unpositioned > 0 && ( |
| 658 | − <button className="ms-refit" onClick={refitToResults}> |
622 | + <span |
| 659 | − ⌖ Recadrer sur les résultats |
623 | + className="ms-nopos" |
| 660 | − </button> |
624 | + title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement" |
| |
625 | + > |
| |
626 | + +{unpositioned} sans position |
| |
627 | + </span> |
| 661 | )} |
628 | )} |
| |
629 | + </div> |
| |
630 | + <label className="ms2-sort"> |
| |
631 | + <select |
| |
632 | + value={qy.sort} |
| |
633 | + onChange={(e) => setQy((q) => ({ |
| |
634 | + ...q, page: 1, sort: e.target.value as SearchSort, |
| |
635 | + }))} |
| |
636 | + aria-label="Trier les résultats" |
| |
637 | + > |
| |
638 | + {SORT_CHOICES.map((s) => ( |
| |
639 | + <option key={s.key} value={s.key}>{s.label}</option> |
| |
640 | + ))} |
| |
641 | + </select> |
| |
642 | + </label> |
| |
643 | + </div> |
| |
644 | + ); |
| 662 | |
645 | |
| 663 | − {loading && <div className="ms-progress" aria-hidden="true" />} |
646 | + const listPane = ( |
| |
647 | + <div className="ms2-list" aria-label="Résultats en liste" ref={listRef}> |
| |
648 | + {userLocked && total !== null && total > 0 && !hasZone && ( |
| |
649 | + <button className="ms-refit" onClick={refitToResults}> |
| |
650 | + ⌖ Recadrer sur les résultats |
| |
651 | + </button> |
| |
652 | + )} |
| 664 | |
653 | |
| 665 | − {error && ( |
654 | + {loading && <div className="ms-progress" aria-hidden="true" />} |
| 666 | − <div className="ms-empty"> |
655 | + |
| 667 | − <h3>Impossible de charger les résultats</h3> |
656 | + {error && ( |
| 668 | − <p>{error}</p> |
657 | + <div className="ms-empty"> |
| 669 | − </div> |
658 | + <h3>Impossible de charger les résultats</h3> |
| 670 | − )} |
659 | + <p>{error}</p> |
| |
660 | + </div> |
| |
661 | + )} |
| 671 | |
662 | |
| 672 | − {!error && total === 0 && ( |
663 | + {!error && total === 0 && ( |
| 673 | − <div className="ms-empty" role="status"> |
664 | + <div className="ms-empty" role="status"> |
| 674 | − <div className="big"><IcoSearch size={34} /></div> |
665 | + <div className="big"><IcoSearch size={34} /></div> |
| 675 | − <h3>Aucun logement dans cette zone</h3> |
666 | + <h3>Aucun logement dans cette zone</h3> |
| 676 | − <p>Élargissez la carte ou modifiez vos filtres.</p> |
667 | + <p>Élargissez la carte ou modifiez vos filtres.</p> |
| 677 | − <div className="ms-empty-actions"> |
668 | + <div className="ms-empty-actions"> |
| 678 | − <button className="btn btn-primary" onClick={widenArea}> |
669 | + <button className="btn btn-primary" onClick={widenArea}> |
| 679 | − Élargir la zone |
670 | + Élargir la zone |
| |
671 | + </button> |
| |
672 | + {hasZone && ( |
| |
673 | + <button className="btn btn-ghost" onClick={clearZone}> |
| |
674 | + Effacer la zone dessinée |
| 680 | </button> |
675 | </button> |
| 681 | − {hasZone && ( |
676 | + )} |
| 682 | − <button className="btn btn-ghost" onClick={clearZone}> |
| |
| 683 | − Effacer la zone dessinée |
| |
| 684 | − </button> |
| |
| 685 | − )} |
| |
| 686 | − </div> |
| |
| 687 | </div> |
677 | </div> |
| 688 | − )} |
678 | + </div> |
| |
679 | + )} |
| 689 | |
680 | |
| 690 | − {!error && listings.map((l) => ( |
681 | + {!error && listings.map((l) => ( |
| 691 | − <div |
682 | + <div |
| 692 | − key={l.uid} |
683 | + key={l.uid} |
| 693 | − data-uid={l.uid} |
684 | + data-uid={l.uid} |
| 694 | − className={ |
685 | + className={ |
| 695 | − "map-card" + |
686 | + "map-card" + |
| 696 | − (selectedUid === l.uid ? " map-card-sel" : "") + |
687 | + (selectedUid === l.uid ? " map-card-sel" : "") + |
| 697 | − (mapHoverUid === l.uid ? " map-card-hover" : "") |
688 | + (mapHoverUid === l.uid ? " map-card-hover" : "") |
| 698 | − } |
689 | + } |
| 699 | − onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} |
690 | + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} |
| 700 | − onMouseLeave={() => engineRef.current?.setHovered(null, "app")} |
691 | + onMouseLeave={() => engineRef.current?.setHovered(null, "app")} |
| 701 | − onFocus={() => engineRef.current?.setHovered(l.uid, "app")} |
692 | + onFocus={() => engineRef.current?.setHovered(l.uid, "app")} |
| 702 | − onBlur={() => engineRef.current?.setHovered(null, "app")} |
693 | + onBlur={() => engineRef.current?.setHovered(null, "app")} |
| 703 | − onClickCapture={(e) => onCardClick(e, l.uid)} |
694 | + onClickCapture={(e) => onCardClick(e, l.uid)} |
| 704 | − > |
695 | + > |
| 705 | − <ListingCard l={l} /> |
696 | + <ListingCard l={l} /> |
| 706 | − </div> |
697 | + </div> |
| 707 | − ))} |
698 | + ))} |
| 708 | − |
699 | + |
| 709 | − {!error && total !== null && total > 0 && ( |
700 | + {!error && total !== null && total > 0 && ( |
| 710 | − <Pager |
701 | + <Pager |
| 711 | − page={qy.page} pageSize={PAGE_SIZE} total={total} |
702 | + page={qy.page} pageSize={PAGE_SIZE} total={total} |
| 712 | − onPage={(p) => { |
703 | + onPage={(p) => { |
| 713 | − setQy((q) => ({ ...q, page: p })); |
704 | + setQy((q) => ({ ...q, page: p })); |
| 714 | − listRef.current?.scrollTo({ top: 0, behavior: "smooth" }); |
705 | + // le défileur est le parent (ka-pane-body / ka-sheet-body) |
| 715 | − }} |
706 | + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" }); |
| 716 | − /> |
707 | + }} |
| 717 | − )} |
708 | + /> |
| |
709 | + )} |
| 718 | |
710 | |
| 719 | − {hoverOffscreen && ( |
711 | + {hoverOffscreen && ( |
| 720 | − <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true"> |
712 | + <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true"> |
| 721 | − {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"} |
713 | + {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"} |
| 722 | − </div> |
714 | + </div> |
| 723 | − )} |
715 | + )} |
| 724 | − </div> |
716 | + </div> |
| |
717 | + ); |
| 725 | |
718 | |
| 726 | − {/* ------------------------------ carte ------------------------------ */} |
719 | + return ( |
| 727 | − <div className="mapview" role="application" aria-label="Carte des logements"> |
720 | + <KaMapShell |
| 728 | − <KaMapView |
721 | + className="ms2" |
| 729 | − theme={louKaMapTheme} |
722 | + brand={<span className="ms2-brand"><b>Lou·Ka</b><span>Carte</span></span>} |
| 730 | − mapboxToken={MAPBOX_TOKEN} |
723 | + onExit={onExit} |
| 731 | − center={initialCamera ?? QUEBEC} |
724 | + exitLabel="Liste" |
| 732 | − zoom={(initialCamera ?? QUEBEC).zoom} |
725 | + storageKey="louka-map-split" |
| 733 | − pitch={50} |
726 | + listHeader={listHeader} |
| 734 | − basemap={{ theme: "default", showLandmarks: true }} |
727 | + list={listPane} |
| 735 | − cluster={{ maxZoom: 15, valueClamp: [250, 8000] }} |
728 | + topExtras={ |
| 736 | − onMoveEnd={onMoveEnd} |
729 | + onOpenFilters ? ( |
| 737 | − onSelect={onSelect} |
730 | + <button className="ka-top-btn" onClick={onOpenFilters}> |
| 738 | − onHover={onHover} |
731 | + <IcoSliders size={13} /> Filtres |
| 739 | − onDraw={onDraw} |
732 | + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>} |
| 740 | − onClusterHover={setClusterTip} |
733 | + </button> |
| 741 | − > |
734 | + ) : null |
| 742 | − <EngineBridge onEngine={handleEngine} /> |
735 | + } |
| 743 | − <SelectionMirror selectedUid={selectedUid} /> |
736 | + > |
| 744 | − <KaBrandBadge /> |
737 | + <KaMapView |
| 745 | − <MetroLignes /> |
738 | + theme={louKaMapTheme} |
| 746 | − <Tilt3DControl /> |
739 | + mapboxToken={MAPBOX_TOKEN} |
| 747 | − <DrawControl /> |
740 | + center={initialCamera ?? QUEBEC} |
| 748 | − <LocateControl /> |
741 | + zoom={(initialCamera ?? QUEBEC).zoom} |
| 749 | − |
742 | + pitch={50} |
| 750 | − {/* « Rechercher quand je déplace la carte » + zone divergée */} |
743 | + basemap={{ theme: "default", showLandmarks: true }} |
| |
744 | + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }} |
| |
745 | + navControl={false} |
| |
746 | + onMoveEnd={onMoveEnd} |
| |
747 | + onSelect={onSelect} |
| |
748 | + onHover={onHover} |
| |
749 | + onDraw={onDraw} |
| |
750 | + onClusterHover={setClusterTip} |
| |
751 | + > |
| |
752 | + <EngineBridge onEngine={handleEngine} /> |
| |
753 | + <SelectionMirror selectedUid={selectedUid} /> |
| |
754 | + <SheetAutoCollapse selectedUid={selectedUid} /> |
| |
755 | + <KaBrandBadge /> |
| |
756 | + <MetroLignes /> |
| |
757 | + |
| |
758 | + {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */} |
| |
759 | + <KaMapToolbar> |
| |
760 | + {!isMobile && ( |
| |
761 | + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup> |
| |
762 | + )} |
| |
763 | + <KaToolbarGroup> |
| |
764 | + <KaToolbar3D /> |
| |
765 | + <KaToolbarDraw /> |
| |
766 | + <KaToolbarLocate |
| |
767 | + onLocated={() => { |
| |
768 | + setUserLocked(true); |
| |
769 | + userLockedRef.current = true; |
| |
770 | + syncListToViewport(750); |
| |
771 | + }} |
| |
772 | + /> |
| |
773 | + </KaToolbarGroup> |
| |
774 | + </KaMapToolbar> |
| |
775 | + |
| |
776 | + {/* mode dessin : bandeau temporaire + CTA « N logements » */} |
| |
777 | + <KaDrawAreaMode |
| |
778 | + count={hasZone ? total : null} |
| |
779 | + formatCount={(n) => `${n.toLocaleString("fr-CA")} logement${n > 1 ? "s" : ""}`} |
| |
780 | + onClear={clearZone} |
| |
781 | + /> |
| |
782 | + |
| |
783 | + {/* « Rechercher quand je déplace la carte » + zone divergée */} |
| |
784 | + {!hasZone && !drawing && ( |
| 751 | <div className="ka-search-area" role="group" aria-label="Recherche géographique"> |
785 | <div className="ka-search-area" role="group" aria-label="Recherche géographique"> |
| 752 | − {dirty && !searchOnMove && !hasZone && ( |
786 | + {dirty && !searchOnMove && ( |
| 753 | <button type="button" className="ka-search-area-btn" |
787 | <button type="button" className="ka-search-area-btn" |
| 754 | onClick={searchThisArea} disabled={loading}> |
788 | onClick={searchThisArea} disabled={loading}> |
| 755 | {loading ? "Recherche…" : "Rechercher dans cette zone"} |
789 | {loading ? "Recherche…" : "Rechercher dans cette zone"} |
| 756 | </button> |
790 | </button> |
| 757 | )} |
791 | )} |
| 758 | − {!hasZone && !drawing && ( |
792 | + <label className="ka-search-area-auto"> |
| 759 | − <label className="ka-search-area-auto"> |
793 | + <input |
| 760 | − <input |
794 | + type="checkbox" |
| 761 | − type="checkbox" |
795 | + checked={searchOnMove} |
| 762 | − checked={searchOnMove} |
796 | + onChange={() => { |
| 763 | − onChange={() => { |
797 | + const next = !searchOnMove; |
| 764 | − const next = !searchOnMove; |
798 | + setSearchOnMove(next); |
| 765 | − setSearchOnMove(next); |
799 | + if (next && dirty) searchThisArea(); |
| 766 | − if (next && dirty) searchThisArea(); |
800 | + }} |
| 767 | − }} |
801 | + /> |
| 768 | − /> |
802 | + <span>Rechercher en déplaçant la carte</span> |
| 769 | − <span>Rechercher quand je déplace la carte</span> |
803 | + </label> |
| 770 | − </label> |
| |
| 771 | − )} |
| |
| 772 | </div> |
804 | </div> |
| |
805 | + )} |
| 773 | |
806 | |
| 774 | − {loading && ( |
807 | + {loading && ( |
| 775 | − <div className="ka-loading" role="status" aria-live="polite"> |
808 | + <div className="ka-loading" role="status" aria-live="polite"> |
| 776 | − <span className="ka-loading-dot" aria-hidden="true" /> |
809 | + <span className="ka-loading-dot" aria-hidden="true" /> |
| 777 | − Mise à jour des logements… |
810 | + Mise à jour des logements… |
| 778 | − </div> |
811 | + </div> |
| 779 | − )} |
812 | + )} |
| 780 | − |
| |
| 781 | − {total === 0 && !loading && ( |
| |
| 782 | − <div className="ka-empty" role="status"> |
| |
| 783 | − <strong>Aucun logement dans cette zone.</strong> |
| |
| 784 | − <span>Élargissez la carte ou modifiez vos filtres.</span> |
| |
| 785 | − </div> |
| |
| 786 | − )} |
| |
| 787 | − |
| |
| 788 | − {clusterTip && ( |
| |
| 789 | − <div |
| |
| 790 | − className="ka-cluster-tip" |
| |
| 791 | − style={{ left: clusterTip.x, top: clusterTip.y }} |
| |
| 792 | − > |
| |
| 793 | − {clusterTip.count.toLocaleString("fr-CA")} logements |
| |
| 794 | − {clusterTip.min != null && clusterTip.max != null && ( |
| |
| 795 | − <small> |
| |
| 796 | − {clusterTip.min === clusterTip.max |
| |
| 797 | − ? formatCompactPrice(clusterTip.min) |
| |
| 798 | − : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`} |
| |
| 799 | − {" "}/mois |
| |
| 800 | − </small> |
| |
| 801 | − )} |
| |
| 802 | − </div> |
| |
| 803 | − )} |
| |
| 804 | |
813 | |
| 805 | − {!isMobile && <PropertyPreview render={(p) => <LazyPreview p={p} />} />} |
814 | + {total === 0 && !loading && ( |
| 806 | − </KaMapView> |
815 | + <div className="ka-empty" role="status"> |
| |
816 | + <strong>Aucun logement dans cette zone.</strong> |
| |
817 | + <span>Élargissez la carte ou modifiez vos filtres.</span> |
| |
818 | + </div> |
| |
819 | + )} |
| 807 | |
820 | |
| 808 | − {/* ---------- carrousel mobile synchronisé aux marqueurs ---------- */} |
821 | + {clusterTip && !isMobile && ( |
| 809 | − {isMobile && listings.length > 0 && ( |
| |
| 810 | <div |
822 | <div |
| 811 | − className="ms-carousel" |
823 | + className="ka-cluster-tip" |
| 812 | − ref={carouselRef} |
824 | + style={{ left: clusterTip.x, top: clusterTip.y }} |
| 813 | − onScroll={onCarouselScroll} |
| |
| 814 | − aria-label="Annonces de la zone visible" |
| |
| 815 | > |
825 | > |
| 816 | − {listings.map((l) => ( |
826 | + {clusterTip.count.toLocaleString("fr-CA")} logements |
| 817 | − <Link |
827 | + {clusterTip.min != null && clusterTip.max != null && ( |
| 818 | − key={l.uid} |
828 | + <small> |
| 819 | − to={`/logement/${encodeURIComponent(l.uid)}`} |
829 | + {clusterTip.min === clusterTip.max |
| 820 | − data-uid={l.uid} |
830 | + ? formatCompactPrice(clusterTip.min) |
| 821 | − className={`ms-car-item${selectedUid === l.uid ? " sel" : ""}`} |
831 | + : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`} |
| 822 | − > |
832 | + {" "}/mois |
| 823 | − <SmartImg |
833 | + </small> |
| 824 | − src={l.images?.[0] ?? null} width_={160} |
| |
| 825 | − fallbackLabel={l.unit_type || undefined} |
| |
| 826 | − alt="" loading="lazy" decoding="async" |
| |
| 827 | − /> |
| |
| 828 | − <div className="ms-car-body"> |
| |
| 829 | − <div className="ms-car-price"> |
| |
| 830 | − {fmtPrice(l.price, l.price_label)} |
| |
| 831 | − <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact /> |
| |
| 832 | − </div> |
| |
| 833 | − <div className="ms-car-title">{l.title || l.address}</div> |
| |
| 834 | − <div className="ms-car-meta"> |
| |
| 835 | − {[l.unit_type, l.sector || l.city].filter(Boolean).join(" · ")} |
| |
| 836 | − </div> |
| |
| 837 | − </div> |
| |
| 838 | − </Link> |
| |
| 839 | − ))} |
| |
| 840 | − {total !== null && total > qy.page * PAGE_SIZE && ( |
| |
| 841 | − <button |
| |
| 842 | − className="ms-car-more" |
| |
| 843 | − onClick={(e) => { |
| |
| 844 | − e.preventDefault(); |
| |
| 845 | − setQy((q) => ({ ...q, page: q.page + 1 })); |
| |
| 846 | − carouselRef.current?.scrollTo({ left: 0 }); |
| |
| 847 | − }} |
| |
| 848 | − > |
| |
| 849 | − Page suivante → |
| |
| 850 | − </button> |
| |
| 851 | )} |
834 | )} |
| 852 | </div> |
835 | </div> |
| 853 | )} |
836 | )} |
| 854 | − </div> |
| |
| 855 | − </div> |
| |
| 856 | − ); |
| |
| 857 | −} |
| |
| 858 | |
837 | |
| 859 | −/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. |
838 | + <KaPropertyPreview render={(p) => <LazyPreview p={p} />} /> |
| 860 | − * (Le survol liste→carte passe directement par setHovered — < 100 ms.) */ |
839 | + </KaMapView> |
| 861 | −function SelectionMirror({ selectedUid }: { selectedUid: string | null }) { |
840 | + </KaMapShell> |
| 862 | − const map = useKaMap(); |
841 | + ); |
| 863 | − useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]); |
| |
| 864 | − return null; |
| |
| 865 | } |
842 | } |