Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// search/MapSearch.tsx : le MODE CARTE (Ka Map System v2) — recherche5// synchronisée liste ↔ carte, plein viewport.6//7// UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent8// dans un état unique ; chaque changement déclenche UNE requête /api/search9// qui renvoie à la fois la page de liste et tous les points carte, triés10// pareil : le compteur de la liste est par construction égal au nombre de11// marqueurs, et l'index d'un point donne directement sa page de liste.12//13// v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est14// une prise de contrôle du viewport.15// · mobile : carte edge-to-edge, barre supérieure compacte, résultats en16// bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ;17// · desktop : split résultats|carte redimensionnable (ratio persisté),18// carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ;19// · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation),20// « Rechercher dans cette zone » contextuel, mode dessin temporaire avec21// CTA « N logements dans cette zone ».22//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.25// -----------------------------------------------------------------------------26import MetroLignes from "../components/MetroLignes";27import {28 useCallback, useEffect, useMemo, useRef, useState,29} from "react";30import { useNavigate } from "react-router-dom";31import "mapbox-gl/dist/mapbox-gl.css";32import "@groupe-ka/ka-maps/styles.css";33import type { MapProperty } from "@groupe-ka/ka-maps";34import {35 bboxToString, cameraFromParams, cameraToParams, formatCompactPrice,36} from "@groupe-ka/ka-maps";37import type { KaMap } from "@groupe-ka/ka-maps";38import {39 KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView,40 KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup,41 KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell,42} from "@groupe-ka/ka-maps/react";43import {44 Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,45 fetchListing, fetchSearch, fmtPrice, sourceName, thumb,46} from "../api";47import { useAccount } from "../account";48import ListingCard from "../components/ListingCard";49import Pager from "../components/Pager";50import FairValueBadge from "../components/FairValueBadge";51import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons";52import { louKaMapTheme } from "../kamaps/theme";53import { MAPBOX_TOKEN } from "../kamaps/config";54import { seenUids } from "./seen";5556const PAGE_SIZE = 20; // annonces par page (vue carte)57const QUEBEC = { lat: 46.82, lng: -71.25, zoom: 11 };5859const SORT_CHOICES: { key: SearchSort; label: string }[] = [60 { key: "recent", label: "Plus récentes" },61 { key: "prix", label: "Prix croissant" },62 { key: "prix_desc", label: "Prix décroissant" },63 { key: "deal", label: "Meilleures affaires" },64 { key: "ka", label: "Meilleur KA Score" },65];6667/** Points compacts → modèle canonique Ka Maps, avec écartement léger des68 * coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que69 * chaque pastille reste cliquable au zoom maximal (spiderfy discret). */70function pointsToProperties(points: SearchPoint[]): MapProperty[] {71 const byCoord = new Map<string, number>();72 const out: MapProperty[] = [];73 for (const [uid, lng, lat, price, fv] of points) {74 const key = `${lng},${lat}`;75 const n = byCoord.get(key) ?? 0;76 byCoord.set(key, n + 1);77 let lng2 = lng, lat2 = lat;78 if (n > 0) {79 // cercle de ~9 m autour du point d'origine, déterministe80 const angle = n * 2.4; // angle d'or : pas d'alignement81 lng2 = lng + Math.cos(angle) * 0.00011;82 lat2 = lat + Math.sin(angle) * 0.00008;83 }84 out.push({85 id: uid,86 appSource: "lou-ka",87 longitude: lng2,88 latitude: lat2,89 kind: "listing",90 listingType: "rent",91 price: price ?? undefined,92 highlight: fv === "s", // sous le marché93 originalUrl: `/logement/${encodeURIComponent(uid)}`,94 });95 }96 return out;97}9899/** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */100const polyToString = (poly: [number, number][]): string =>101 poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";");102103function polyFromString(text: string | null): [number, number][] | null {104 if (!text) return null;105 const pts: [number, number][] = [];106 for (const part of text.split(";")) {107 const [lng, lat] = part.split(",").map(Number);108 if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;109 pts.push([lng as number, lat as number]);110 }111 return pts.length >= 3 ? pts : null;112}113114/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */115function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {116 const map = useKaMap();117 useEffect(() => {118 onEngine(map);119 return () => onEngine(null);120 }, [map, onEngine]);121 return null;122}123124/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */125function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {126 const map = useKaMap();127 useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);128 return null;129}130131/** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser132 * toute la place à la carte + fiche contextuelle. */133function 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}143144/** Fiche contextuelle : les points carte sont volontairement compacts, le145 * détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en146 * cache) — structure ka-prevcard du Ka Map System, contenu Lou-Ka. */147const previewCache = new Map<string, Listing>();148149function LazyPreview({ p }: { p: MapProperty }) {150 const navigate = useNavigate();151 const { favs, toggleFav } = useAccount();152 const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);153 useEffect(() => {154 if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }155 let dead = false;156 setL(null);157 fetchListing(p.id)158 .then((r) => {159 previewCache.set(p.id, r);160 if (previewCache.size > 80) {161 const first = previewCache.keys().next().value;162 if (first) previewCache.delete(first);163 }164 if (!dead) setL(r);165 })166 .catch(() => {});167 return () => { dead = true; };168 }, [p.id]);169170 const img = l?.images?.[0] ?? null;171 const fav = favs.has(p.id);172 const fiche = `/logement/${encodeURIComponent(p.id)}`;173 return (174 <>175 <div className="ka-prevcard-media">176 {img ? (177 <img src={thumb(img, 480)} alt="" loading="lazy" />178 ) : (179 <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>180 )}181 </div>182 <div className="ka-prevcard-body">183 <div className="ka-prevcard-price">184 {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"}185 {(l?.price ?? p.price) != null && <small>/mois</small>}186 {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}187 </div>188 <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Chargement…"}</div>189 <div className="ka-prevcard-meta">190 {[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 <a199 className="ka-prevcard-cta"200 href={fiche}201 onClick={(e) => { e.preventDefault(); navigate(fiche); }}202 >203 Voir la fiche →204 </a>205 <button206 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>214 </div>215 </div>216 </>217 );218}219220export interface MapSearchProps {221 filters: ListingFilters;222 /** Compteur partagé remonté à l'entête de résultats de la page. */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;230}231232interface Query {233 bboxStr: string | null; // zone visible (gestes utilisateur uniquement)234 poly: string | null; // zone dessinée (prime sur la zone visible)235 sort: SearchSort;236 page: number;237}238239export default function MapSearch({240 filters, onTotal, onExit, onOpenFilters, filtersCount = 0,241}: MapSearchProps) {242 const initialParams = useMemo(243 () => new URLSearchParams(window.location.search), []);244 const initialCamera = useMemo(245 () => cameraFromParams(initialParams), [initialParams]);246247 // --- état de recherche partagé (la « seule vérité ») ---248 const [qy, setQy] = useState<Query>(() => {249 const sort = (initialParams.get("tri") as SearchSort | null) ?? "recent";250 const p = Number(initialParams.get("page"));251 return {252 bboxStr: null, // posé au premier geste ou « zone » restaurée253 poly: initialParams.get("zone"),254 sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "recent",255 page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1,256 };257 });258 const [searchOnMove, setSearchOnMove] = useState(259 initialParams.get("move") !== "0");260 const [dirty, setDirty] = useState(false); // zone divergée (mode manuel)261 const [userLocked, setUserLocked] = useState(initialCamera !== null);262 const [drawing, setDrawing] = useState(false);263264 // URL avec caméra partagée : attendre la carte pour connaître la zone265 // visible AVANT la première requête (la liste doit refléter cette zone).266 const [bootstrapped, setBootstrapped] = useState(initialCamera === null);267 const bootstrappedRef = useRef(bootstrapped);268269 const [res, setRes] = useState<SearchResponse | null>(null);270 const [points, setPoints] = useState<SearchPoint[]>([]);271 const [loading, setLoading] = useState(false);272 const [error, setError] = useState<string | null>(null);273274 const [engine, setEngine] = useState<KaMap | null>(null);275 const [selectedUid, setSelectedUid] = useState<string | null>(null);276 const [mapHoverUid, setMapHoverUid] = useState<string | null>(null);277 const [hoverOffscreen, setHoverOffscreen] = useState<"haut" | "bas" | null>(null);278 const [clusterTip, setClusterTip] = useState<279 { count: number; min: number | null; max: number | null; x: number; y: number } | null280 >(null);281 const [isMobile, setIsMobile] = useState(282 () => window.matchMedia("(max-width: 780px)").matches);283284 // --- refs de pilotage (valeurs à jour dans les callbacks carte) ---285 const engineRef = useRef<KaMap | null>(null);286 const qyRef = useRef(qy); qyRef.current = qy;287 const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove;288 const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked;289 const seqRef = useRef(0);290 const ctrlRef = useRef<AbortController | null>(null);291 const debounceMsRef = useRef(0);292 const pointsKeyRef = useRef<string>("");293 const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat294 const pendingScrollUidRef = useRef<string | null>(null);295 const listRef = useRef<HTMLDivElement | null>(null);296297 const filtersClean = useMemo(() => {298 const f: Record<string, string> = {};299 for (const [k, v] of Object.entries(filters)) {300 if (v && k !== "sort") f[k] = String(v);301 }302 return f;303 }, [filters]);304 const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]);305306 useEffect(() => {307 const mq = window.matchMedia("(max-width: 780px)");308 const update = () => setIsMobile(mq.matches);309 mq.addEventListener("change", update);310 return () => mq.removeEventListener("change", update);311 }, []);312313 // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main314 const firstFiltersRun = useRef(true);315 useEffect(() => {316 if (firstFiltersRun.current) { firstFiltersRun.current = false; return; }317 if (!userLockedRef.current) {318 fitAfterRef.current = true;319 setQy((q) => ({ ...q, page: 1, bboxStr: null }));320 } else {321 setQy((q) => ({ ...q, page: 1 }));322 }323 }, [filtersKey]);324325 // --- LA requête unifiée : une réponse = page de liste + points carte ---326 const qyKey = useMemo(() => JSON.stringify(qy), [qy]);327 useEffect(() => {328 if (!bootstrapped) return;329 const { bboxStr, poly, sort, page } = qyRef.current;330 const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`;331 const wait = debounceMsRef.current;332 debounceMsRef.current = 0;333334 const timer = window.setTimeout(() => {335 ctrlRef.current?.abort();336 const ctrl = new AbortController();337 ctrlRef.current = ctrl;338 const mySeq = ++seqRef.current;339 setLoading(true);340 setError(null);341 fetchSearch(342 {343 ...filtersClean,344 bbox: poly ? undefined : bboxStr ?? undefined,345 poly: poly ?? undefined,346 sort, page, page_size: PAGE_SIZE,347 include: pKey === pointsKeyRef.current ? "liste" : "tout",348 },349 ctrl.signal,350 )351 .then((r) => {352 if (mySeq !== seqRef.current) return; // réponse périmée : ignorée353 setLoading(false);354 setRes(r);355 if (r.points) {356 setPoints(r.points);357 pointsKeyRef.current = pKey;358 }359 if (r.page !== qyRef.current.page) {360 // page ramenée dans les bornes par le serveur (filtre resserré)361 setQy((q) => ({ ...q, page: r.page }));362 }363 // recadrage sur les résultats — si la carte n'est pas encore364 // prête (premier chargement), l'effet données→carte le fera365 if (fitAfterRef.current && engineRef.current && r.points?.length) {366 fitAfterRef.current = false;367 engineRef.current.fitToProperties(pointsToProperties(r.points), {368 padding: 72, maxZoom: 16,369 });370 }371 })372 .catch((e) => {373 if (ctrl.signal.aborted || mySeq !== seqRef.current) return;374 setLoading(false);375 setError(String(e));376 });377 }, wait);378 return () => window.clearTimeout(timer);379 }, [filtersKey, qyKey, filtersClean, bootstrapped]);380381 // compteur partagé remonté à la page382 useEffect(() => {383 onTotal?.(res?.total ?? null);384 return () => onTotal?.(null);385 }, [res?.total, onTotal]);386387 // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) ---388 useEffect(() => {389 const url = new URL(window.location.href);390 const set = (k: string, v: string | null) =>391 v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v);392 set("tri", qy.sort === "recent" ? null : qy.sort);393 set("page", qy.page > 1 ? String(qy.page) : null);394 set("zone", qy.poly);395 set("move", searchOnMove ? null : "0");396 window.history.replaceState(null, "", url);397 }, [qy.sort, qy.page, qy.poly, searchOnMove]);398399 // --- moteur prêt : polygone restauré, annonces « vues » ---400 const handleEngine = useCallback((m: KaMap | null) => {401 engineRef.current = m;402 setEngine(m);403 if (!m) return;404 m.setSeenIds(seenUids());405 const zone = polyFromString(qyRef.current.poly);406 if (zone) m.setDrawnPolygon(zone);407 // Caméra restaurée depuis l'URL : la zone visible devient la contrainte408 // spatiale de la première requête (lien partagé = même recherche).409 if (!bootstrappedRef.current) {410 bootstrappedRef.current = true;411 const b = m.currentBBox();412 if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) }));413 setBootstrapped(true);414 }415 }, []);416417 // --- données → carte (poussées, pas d'adaptateur : une seule requête) ---418 const mapProperties = useMemo(() => pointsToProperties(points), [points]);419 useEffect(() => {420 if (!engine) return;421 engine.setProperties(mapProperties);422 engine.setSeenIds(seenUids());423 // premier chargement : la réponse est souvent arrivée avant la carte —424 // le recadrage en attente s'applique dès que les deux sont prêts425 if (fitAfterRef.current && mapProperties.length > 0) {426 fitAfterRef.current = false;427 engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 });428 }429 }, [engine, mapProperties]);430431 // index uid → position dans le tri (= page de liste, sans requête)432 const uidIndex = useMemo(() => {433 const m = new Map<string, number>();434 points.forEach((p, i) => m.set(p[0], i));435 return m;436 }, [points]);437438 // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la439 // 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 }, []);449450 useEffect(() => {451 const handler = (e: Event) => {452 const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;453 const m = engineRef.current;454 if (!m) return;455 setUserLocked(true);456 userLockedRef.current = true;457 m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });458 syncListToViewport(950);459 };460 window.addEventListener("louka:geolocate", handler);461 return () => window.removeEventListener("louka:geolocate", handler);462 }, [syncListToViewport]);463464 // --- geste sur la carte ---465 const onMoveEnd = useCallback(466 (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => {467 const url = new URL(window.location.href);468 url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();469 window.history.replaceState(null, "", url);470 if (!byUser) return; // fitBounds/recentrages : rien471 setUserLocked(true);472 userLockedRef.current = true;473 if (qyRef.current.poly) return; // la zone dessinée prime474 const engineNow = engineRef.current;475 const b = engineNow?.currentBBox();476 if (!b) return;477 const bboxStr = bboxToString(b);478 if (searchOnMoveRef.current) {479 debounceMsRef.current = 280; // fin de geste + petite marge480 setDirty(false);481 setQy((q) => ({ ...q, page: 1, bboxStr }));482 } else {483 setDirty(true);484 }485 }, []);486487 const searchThisArea = useCallback(() => {488 const b = engineRef.current?.currentBBox();489 if (!b) return;490 setDirty(false);491 setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) }));492 }, []);493494 const refitToResults = useCallback(() => {495 setUserLocked(false);496 userLockedRef.current = false;497 setDirty(false);498 if (qyRef.current.bboxStr === null) {499 // requête déjà « tous les résultats » : recadrage immédiat500 engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 });501 } else {502 fitAfterRef.current = true;503 setQy((q) => ({ ...q, page: 1, bboxStr: null }));504 }505 }, []);506507 const widenArea = useCallback(() => {508 const m = engineRef.current;509 if (!m) return;510 // « Élargir la zone » : dézoome, puis recherche élargie explicite.511 m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {512 zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,513 });514 syncListToViewport(700);515 }, [syncListToViewport]);516517 // --- polygone dessiné ---518 const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {519 setDrawing(isDrawing);520 if (isDrawing) return;521 const polyStr = polygon ? polyToString(polygon) : null;522 if (polyStr === qyRef.current.poly) return;523 if (polyStr) {524 fitAfterRef.current = true;525 setDirty(false);526 setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null }));527 } else {528 const b = engineRef.current?.currentBBox();529 setQy((q) => ({530 ...q, page: 1, poly: null,531 bboxStr: userLockedRef.current && b ? bboxToString(b) : null,532 }));533 }534 }, []);535536 const clearZone = useCallback(() => {537 engineRef.current?.clearDrawnPolygon(); // onDraw fera le reste538 }, []);539540 // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page ---541 const scrollToCard = useCallback((uid: string, pulse: boolean) => {542 const sel = `[data-uid="${CSS.escape(uid)}"]`;543 const card = listRef.current?.querySelector<HTMLElement>(sel);544 if (card) {545 card.scrollIntoView({ behavior: "smooth", block: "nearest" });546 if (pulse) {547 card.classList.remove("map-card-pulse");548 void card.offsetWidth; // relance l'animation549 card.classList.add("map-card-pulse");550 }551 }552 }, []);553554 const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {555 const uid = p?.id ?? null;556 setSelectedUid(uid);557 if (!uid) return;558 const idx = uidIndex.get(uid);559 if (idx === undefined) return;560 const targetPage = Math.floor(idx / PAGE_SIZE) + 1;561 if (targetPage !== qyRef.current.page) {562 pendingScrollUidRef.current = uid;563 setQy((q) => ({ ...q, page: targetPage }));564 } else {565 scrollToCard(uid, origin === "map");566 }567 }, [uidIndex, scrollToCard]);568569 // défilement différé (la page vient de changer pour révéler l'annonce)570 useEffect(() => {571 const uid = pendingScrollUidRef.current;572 if (!uid || !res) return;573 if (res.listings.some((l) => l.uid === uid)) {574 pendingScrollUidRef.current = null;575 requestAnimationFrame(() => scrollToCard(uid, true));576 }577 }, [res, scrollToCard]);578579 // --- survol carte → liste : surlignage + indicateur hors écran ---580 const onHover = useCallback((p: MapProperty | null) => {581 setMapHoverUid(p?.id ?? null);582 }, []);583 useEffect(() => {584 if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; }585 const card = listRef.current.querySelector<HTMLElement>(586 `[data-uid="${CSS.escape(mapHoverUid)}"]`);587 if (!card) { setHoverOffscreen(null); return; }588 const list = listRef.current.getBoundingClientRect();589 const rect = card.getBoundingClientRect();590 if (rect.bottom < list.top + 8) setHoverOffscreen("haut");591 else if (rect.top > list.bottom - 8) setHoverOffscreen("bas");592 else setHoverOffscreen(null);593 }, [mapHoverUid]);594595 // --- clic sur une carte d'annonce : recentre + fiche (1er clic),596 // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---597 const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {598 if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet599 if (selectedUid === uid) return; // 2e clic : fiche600 e.preventDefault();601 engineRef.current?.select(uid, "app");602 }, [selectedUid]);603604 const listings = res?.listings ?? [];605 const total = res?.total ?? null;606 const unpositioned = res?.unpositioned ?? 0;607 const hasZone = qy.poly !== null;608609 /* ------------------------- volets du shell ------------------------- */610611 const listHeader = (612 <div className="ms2-head">613 <div className="ms2-count" role="status" aria-live="polite">614 {total === null ? "…" : (615 <>616 <b>{total.toLocaleString("fr-CA")}</b>617 {" "}logement{total !== null && total > 1 ? "s" : ""}618 {hasZone ? " · zone dessinée" : ""}619 </>620 )}621 {unpositioned > 0 && (622 <span623 className="ms-nopos"624 title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement"625 >626 +{unpositioned} sans position627 </span>628 )}629 </div>630 <label className="ms2-sort">631 <select632 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 );645646 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ésultats651 </button>652 )}653654 {loading && <div className="ms-progress" aria-hidden="true" />}655656 {error && (657 <div className="ms-empty">658 <h3>Impossible de charger les résultats</h3>659 <p>{error}</p>660 </div>661 )}662663 {!error && total === 0 && (664 <div className="ms-empty" role="status">665 <div className="big"><IcoSearch size={34} /></div>666 <h3>Aucun logement dans cette zone</h3>667 <p>Élargissez la carte ou modifiez vos filtres.</p>668 <div className="ms-empty-actions">669 <button className="btn btn-primary" onClick={widenArea}>670 Élargir la zone671 </button>672 {hasZone && (673 <button className="btn btn-ghost" onClick={clearZone}>674 Effacer la zone dessinée675 </button>676 )}677 </div>678 </div>679 )}680681 {!error && listings.map((l) => (682 <div683 key={l.uid}684 data-uid={l.uid}685 className={686 "map-card" +687 (selectedUid === l.uid ? " map-card-sel" : "") +688 (mapHoverUid === l.uid ? " map-card-hover" : "")689 }690 onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}691 onMouseLeave={() => engineRef.current?.setHovered(null, "app")}692 onFocus={() => engineRef.current?.setHovered(l.uid, "app")}693 onBlur={() => engineRef.current?.setHovered(null, "app")}694 onClickCapture={(e) => onCardClick(e, l.uid)}695 >696 <ListingCard l={l} />697 </div>698 ))}699700 {!error && total !== null && total > 0 && (701 <Pager702 page={qy.page} pageSize={PAGE_SIZE} total={total}703 onPage={(p) => {704 setQy((q) => ({ ...q, page: p }));705 // le défileur est le parent (ka-pane-body / ka-sheet-body)706 listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });707 }}708 />709 )}710711 {hoverOffscreen && (712 <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">713 {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"}714 </div>715 )}716 </div>717 );718719 return (720 <KaMapShell721 className="ms2"722 brand={<span className="ms2-brand"><b>Lou·Ka</b><span>Carte</span></span>}723 onExit={onExit}724 exitLabel="Liste"725 storageKey="louka-map-split"726 listHeader={listHeader}727 list={listPane}728 topExtras={729 onOpenFilters ? (730 <button className="ka-top-btn" onClick={onOpenFilters}>731 <IcoSliders size={13} /> Filtres732 {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}733 </button>734 ) : null735 }736 >737 <KaMapView738 theme={louKaMapTheme}739 mapboxToken={MAPBOX_TOKEN}740 center={initialCamera ?? QUEBEC}741 zoom={(initialCamera ?? QUEBEC).zoom}742 pitch={50}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 />757758 {/* 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 <KaToolbarLocate767 onLocated={() => {768 setUserLocked(true);769 userLockedRef.current = true;770 syncListToViewport(750);771 }}772 />773 </KaToolbarGroup>774 </KaMapToolbar>775776 {/* mode dessin : bandeau temporaire + CTA « N logements » */}777 <KaDrawAreaMode778 count={hasZone ? total : null}779 formatCount={(n) => `${n.toLocaleString("fr-CA")} logement${n > 1 ? "s" : ""}`}780 onClear={clearZone}781 />782783 {/* « Rechercher quand je déplace la carte » + zone divergée */}784 {!hasZone && !drawing && (785 <div className="ka-search-area" role="group" aria-label="Recherche géographique">786 {dirty && !searchOnMove && (787 <button type="button" className="ka-search-area-btn"788 onClick={searchThisArea} disabled={loading}>789 {loading ? "Recherche…" : "Rechercher dans cette zone"}790 </button>791 )}792 <label className="ka-search-area-auto">793 <input794 type="checkbox"795 checked={searchOnMove}796 onChange={() => {797 const next = !searchOnMove;798 setSearchOnMove(next);799 if (next && dirty) searchThisArea();800 }}801 />802 <span>Rechercher en déplaçant la carte</span>803 </label>804 </div>805 )}806807 {loading && (808 <div className="ka-loading" role="status" aria-live="polite">809 <span className="ka-loading-dot" aria-hidden="true" />810 Mise à jour des logements…811 </div>812 )}813814 {total === 0 && !loading && (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 )}820821 {clusterTip && !isMobile && (822 <div823 className="ka-cluster-tip"824 style={{ left: clusterTip.x, top: clusterTip.y }}825 >826 {clusterTip.count.toLocaleString("fr-CA")} logements827 {clusterTip.min != null && clusterTip.max != null && (828 <small>829 {clusterTip.min === clusterTip.max830 ? formatCompactPrice(clusterTip.min)831 : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}832 {" "}/mois833 </small>834 )}835 </div>836 )}837838 <KaPropertyPreview render={(p) => <LazyPreview p={p} />} />839 </KaMapView>840 </KaMapShell>841 );842}843