SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
31.3 KB · 843 lines tsx
Raw Blame History
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