Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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