// ----------------------------------------------------------------------------- // Rent-Ka — Rental listings aggregator (Canada, outside Québec) // Author: Simon-Pierre Boucher — contact@spboucher.ai // search/MapSearch.tsx: the MAP MODE (Ka Map System v2) — synchronized // list ↔ map search, full viewport. // // UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent // dans un état unique ; chaque changement déclenche UNE requête /api/search // qui renvoie à la fois la page de liste et tous les points carte, triés // pareil : le compteur de la liste est par construction égal au nombre de // marqueurs, et l'index d'un point donne directement sa page de liste. // // v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est // une prise de contrôle du viewport. // · mobile : carte edge-to-edge, barre supérieure compacte, résultats en // bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ; // · desktop : split résultats|carte redimensionnable (ratio persisté), // carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ; // · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation), // « Rechercher dans cette zone » contextuel, mode dessin temporaire avec // CTA « N logements dans cette zone ». // // Ce composant reste le modèle réutilisable Groupe-KA (immo-ka, resto-ka, // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md. // ----------------------------------------------------------------------------- import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useNavigate } from "react-router-dom"; import "mapbox-gl/dist/mapbox-gl.css"; import "@groupe-ka/ka-maps/styles.css"; import type { MapProperty } from "@groupe-ka/ka-maps"; import { bboxToString, cameraFromParams, cameraToParams, formatCompactPrice, } from "@groupe-ka/ka-maps"; import type { KaMap } from "@groupe-ka/ka-maps"; import { KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView, KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup, KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell, } from "@groupe-ka/ka-maps/react"; import { Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort, fetchListing, fetchSearch, fmtPrice, sourceName, thumb, } from "../api"; import { useAccount } from "../account"; import ListingCard from "../components/ListingCard"; import Pager from "../components/Pager"; import FairValueBadge from "../components/FairValueBadge"; import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons"; import { louKaMapTheme } from "../kamaps/theme"; import { MAPBOX_TOKEN } from "../kamaps/config"; import { seenUids } from "./seen"; const PAGE_SIZE = 20; // listings per page (map view) const CANADA = { lat: 47.5, lng: -84.5, zoom: 4.4 }; const SORT_CHOICES: { key: SearchSort; label: string }[] = [ { key: "recent", label: "Most recent" }, { key: "prix", label: "Price: low to high" }, { key: "prix_desc", label: "Price: high to low" }, { key: "deal", label: "Best deals" }, { key: "ka", label: "Best KA Score" }, ]; /** Points compacts → modèle canonique Ka Maps, avec écartement léger des * coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que * chaque pastille reste cliquable au zoom maximal (spiderfy discret). */ function pointsToProperties(points: SearchPoint[]): MapProperty[] { const byCoord = new Map(); const out: MapProperty[] = []; for (const [uid, lng, lat, price, fv] of points) { const key = `${lng},${lat}`; const n = byCoord.get(key) ?? 0; byCoord.set(key, n + 1); let lng2 = lng, lat2 = lat; if (n > 0) { // cercle de ~9 m autour du point d'origine, déterministe const angle = n * 2.4; // angle d'or : pas d'alignement lng2 = lng + Math.cos(angle) * 0.00011; lat2 = lat + Math.sin(angle) * 0.00008; } out.push({ id: uid, appSource: "rent-ka", longitude: lng2, latitude: lat2, kind: "listing", listingType: "rent", price: price ?? undefined, highlight: fv === "s", // below market originalUrl: `/listing/${encodeURIComponent(uid)}`, }); } return out; } /** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */ const polyToString = (poly: [number, number][]): string => poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";"); function polyFromString(text: string | null): [number, number][] | null { if (!text) return null; const pts: [number, number][] = []; for (const part of text.split(";")) { const [lng, lat] = part.split(",").map(Number); if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; pts.push([lng as number, lat as number]); } return pts.length >= 3 ? pts : null; } /** Pont : expose le moteur KaMap au composant parent (hors du canevas). */ function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) { const map = useKaMap(); useEffect(() => { onEngine(map); return () => onEngine(null); }, [map, onEngine]); return null; } /** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */ function SelectionMirror({ selectedUid }: { selectedUid: string | null }) { const map = useKaMap(); useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]); return null; } /** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser * toute la place à la carte + fiche contextuelle. */ function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) { const shell = useKaShell(); const shellRef = useRef(shell); shellRef.current = shell; useEffect(() => { const s = shellRef.current; if (selectedUid && s?.isMobile) s.setSheet("mini"); }, [selectedUid]); return null; } /** Fiche contextuelle : les points carte sont volontairement compacts, le * détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en * cache) — ka-prevcard structure of the Ka Map System, Rent-Ka content. */ const previewCache = new Map(); function LazyPreview({ p }: { p: MapProperty }) { const navigate = useNavigate(); const { favs, toggleFav } = useAccount(); const [l, setL] = useState(previewCache.get(p.id) ?? null); useEffect(() => { if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; } let dead = false; setL(null); fetchListing(p.id) .then((r) => { previewCache.set(p.id, r); if (previewCache.size > 80) { const first = previewCache.keys().next().value; if (first) previewCache.delete(first); } if (!dead) setL(r); }) .catch(() => {}); return () => { dead = true; }; }, [p.id]); const img = l?.images?.[0] ?? null; const fav = favs.has(p.id); const fiche = `/listing/${encodeURIComponent(p.id)}`; return ( <>
{img ? ( ) : ( )}
{l ? fmtPrice(l.price, l.price_label) : p.price != null ? `$${p.price.toLocaleString("en-CA")}` : "…"} {(l?.price ?? p.price) != null && /month} {l && }
{l ? (l.title || l.address) : "Loading…"}
{[ l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} sq ft` : "", l?.availability, l ? sourceName(l.source) : "", ].filter(Boolean).join(" · ")}
{ e.preventDefault(); navigate(fiche); }} > See the listing →
); } export interface MapSearchProps { filters: ListingFilters; /** Compteur partagé remonté à l'entête de résultats de la page. */ onTotal?: (total: number | null) => void; /** Quitter le mode carte (retour à la vue liste). */ onExit?: () => void; /** Ouvrir la feuille de filtres de la page (le shell n'a qu'un bouton). */ onOpenFilters?: () => void; /** Nombre de filtres actifs (badge du bouton Filtres). */ filtersCount?: number; } interface Query { bboxStr: string | null; // zone visible (gestes utilisateur uniquement) poly: string | null; // zone dessinée (prime sur la zone visible) sort: SearchSort; page: number; } export default function MapSearch({ filters, onTotal, onExit, onOpenFilters, filtersCount = 0, }: MapSearchProps) { const initialParams = useMemo( () => new URLSearchParams(window.location.search), []); const initialCamera = useMemo( () => cameraFromParams(initialParams), [initialParams]); // --- état de recherche partagé (la « seule vérité ») --- const [qy, setQy] = useState(() => { const sort = (initialParams.get("sort") as SearchSort | null) ?? "recent"; const p = Number(initialParams.get("page")); return { bboxStr: null, // posé au premier geste ou « zone » restaurée poly: initialParams.get("area"), sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "recent", page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1, }; }); const [searchOnMove, setSearchOnMove] = useState( initialParams.get("move") !== "0"); const [dirty, setDirty] = useState(false); // zone divergée (mode manuel) const [userLocked, setUserLocked] = useState(initialCamera !== null); const [drawing, setDrawing] = useState(false); // URL avec caméra partagée : attendre la carte pour connaître la zone // visible AVANT la première requête (la liste doit refléter cette zone). const [bootstrapped, setBootstrapped] = useState(initialCamera === null); const bootstrappedRef = useRef(bootstrapped); const [res, setRes] = useState(null); const [points, setPoints] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [engine, setEngine] = useState(null); const [selectedUid, setSelectedUid] = useState(null); const [mapHoverUid, setMapHoverUid] = useState(null); const [hoverOffscreen, setHoverOffscreen] = useState<"up" | "down" | null>(null); const [clusterTip, setClusterTip] = useState< { count: number; min: number | null; max: number | null; x: number; y: number } | null >(null); const [isMobile, setIsMobile] = useState( () => window.matchMedia("(max-width: 780px)").matches); // --- refs de pilotage (valeurs à jour dans les callbacks carte) --- const engineRef = useRef(null); const qyRef = useRef(qy); qyRef.current = qy; const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove; const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked; const seqRef = useRef(0); const ctrlRef = useRef(null); const debounceMsRef = useRef(0); const pointsKeyRef = useRef(""); const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat const pendingScrollUidRef = useRef(null); const listRef = useRef(null); const filtersClean = useMemo(() => { const f: Record = {}; for (const [k, v] of Object.entries(filters)) { if (v && k !== "sort") f[k] = String(v); } return f; }, [filters]); const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]); useEffect(() => { const mq = window.matchMedia("(max-width: 780px)"); const update = () => setIsMobile(mq.matches); mq.addEventListener("change", update); return () => mq.removeEventListener("change", update); }, []); // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main const firstFiltersRun = useRef(true); useEffect(() => { if (firstFiltersRun.current) { firstFiltersRun.current = false; return; } if (!userLockedRef.current) { fitAfterRef.current = true; setQy((q) => ({ ...q, page: 1, bboxStr: null })); } else { setQy((q) => ({ ...q, page: 1 })); } }, [filtersKey]); // --- LA requête unifiée : une réponse = page de liste + points carte --- const qyKey = useMemo(() => JSON.stringify(qy), [qy]); useEffect(() => { if (!bootstrapped) return; const { bboxStr, poly, sort, page } = qyRef.current; const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`; const wait = debounceMsRef.current; debounceMsRef.current = 0; const timer = window.setTimeout(() => { ctrlRef.current?.abort(); const ctrl = new AbortController(); ctrlRef.current = ctrl; const mySeq = ++seqRef.current; setLoading(true); setError(null); fetchSearch( { ...filtersClean, bbox: poly ? undefined : bboxStr ?? undefined, poly: poly ?? undefined, sort, page, page_size: PAGE_SIZE, include: pKey === pointsKeyRef.current ? "liste" : "tout", }, ctrl.signal, ) .then((r) => { if (mySeq !== seqRef.current) return; // réponse périmée : ignorée setLoading(false); setRes(r); if (r.points) { setPoints(r.points); pointsKeyRef.current = pKey; } if (r.page !== qyRef.current.page) { // page ramenée dans les bornes par le serveur (filtre resserré) setQy((q) => ({ ...q, page: r.page })); } // recadrage sur les résultats — si la carte n'est pas encore // prête (premier chargement), l'effet données→carte le fera if (fitAfterRef.current && engineRef.current && r.points?.length) { fitAfterRef.current = false; engineRef.current.fitToProperties(pointsToProperties(r.points), { padding: 72, maxZoom: 16, }); } }) .catch((e) => { if (ctrl.signal.aborted || mySeq !== seqRef.current) return; setLoading(false); setError(String(e)); }); }, wait); return () => window.clearTimeout(timer); }, [filtersKey, qyKey, filtersClean, bootstrapped]); // compteur partagé remonté à la page useEffect(() => { onTotal?.(res?.total ?? null); return () => onTotal?.(null); }, [res?.total, onTotal]); // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) --- useEffect(() => { const url = new URL(window.location.href); const set = (k: string, v: string | null) => v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v); set("sort", qy.sort === "recent" ? null : qy.sort); set("page", qy.page > 1 ? String(qy.page) : null); set("area", qy.poly); set("move", searchOnMove ? null : "0"); window.history.replaceState(null, "", url); }, [qy.sort, qy.page, qy.poly, searchOnMove]); // --- moteur prêt : polygone restauré, annonces « vues » --- const handleEngine = useCallback((m: KaMap | null) => { engineRef.current = m; setEngine(m); if (!m) return; m.setSeenIds(seenUids()); const zone = polyFromString(qyRef.current.poly); if (zone) m.setDrawnPolygon(zone); // Caméra restaurée depuis l'URL : la zone visible devient la contrainte // spatiale de la première requête (lien partagé = même recherche). if (!bootstrappedRef.current) { bootstrappedRef.current = true; const b = m.currentBBox(); if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) })); setBootstrapped(true); } }, []); // --- données → carte (poussées, pas d'adaptateur : une seule requête) --- const mapProperties = useMemo(() => pointsToProperties(points), [points]); useEffect(() => { if (!engine) return; engine.setProperties(mapProperties); engine.setSeenIds(seenUids()); // premier chargement : la réponse est souvent arrivée avant la carte — // le recadrage en attente s'applique dès que les deux sont prêts if (fitAfterRef.current && mapProperties.length > 0) { fitAfterRef.current = false; engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 }); } }, [engine, mapProperties]); // index uid → position dans le tri (= page de liste, sans requête) const uidIndex = useMemo(() => { const m = new Map(); points.forEach((p, i) => m.set(p[0], i)); return m; }, [points]); // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la // position et synchroniser la liste sur cette zone --- const syncListToViewport = useCallback((delayMs: number) => { window.setTimeout(() => { const b = engineRef.current?.currentBBox(); if (b) { setDirty(false); setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) })); } }, delayMs); }, []); useEffect(() => { const handler = (e: Event) => { const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail; const m = engineRef.current; if (!m) return; setUserLocked(true); userLockedRef.current = true; m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 }); syncListToViewport(950); }; window.addEventListener("rentka:geolocate", handler); return () => window.removeEventListener("rentka:geolocate", handler); }, [syncListToViewport]); // --- geste sur la carte --- const onMoveEnd = useCallback( (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => { const url = new URL(window.location.href); url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString(); window.history.replaceState(null, "", url); if (!byUser) return; // fitBounds/recentrages : rien setUserLocked(true); userLockedRef.current = true; if (qyRef.current.poly) return; // la zone dessinée prime const engineNow = engineRef.current; const b = engineNow?.currentBBox(); if (!b) return; const bboxStr = bboxToString(b); if (searchOnMoveRef.current) { debounceMsRef.current = 280; // fin de geste + petite marge setDirty(false); setQy((q) => ({ ...q, page: 1, bboxStr })); } else { setDirty(true); } }, []); const searchThisArea = useCallback(() => { const b = engineRef.current?.currentBBox(); if (!b) return; setDirty(false); setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) })); }, []); const refitToResults = useCallback(() => { setUserLocked(false); userLockedRef.current = false; setDirty(false); if (qyRef.current.bboxStr === null) { // requête déjà « tous les résultats » : recadrage immédiat engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 }); } else { fitAfterRef.current = true; setQy((q) => ({ ...q, page: 1, bboxStr: null })); } }, []); const widenArea = useCallback(() => { const m = engineRef.current; if (!m) return; // « Élargir la zone » : dézoome, puis recherche élargie explicite. m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, { zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650, }); syncListToViewport(700); }, [syncListToViewport]); // --- polygone dessiné --- const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => { setDrawing(isDrawing); if (isDrawing) return; const polyStr = polygon ? polyToString(polygon) : null; if (polyStr === qyRef.current.poly) return; if (polyStr) { fitAfterRef.current = true; setDirty(false); setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null })); } else { const b = engineRef.current?.currentBBox(); setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: userLockedRef.current && b ? bboxToString(b) : null, })); } }, []); const clearZone = useCallback(() => { engineRef.current?.clearDrawnPolygon(); // onDraw fera le reste }, []); // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page --- const scrollToCard = useCallback((uid: string, pulse: boolean) => { const sel = `[data-uid="${CSS.escape(uid)}"]`; const card = listRef.current?.querySelector(sel); if (card) { card.scrollIntoView({ behavior: "smooth", block: "nearest" }); if (pulse) { card.classList.remove("map-card-pulse"); void card.offsetWidth; // relance l'animation card.classList.add("map-card-pulse"); } } }, []); const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => { const uid = p?.id ?? null; setSelectedUid(uid); if (!uid) return; const idx = uidIndex.get(uid); if (idx === undefined) return; const targetPage = Math.floor(idx / PAGE_SIZE) + 1; if (targetPage !== qyRef.current.page) { pendingScrollUidRef.current = uid; setQy((q) => ({ ...q, page: targetPage })); } else { scrollToCard(uid, origin === "map"); } }, [uidIndex, scrollToCard]); // défilement différé (la page vient de changer pour révéler l'annonce) useEffect(() => { const uid = pendingScrollUidRef.current; if (!uid || !res) return; if (res.listings.some((l) => l.uid === uid)) { pendingScrollUidRef.current = null; requestAnimationFrame(() => scrollToCard(uid, true)); } }, [res, scrollToCard]); // --- survol carte → liste : surlignage + indicateur hors écran --- const onHover = useCallback((p: MapProperty | null) => { setMapHoverUid(p?.id ?? null); }, []); useEffect(() => { if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; } const card = listRef.current.querySelector( `[data-uid="${CSS.escape(mapHoverUid)}"]`); if (!card) { setHoverOffscreen(null); return; } const list = listRef.current.getBoundingClientRect(); const rect = card.getBoundingClientRect(); if (rect.bottom < list.top + 8) setHoverOffscreen("up"); else if (rect.top > list.bottom - 8) setHoverOffscreen("down"); else setHoverOffscreen(null); }, [mapHoverUid]); // --- clic sur une carte d'annonce : recentre + fiche (1er clic), // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) --- const onCardClick = useCallback((e: React.MouseEvent, uid: string) => { if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet if (selectedUid === uid) return; // 2e clic : fiche e.preventDefault(); engineRef.current?.select(uid, "app"); }, [selectedUid]); const listings = res?.listings ?? []; const total = res?.total ?? null; const unpositioned = res?.unpositioned ?? 0; const hasZone = qy.poly !== null; /* ------------------------- volets du shell ------------------------- */ const listHeader = (
{total === null ? "…" : ( <> {total.toLocaleString("en-CA")} {" "}rental{total !== null && total > 1 ? "s" : ""} {hasZone ? " · drawn area" : ""} )} {unpositioned > 0 && ( +{unpositioned} unpositioned )}
); const listPane = (
{userLocked && total !== null && total > 0 && !hasZone && ( )} {loading && ); return ( Rent·KaMap} onExit={onExit} exitLabel="List" storageKey="rentka-map-split" listHeader={listHeader} list={listPane} topExtras={ onOpenFilters ? ( ) : null } > {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */} {!isMobile && ( )} { setUserLocked(true); userLockedRef.current = true; syncListToViewport(750); }} /> {/* mode dessin : bandeau temporaire + CTA « N logements » */} `${n.toLocaleString("en-CA")} rental${n > 1 ? "s" : ""}`} onClear={clearZone} /> {/* « Rechercher quand je déplace la carte » + zone divergée */} {!hasZone && !drawing && (
{dirty && !searchOnMove && ( )}
)} {loading && (
)} {total === 0 && !loading && (
No rentals in this area. Widen the map or change your filters.
)} {clusterTip && !isMobile && (
{clusterTip.count.toLocaleString("en-CA")} rentals {clusterTip.min != null && clusterTip.max != null && ( {clusterTip.min === clusterTip.max ? formatCompactPrice(clusterTip.min) : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`} {" "}/month )}
)} } />
); }