SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
31.1 KB · 841 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// search/MapSearch.tsx: the MAP MODE (Ka Map System v2) — synchronized5// list ↔ map search, full 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 {27  useCallback, useEffect, useMemo, useRef, useState,28} from "react";29import { useNavigate } from "react-router-dom";30import "mapbox-gl/dist/mapbox-gl.css";31import "@groupe-ka/ka-maps/styles.css";32import type { MapProperty } from "@groupe-ka/ka-maps";33import {34  bboxToString, cameraFromParams, cameraToParams, formatCompactPrice,35} from "@groupe-ka/ka-maps";36import type { KaMap } from "@groupe-ka/ka-maps";37import {38  KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView,39  KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup,40  KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell,41} from "@groupe-ka/ka-maps/react";42import {43  Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,44  fetchListing, fetchSearch, fmtPrice, sourceName, thumb,45} from "../api";46import { useAccount } from "../account";47import ListingCard from "../components/ListingCard";48import Pager from "../components/Pager";49import FairValueBadge from "../components/FairValueBadge";50import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons";51import { louKaMapTheme } from "../kamaps/theme";52import { MAPBOX_TOKEN } from "../kamaps/config";53import { seenUids } from "./seen";5455const PAGE_SIZE = 20;                       // listings per page (map view)56const CANADA = { lat: 47.5, lng: -84.5, zoom: 4.4 };5758const SORT_CHOICES: { key: SearchSort; label: string }[] = [59  { key: "recent", label: "Most recent" },60  { key: "prix", label: "Price: low to high" },61  { key: "prix_desc", label: "Price: high to low" },62  { key: "deal", label: "Best deals" },63  { key: "ka", label: "Best KA Score" },64];6566/** Points compacts → modèle canonique Ka Maps, avec écartement léger des67 *  coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que68 *  chaque pastille reste cliquable au zoom maximal (spiderfy discret). */69function pointsToProperties(points: SearchPoint[]): MapProperty[] {70  const byCoord = new Map<string, number>();71  const out: MapProperty[] = [];72  for (const [uid, lng, lat, price, fv] of points) {73    const key = `${lng},${lat}`;74    const n = byCoord.get(key) ?? 0;75    byCoord.set(key, n + 1);76    let lng2 = lng, lat2 = lat;77    if (n > 0) {78      // cercle de ~9 m autour du point d'origine, déterministe79      const angle = n * 2.4;                       // angle d'or : pas d'alignement80      lng2 = lng + Math.cos(angle) * 0.00011;81      lat2 = lat + Math.sin(angle) * 0.00008;82    }83    out.push({84      id: uid,85      appSource: "rent-ka",86      longitude: lng2,87      latitude: lat2,88      kind: "listing",89      listingType: "rent",90      price: price ?? undefined,91      highlight: fv === "s",                       // below market92      originalUrl: `/listing/${encodeURIComponent(uid)}`,93    });94  }95  return out;96}9798/** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */99const polyToString = (poly: [number, number][]): string =>100  poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";");101102function polyFromString(text: string | null): [number, number][] | null {103  if (!text) return null;104  const pts: [number, number][] = [];105  for (const part of text.split(";")) {106    const [lng, lat] = part.split(",").map(Number);107    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;108    pts.push([lng as number, lat as number]);109  }110  return pts.length >= 3 ? pts : null;111}112113/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */114function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {115  const map = useKaMap();116  useEffect(() => {117    onEngine(map);118    return () => onEngine(null);119  }, [map, onEngine]);120  return null;121}122123/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */124function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {125  const map = useKaMap();126  useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);127  return null;128}129130/** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser131 *  toute la place à la carte + fiche contextuelle. */132function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {133  const shell = useKaShell();134  const shellRef = useRef(shell);135  shellRef.current = shell;136  useEffect(() => {137    const s = shellRef.current;138    if (selectedUid && s?.isMobile) s.setSheet("mini");139  }, [selectedUid]);140  return null;141}142143/** Fiche contextuelle : les points carte sont volontairement compacts, le144 *  détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en145 *  cache) — ka-prevcard structure of the Ka Map System, Rent-Ka content. */146const previewCache = new Map<string, Listing>();147148function LazyPreview({ p }: { p: MapProperty }) {149  const navigate = useNavigate();150  const { favs, toggleFav } = useAccount();151  const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);152  useEffect(() => {153    if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }154    let dead = false;155    setL(null);156    fetchListing(p.id)157      .then((r) => {158        previewCache.set(p.id, r);159        if (previewCache.size > 80) {160          const first = previewCache.keys().next().value;161          if (first) previewCache.delete(first);162        }163        if (!dead) setL(r);164      })165      .catch(() => {});166    return () => { dead = true; };167  }, [p.id]);168169  const img = l?.images?.[0] ?? null;170  const fav = favs.has(p.id);171  const fiche = `/listing/${encodeURIComponent(p.id)}`;172  return (173    <>174      <div className="ka-prevcard-media">175        {img ? (176          <img src={thumb(img, 480)} alt="" loading="lazy" />177        ) : (178          <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>179        )}180      </div>181      <div className="ka-prevcard-body">182        <div className="ka-prevcard-price">183          {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `$${p.price.toLocaleString("en-CA")}` : "…"}184          {(l?.price ?? p.price) != null && <small>/month</small>}185          {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}186        </div>187        <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Loading…"}</div>188        <div className="ka-prevcard-meta">189          {[190            l?.unit_type,191            l?.area_sqft ? `${Math.round(l.area_sqft)} sq ft` : "",192            l?.availability,193            l ? sourceName(l.source) : "",194          ].filter(Boolean).join(" · ")}195        </div>196        <div className="ka-prevcard-actions">197          <a198            className="ka-prevcard-cta"199            href={fiche}200            onClick={(e) => { e.preventDefault(); navigate(fiche); }}201          >202            See the listing →203          </a>204          <button205            type="button"206            className={`ka-prevcard-fav${fav ? " on" : ""}`}207            onClick={() => toggleFav(p.id)}208            aria-label={fav ? "Remove from saved" : "Save this rental"}209            aria-pressed={fav}210          >211            <IcoHeart size={16} filled={fav} />212          </button>213        </div>214      </div>215    </>216  );217}218219export interface MapSearchProps {220  filters: ListingFilters;221  /** Compteur partagé remonté à l'entête de résultats de la page. */222  onTotal?: (total: number | null) => void;223  /** Quitter le mode carte (retour à la vue liste). */224  onExit?: () => void;225  /** Ouvrir la feuille de filtres de la page (le shell n'a qu'un bouton). */226  onOpenFilters?: () => void;227  /** Nombre de filtres actifs (badge du bouton Filtres). */228  filtersCount?: number;229}230231interface Query {232  bboxStr: string | null;     // zone visible (gestes utilisateur uniquement)233  poly: string | null;        // zone dessinée (prime sur la zone visible)234  sort: SearchSort;235  page: number;236}237238export default function MapSearch({239  filters, onTotal, onExit, onOpenFilters, filtersCount = 0,240}: MapSearchProps) {241  const initialParams = useMemo(242    () => new URLSearchParams(window.location.search), []);243  const initialCamera = useMemo(244    () => cameraFromParams(initialParams), [initialParams]);245246  // --- état de recherche partagé (la « seule vérité ») ---247  const [qy, setQy] = useState<Query>(() => {248    const sort = (initialParams.get("sort") as SearchSort | null) ?? "recent";249    const p = Number(initialParams.get("page"));250    return {251      bboxStr: null,          // posé au premier geste ou « zone » restaurée252      poly: initialParams.get("area"),253      sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "recent",254      page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1,255    };256  });257  const [searchOnMove, setSearchOnMove] = useState(258    initialParams.get("move") !== "0");259  const [dirty, setDirty] = useState(false);          // zone divergée (mode manuel)260  const [userLocked, setUserLocked] = useState(initialCamera !== null);261  const [drawing, setDrawing] = useState(false);262263  // URL avec caméra partagée : attendre la carte pour connaître la zone264  // visible AVANT la première requête (la liste doit refléter cette zone).265  const [bootstrapped, setBootstrapped] = useState(initialCamera === null);266  const bootstrappedRef = useRef(bootstrapped);267268  const [res, setRes] = useState<SearchResponse | null>(null);269  const [points, setPoints] = useState<SearchPoint[]>([]);270  const [loading, setLoading] = useState(false);271  const [error, setError] = useState<string | null>(null);272273  const [engine, setEngine] = useState<KaMap | null>(null);274  const [selectedUid, setSelectedUid] = useState<string | null>(null);275  const [mapHoverUid, setMapHoverUid] = useState<string | null>(null);276  const [hoverOffscreen, setHoverOffscreen] = useState<"up" | "down" | null>(null);277  const [clusterTip, setClusterTip] = useState<278    { count: number; min: number | null; max: number | null; x: number; y: number } | null279  >(null);280  const [isMobile, setIsMobile] = useState(281    () => window.matchMedia("(max-width: 780px)").matches);282283  // --- refs de pilotage (valeurs à jour dans les callbacks carte) ---284  const engineRef = useRef<KaMap | null>(null);285  const qyRef = useRef(qy); qyRef.current = qy;286  const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove;287  const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked;288  const seqRef = useRef(0);289  const ctrlRef = useRef<AbortController | null>(null);290  const debounceMsRef = useRef(0);291  const pointsKeyRef = useRef<string>("");292  const fitAfterRef = useRef(initialCamera === null);   // recadrer au 1er résultat293  const pendingScrollUidRef = useRef<string | null>(null);294  const listRef = useRef<HTMLDivElement | null>(null);295296  const filtersClean = useMemo(() => {297    const f: Record<string, string> = {};298    for (const [k, v] of Object.entries(filters)) {299      if (v && k !== "sort") f[k] = String(v);300    }301    return f;302  }, [filters]);303  const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]);304305  useEffect(() => {306    const mq = window.matchMedia("(max-width: 780px)");307    const update = () => setIsMobile(mq.matches);308    mq.addEventListener("change", update);309    return () => mq.removeEventListener("change", update);310  }, []);311312  // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main313  const firstFiltersRun = useRef(true);314  useEffect(() => {315    if (firstFiltersRun.current) { firstFiltersRun.current = false; return; }316    if (!userLockedRef.current) {317      fitAfterRef.current = true;318      setQy((q) => ({ ...q, page: 1, bboxStr: null }));319    } else {320      setQy((q) => ({ ...q, page: 1 }));321    }322  }, [filtersKey]);323324  // --- LA requête unifiée : une réponse = page de liste + points carte ---325  const qyKey = useMemo(() => JSON.stringify(qy), [qy]);326  useEffect(() => {327    if (!bootstrapped) return;328    const { bboxStr, poly, sort, page } = qyRef.current;329    const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`;330    const wait = debounceMsRef.current;331    debounceMsRef.current = 0;332333    const timer = window.setTimeout(() => {334      ctrlRef.current?.abort();335      const ctrl = new AbortController();336      ctrlRef.current = ctrl;337      const mySeq = ++seqRef.current;338      setLoading(true);339      setError(null);340      fetchSearch(341        {342          ...filtersClean,343          bbox: poly ? undefined : bboxStr ?? undefined,344          poly: poly ?? undefined,345          sort, page, page_size: PAGE_SIZE,346          include: pKey === pointsKeyRef.current ? "liste" : "tout",347        },348        ctrl.signal,349      )350        .then((r) => {351          if (mySeq !== seqRef.current) return;   // réponse périmée : ignorée352          setLoading(false);353          setRes(r);354          if (r.points) {355            setPoints(r.points);356            pointsKeyRef.current = pKey;357          }358          if (r.page !== qyRef.current.page) {359            // page ramenée dans les bornes par le serveur (filtre resserré)360            setQy((q) => ({ ...q, page: r.page }));361          }362          // recadrage sur les résultats — si la carte n'est pas encore363          // prête (premier chargement), l'effet données→carte le fera364          if (fitAfterRef.current && engineRef.current && r.points?.length) {365            fitAfterRef.current = false;366            engineRef.current.fitToProperties(pointsToProperties(r.points), {367              padding: 72, maxZoom: 16,368            });369          }370        })371        .catch((e) => {372          if (ctrl.signal.aborted || mySeq !== seqRef.current) return;373          setLoading(false);374          setError(String(e));375        });376    }, wait);377    return () => window.clearTimeout(timer);378  }, [filtersKey, qyKey, filtersClean, bootstrapped]);379380  // compteur partagé remonté à la page381  useEffect(() => {382    onTotal?.(res?.total ?? null);383    return () => onTotal?.(null);384  }, [res?.total, onTotal]);385386  // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) ---387  useEffect(() => {388    const url = new URL(window.location.href);389    const set = (k: string, v: string | null) =>390      v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v);391    set("sort", qy.sort === "recent" ? null : qy.sort);392    set("page", qy.page > 1 ? String(qy.page) : null);393    set("area", qy.poly);394    set("move", searchOnMove ? null : "0");395    window.history.replaceState(null, "", url);396  }, [qy.sort, qy.page, qy.poly, searchOnMove]);397398  // --- moteur prêt : polygone restauré, annonces « vues » ---399  const handleEngine = useCallback((m: KaMap | null) => {400    engineRef.current = m;401    setEngine(m);402    if (!m) return;403    m.setSeenIds(seenUids());404    const zone = polyFromString(qyRef.current.poly);405    if (zone) m.setDrawnPolygon(zone);406    // Caméra restaurée depuis l'URL : la zone visible devient la contrainte407    // spatiale de la première requête (lien partagé = même recherche).408    if (!bootstrappedRef.current) {409      bootstrappedRef.current = true;410      const b = m.currentBBox();411      if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) }));412      setBootstrapped(true);413    }414  }, []);415416  // --- données → carte (poussées, pas d'adaptateur : une seule requête) ---417  const mapProperties = useMemo(() => pointsToProperties(points), [points]);418  useEffect(() => {419    if (!engine) return;420    engine.setProperties(mapProperties);421    engine.setSeenIds(seenUids());422    // premier chargement : la réponse est souvent arrivée avant la carte —423    // le recadrage en attente s'applique dès que les deux sont prêts424    if (fitAfterRef.current && mapProperties.length > 0) {425      fitAfterRef.current = false;426      engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 });427    }428  }, [engine, mapProperties]);429430  // index uid → position dans le tri (= page de liste, sans requête)431  const uidIndex = useMemo(() => {432    const m = new Map<string, number>();433    points.forEach((p, i) => m.set(p[0], i));434    return m;435  }, [points]);436437  // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la438  //     position et synchroniser la liste sur cette zone ---439  const syncListToViewport = useCallback((delayMs: number) => {440    window.setTimeout(() => {441      const b = engineRef.current?.currentBBox();442      if (b) {443        setDirty(false);444        setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) }));445      }446    }, delayMs);447  }, []);448449  useEffect(() => {450    const handler = (e: Event) => {451      const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;452      const m = engineRef.current;453      if (!m) return;454      setUserLocked(true);455      userLockedRef.current = true;456      m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });457      syncListToViewport(950);458    };459    window.addEventListener("rentka:geolocate", handler);460    return () => window.removeEventListener("rentka:geolocate", handler);461  }, [syncListToViewport]);462463  // --- geste sur la carte ---464  const onMoveEnd = useCallback(465    (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => {466      const url = new URL(window.location.href);467      url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();468      window.history.replaceState(null, "", url);469      if (!byUser) return;                        // fitBounds/recentrages : rien470      setUserLocked(true);471      userLockedRef.current = true;472      if (qyRef.current.poly) return;             // la zone dessinée prime473      const engineNow = engineRef.current;474      const b = engineNow?.currentBBox();475      if (!b) return;476      const bboxStr = bboxToString(b);477      if (searchOnMoveRef.current) {478        debounceMsRef.current = 280;              // fin de geste + petite marge479        setDirty(false);480        setQy((q) => ({ ...q, page: 1, bboxStr }));481      } else {482        setDirty(true);483      }484    }, []);485486  const searchThisArea = useCallback(() => {487    const b = engineRef.current?.currentBBox();488    if (!b) return;489    setDirty(false);490    setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) }));491  }, []);492493  const refitToResults = useCallback(() => {494    setUserLocked(false);495    userLockedRef.current = false;496    setDirty(false);497    if (qyRef.current.bboxStr === null) {498      // requête déjà « tous les résultats » : recadrage immédiat499      engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 });500    } else {501      fitAfterRef.current = true;502      setQy((q) => ({ ...q, page: 1, bboxStr: null }));503    }504  }, []);505506  const widenArea = useCallback(() => {507    const m = engineRef.current;508    if (!m) return;509    // « Élargir la zone » : dézoome, puis recherche élargie explicite.510    m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {511      zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,512    });513    syncListToViewport(700);514  }, [syncListToViewport]);515516  // --- polygone dessiné ---517  const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {518    setDrawing(isDrawing);519    if (isDrawing) return;520    const polyStr = polygon ? polyToString(polygon) : null;521    if (polyStr === qyRef.current.poly) return;522    if (polyStr) {523      fitAfterRef.current = true;524      setDirty(false);525      setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null }));526    } else {527      const b = engineRef.current?.currentBBox();528      setQy((q) => ({529        ...q, page: 1, poly: null,530        bboxStr: userLockedRef.current && b ? bboxToString(b) : null,531      }));532    }533  }, []);534535  const clearZone = useCallback(() => {536    engineRef.current?.clearDrawnPolygon();       // onDraw fera le reste537  }, []);538539  // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page ---540  const scrollToCard = useCallback((uid: string, pulse: boolean) => {541    const sel = `[data-uid="${CSS.escape(uid)}"]`;542    const card = listRef.current?.querySelector<HTMLElement>(sel);543    if (card) {544      card.scrollIntoView({ behavior: "smooth", block: "nearest" });545      if (pulse) {546        card.classList.remove("map-card-pulse");547        void card.offsetWidth;                    // relance l'animation548        card.classList.add("map-card-pulse");549      }550    }551  }, []);552553  const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {554    const uid = p?.id ?? null;555    setSelectedUid(uid);556    if (!uid) return;557    const idx = uidIndex.get(uid);558    if (idx === undefined) return;559    const targetPage = Math.floor(idx / PAGE_SIZE) + 1;560    if (targetPage !== qyRef.current.page) {561      pendingScrollUidRef.current = uid;562      setQy((q) => ({ ...q, page: targetPage }));563    } else {564      scrollToCard(uid, origin === "map");565    }566  }, [uidIndex, scrollToCard]);567568  // défilement différé (la page vient de changer pour révéler l'annonce)569  useEffect(() => {570    const uid = pendingScrollUidRef.current;571    if (!uid || !res) return;572    if (res.listings.some((l) => l.uid === uid)) {573      pendingScrollUidRef.current = null;574      requestAnimationFrame(() => scrollToCard(uid, true));575    }576  }, [res, scrollToCard]);577578  // --- survol carte → liste : surlignage + indicateur hors écran ---579  const onHover = useCallback((p: MapProperty | null) => {580    setMapHoverUid(p?.id ?? null);581  }, []);582  useEffect(() => {583    if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; }584    const card = listRef.current.querySelector<HTMLElement>(585      `[data-uid="${CSS.escape(mapHoverUid)}"]`);586    if (!card) { setHoverOffscreen(null); return; }587    const list = listRef.current.getBoundingClientRect();588    const rect = card.getBoundingClientRect();589    if (rect.bottom < list.top + 8) setHoverOffscreen("up");590    else if (rect.top > list.bottom - 8) setHoverOffscreen("down");591    else setHoverOffscreen(null);592  }, [mapHoverUid]);593594  // --- clic sur une carte d'annonce : recentre + fiche (1er clic),595  //     navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---596  const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {597    if (e.metaKey || e.ctrlKey || e.button !== 0) return;   // nouvel onglet598    if (selectedUid === uid) return;                        // 2e clic : fiche599    e.preventDefault();600    engineRef.current?.select(uid, "app");601  }, [selectedUid]);602603  const listings = res?.listings ?? [];604  const total = res?.total ?? null;605  const unpositioned = res?.unpositioned ?? 0;606  const hasZone = qy.poly !== null;607608  /* ------------------------- volets du shell ------------------------- */609610  const listHeader = (611    <div className="ms2-head">612      <div className="ms2-count" role="status" aria-live="polite">613        {total === null ? "…" : (614          <>615            <b>{total.toLocaleString("en-CA")}</b>616            {" "}rental{total !== null && total > 1 ? "s" : ""}617            {hasZone ? " · drawn area" : ""}618          </>619        )}620        {unpositioned > 0 && (621          <span622            className="ms-nopos"623            title="Listings matching the filters but without a geocoded position — visible in List view only"624          >625            +{unpositioned} unpositioned626          </span>627        )}628      </div>629      <label className="ms2-sort">630        <select631          value={qy.sort}632          onChange={(e) => setQy((q) => ({633            ...q, page: 1, sort: e.target.value as SearchSort,634          }))}635          aria-label="Sort the results"636        >637          {SORT_CHOICES.map((s) => (638            <option key={s.key} value={s.key}>{s.label}</option>639          ))}640        </select>641      </label>642    </div>643  );644645  const listPane = (646    <div className="ms2-list" aria-label="Results list" ref={listRef}>647      {userLocked && total !== null && total > 0 && !hasZone && (648        <button className="ms-refit" onClick={refitToResults}>649          ⌖ Refit to results650        </button>651      )}652653      {loading && <div className="ms-progress" aria-hidden="true" />}654655      {error && (656        <div className="ms-empty">657          <h3>Could not load the results</h3>658          <p>{error}</p>659        </div>660      )}661662      {!error && total === 0 && (663        <div className="ms-empty" role="status">664          <div className="big"><IcoSearch size={34} /></div>665          <h3>No rentals in this area</h3>666          <p>Widen the map or change your filters.</p>667          <div className="ms-empty-actions">668            <button className="btn btn-primary" onClick={widenArea}>669              Widen the area670            </button>671            {hasZone && (672              <button className="btn btn-ghost" onClick={clearZone}>673                Clear the drawn area674              </button>675            )}676          </div>677        </div>678      )}679680      {!error && listings.map((l) => (681        <div682          key={l.uid}683          data-uid={l.uid}684          className={685            "map-card" +686            (selectedUid === l.uid ? " map-card-sel" : "") +687            (mapHoverUid === l.uid ? " map-card-hover" : "")688          }689          onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}690          onMouseLeave={() => engineRef.current?.setHovered(null, "app")}691          onFocus={() => engineRef.current?.setHovered(l.uid, "app")}692          onBlur={() => engineRef.current?.setHovered(null, "app")}693          onClickCapture={(e) => onCardClick(e, l.uid)}694        >695          <ListingCard l={l} />696        </div>697      ))}698699      {!error && total !== null && total > 0 && (700        <Pager701          page={qy.page} pageSize={PAGE_SIZE} total={total}702          onPage={(p) => {703            setQy((q) => ({ ...q, page: p }));704            // le défileur est le parent (ka-pane-body / ka-sheet-body)705            listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });706          }}707        />708      )}709710      {hoverOffscreen && (711        <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">712          {hoverOffscreen === "up" ? "▲ Listing higher up" : "▼ Listing further down"}713        </div>714      )}715    </div>716  );717718  return (719    <KaMapShell720      className="ms2"721      brand={<span className="ms2-brand"><b>Rent·Ka</b><span>Map</span></span>}722      onExit={onExit}723      exitLabel="List"724      storageKey="rentka-map-split"725      listHeader={listHeader}726      list={listPane}727      topExtras={728        onOpenFilters ? (729          <button className="ka-top-btn" onClick={onOpenFilters}>730            <IcoSliders size={13} /> Filters731            {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}732          </button>733        ) : null734      }735    >736      <KaMapView737        theme={louKaMapTheme}738        mapboxToken={MAPBOX_TOKEN}739        center={initialCamera ?? CANADA}740        zoom={(initialCamera ?? CANADA).zoom}741        pitch={50}742        basemap={{ theme: "default", showLandmarks: true }}743        cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}744        navControl={false}745        onMoveEnd={onMoveEnd}746        onSelect={onSelect}747        onHover={onHover}748        onDraw={onDraw}749        onClusterHover={setClusterTip}750      >751        <EngineBridge onEngine={handleEngine} />752        <SelectionMirror selectedUid={selectedUid} />753        <SheetAutoCollapse selectedUid={selectedUid} />754        <KaBrandBadge />755756        {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */}757        <KaMapToolbar>758          {!isMobile && (759            <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>760          )}761          <KaToolbarGroup>762            <KaToolbar3D />763            <KaToolbarDraw />764            <KaToolbarLocate765              onLocated={() => {766                setUserLocked(true);767                userLockedRef.current = true;768                syncListToViewport(750);769              }}770            />771          </KaToolbarGroup>772        </KaMapToolbar>773774        {/* mode dessin : bandeau temporaire + CTA « N logements » */}775        <KaDrawAreaMode776          count={hasZone ? total : null}777          formatCount={(n) => `${n.toLocaleString("en-CA")} rental${n > 1 ? "s" : ""}`}778          onClear={clearZone}779        />780781        {/* « Rechercher quand je déplace la carte » + zone divergée */}782        {!hasZone && !drawing && (783          <div className="ka-search-area" role="group" aria-label="Geographic search">784            {dirty && !searchOnMove && (785              <button type="button" className="ka-search-area-btn"786                onClick={searchThisArea} disabled={loading}>787                {loading ? "Searching…" : "Search this area"}788              </button>789            )}790            <label className="ka-search-area-auto">791              <input792                type="checkbox"793                checked={searchOnMove}794                onChange={() => {795                  const next = !searchOnMove;796                  setSearchOnMove(next);797                  if (next && dirty) searchThisArea();798                }}799              />800              <span>Search as I move the map</span>801            </label>802          </div>803        )}804805        {loading && (806          <div className="ka-loading" role="status" aria-live="polite">807            <span className="ka-loading-dot" aria-hidden="true" />808            Updating rentals…809          </div>810        )}811812        {total === 0 && !loading && (813          <div className="ka-empty" role="status">814            <strong>No rentals in this area.</strong>815            <span>Widen the map or change your filters.</span>816          </div>817        )}818819        {clusterTip && !isMobile && (820          <div821            className="ka-cluster-tip"822            style={{ left: clusterTip.x, top: clusterTip.y }}823          >824            {clusterTip.count.toLocaleString("en-CA")} rentals825            {clusterTip.min != null && clusterTip.max != null && (826              <small>827                {clusterTip.min === clusterTip.max828                  ? formatCompactPrice(clusterTip.min)829                  : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}830                {" "}/month831              </small>832            )}833          </div>834        )}835836        <KaPropertyPreview render={(p) => <LazyPreview p={p} />} />837      </KaMapView>838    </KaMapShell>839  );840}841