Recherche synchronisée liste ↔ carte (une seule vérité partagée)
· /api/search : une réponse = page de liste + tous les points carte, même tri — compteur liste = marqueurs par construction ; bbox, polygone dessiné, tris (prix/récent/affaires), include=liste, annonces sans position comptées honnêtement. · MapSearch.tsx : état unique filtres+zone+tri+page, miroirs survol/clic < 100 ms dans les deux sens, page ajustée au clic marqueur + pulsation, « Rechercher quand je déplace la carte », « Rechercher dans cette zone », recadrage animé respectant l intention (byUser), « Recadrer sur les résultats », zone dessinée en puce retirable + URL, carrousel mobile synchronisé aux marqueurs, états vides propres, URL complète partageable. · Géolocalisation d accueil : arrivée sur le site → demande de position → vue carte centrée sur l utilisateur (une fois par session, lien partagé respecté, refus silencieux). · Marqueurs « déjà vu » atténués (localStorage), fourchette de prix des clusters au survol, spiderfy discret des unités d un même immeuble. · Tests : scripts/test-sync.mjs (25 vérifications E2E Playwright, critères 1-9), tests/test_search.py (9 tests API). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 changed files +1,829 −41
modified
.gitignore
+6 −0
@@ -18,3 +18,9 @@ data/louka.db-shm | ||
| 18 | 18 | data/louka.db-wal |
| 19 | 19 | data/louka.db.backup-* |
| 20 | 20 | backup-connectors-*/ |
| 21 | +node_modules/ | |
| 22 | +data/imgcache/ | |
| 23 | +data/louka.db-shm | |
| 24 | +data/louka.db-wal | |
| 25 | +data/louka.db.backup-* | |
| 26 | +frontend/tsconfig.tsbuildinfo | |
modified
frontend/src/api.ts
+39 −0
@@ -203,6 +203,45 @@ export function fetchListings(f: ListingFilters, limit?: number, offset?: number | ||
| 203 | 203 | return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`); |
| 204 | 204 | } |
| 205 | 205 | |
| 206 | +// --- Recherche unifiée liste + carte (/api/search) --------------------------- | |
| 207 | +// Une réponse = la page de liste + TOUS les points carte, même tri : le | |
| 208 | +// compteur de la liste est par construction égal au nombre de marqueurs. | |
| 209 | + | |
| 210 | +/** Point compact : [uid, lng, lat, prix, verdict ("s"|"m"|"o"|null)]. */ | |
| 211 | +export type SearchPoint = [string, number, number, number | null, string | null]; | |
| 212 | + | |
| 213 | +export type SearchSort = "prix" | "prix_desc" | "recent" | "deal"; | |
| 214 | + | |
| 215 | +export interface SearchQuery extends Omit<ListingFilters, "sort"> { | |
| 216 | + bbox?: string; // ouest,sud,est,nord — zone visible de la carte | |
| 217 | + poly?: string; // lng,lat;lng,lat;… — zone dessinée | |
| 218 | + sort?: SearchSort; | |
| 219 | + page?: number; | |
| 220 | + page_size?: number; | |
| 221 | + include?: "tout" | "liste"; // "liste" : points inchangés côté client | |
| 222 | +} | |
| 223 | + | |
| 224 | +export interface SearchResponse { | |
| 225 | + total: number; // compteur partagé liste = carte | |
| 226 | + page: number; | |
| 227 | + page_size: number; | |
| 228 | + sort: SearchSort; | |
| 229 | + listings: Listing[]; | |
| 230 | + points: SearchPoint[] | null; // null si include="liste" | |
| 231 | + unpositioned: number; // annonces filtrées sans coordonnées | |
| 232 | +} | |
| 233 | + | |
| 234 | +export async function fetchSearch( | |
| 235 | + qy: SearchQuery, | |
| 236 | + signal?: AbortSignal, | |
| 237 | +): Promise<SearchResponse> { | |
| 238 | + const params = new URLSearchParams(); | |
| 239 | + for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v)); | |
| 240 | + const res = await fetch(`/api/search?${params}`, { signal }); | |
| 241 | + if (!res.ok) throw new Error(`API ${res.status} — /api/search`); | |
| 242 | + return (await res.json()) as SearchResponse; | |
| 243 | +} | |
| 244 | + | |
| 206 | 245 | export interface GroupStat { |
| 207 | 246 | key: string; |
| 208 | 247 | count: number; |
modified
frontend/src/pages/Home.tsx
+59 −40
@@ -19,8 +19,9 @@ import { | ||
| 19 | 19 | IcoSearch, IcoSliders, IcoSofa, |
| 20 | 20 | } from "../components/Icons"; |
| 21 | 21 | |
| 22 | −// La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre | |
| 23 | −const MapView = lazy(() => import("../components/MapView")); | |
| 22 | +// La recherche liste+carte synchronisée (Mapbox, ~220 ko) n'est chargée | |
| 23 | +// que si l'utilisateur ouvre la vue Carte — voir search/MapSearch.tsx | |
| 24 | +const MapSearch = lazy(() => import("../search/MapSearch")); | |
| 24 | 25 | |
| 25 | 26 | const PAGE_SIZE = 12; // annonces par page (grille « liste ») |
| 26 | 27 | |
@@ -94,16 +95,8 @@ export default function Home() { | ||
| 94 | 95 | }; |
| 95 | 96 | }, [qDebounced, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal]); |
| 96 | 97 | |
| 97 | − // sélection carte ↔ liste (vue carte) — Lou-Ka Maps (framework Ka Maps) | |
| 98 | − const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null); | |
| 99 | − const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null); | |
| 100 | − const mapListRef = useRef<HTMLDivElement | null>(null); | |
| 101 | − useEffect(() => { | |
| 102 | − if (!mapSelectedUid) return; | |
| 103 | − mapListRef.current | |
| 104 | − ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`) | |
| 105 | − ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
| 106 | − }, [mapSelectedUid]); | |
| 98 | + // compteur partagé de la vue carte (remonté par MapSearch : liste = carte) | |
| 99 | + const [mapTotal, setMapTotal] = useState<number | null>(null); | |
| 107 | 100 | |
| 108 | 101 | useEffect(() => { |
| 109 | 102 | fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
@@ -111,6 +104,40 @@ export default function Home() { | ||
| 111 | 104 | fetchStats().then(setStats).catch(() => {}); |
| 112 | 105 | }, []); |
| 113 | 106 | |
| 107 | + // Géolocalisation d'accueil : à l'arrivée sur le site (URL nue, une fois | |
| 108 | + // par session), demander la position pour afficher directement les | |
| 109 | + // logements autour de l'utilisateur — vue Carte centrée sur lui. | |
| 110 | + // Lien partagé (filtres/caméra dans l'URL) : on respecte le lien, pas de | |
| 111 | + // demande. Refus : silencieux, l'accueil classique reste tel quel. | |
| 112 | + useEffect(() => { | |
| 113 | + const url = new URL(window.location.href); | |
| 114 | + const hasState = [...url.searchParams.keys()].length > 0; | |
| 115 | + if (hasState) return; | |
| 116 | + if (!("geolocation" in navigator)) return; | |
| 117 | + if (sessionStorage.getItem("louka_geo_session")) return; | |
| 118 | + sessionStorage.setItem("louka_geo_session", "1"); | |
| 119 | + navigator.geolocation.getCurrentPosition( | |
| 120 | + (pos) => { | |
| 121 | + const { latitude, longitude } = pos.coords; | |
| 122 | + // hors Québec (voyage, VPN) : ne pas centrer sur l'océan | |
| 123 | + if (latitude < 44 || latitude > 63 || longitude < -80 || longitude > -57) return; | |
| 124 | + const u = new URL(window.location.href); | |
| 125 | + u.searchParams.set("lat", latitude.toFixed(5)); | |
| 126 | + u.searchParams.set("lng", longitude.toFixed(5)); | |
| 127 | + u.searchParams.set("zoom", "13.5"); | |
| 128 | + u.searchParams.set("view", "carte"); | |
| 129 | + window.history.replaceState(null, "", u); | |
| 130 | + // MapSearch déjà monté (vue carte) : recentrage + liste via événement | |
| 131 | + window.dispatchEvent(new CustomEvent("louka:geolocate", { | |
| 132 | + detail: { lat: latitude, lng: longitude }, | |
| 133 | + })); | |
| 134 | + setView("carte"); | |
| 135 | + }, | |
| 136 | + () => { /* refus ou indisponible : accueil inchangé */ }, | |
| 137 | + { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }, | |
| 138 | + ); | |
| 139 | + }, []); | |
| 140 | + | |
| 114 | 141 | // quartiers dépendants de la ville choisie |
| 115 | 142 | useEffect(() => { |
| 116 | 143 | fetchFacets(city || undefined) |
@@ -134,11 +161,13 @@ export default function Home() { | ||
| 134 | 161 | }, [filtersKey]); |
| 135 | 162 | useEffect(() => { |
| 136 | 163 | // refléter la page dans l'URL sans re-render du routeur |
| 164 | + // (en vue carte, la pagination appartient à MapSearch) | |
| 165 | + if (view === "carte") return; | |
| 137 | 166 | const url = new URL(window.location.href); |
| 138 | 167 | if (page > 1) url.searchParams.set("page", String(page)); |
| 139 | 168 | else url.searchParams.delete("page"); |
| 140 | 169 | window.history.replaceState(null, "", url); |
| 141 | − }, [page]); | |
| 170 | + }, [page, view]); | |
| 142 | 171 | |
| 143 | 172 | const goToPage = (p: number) => { |
| 144 | 173 | setPage(p); |
@@ -146,6 +175,7 @@ export default function Home() { | ||
| 146 | 175 | }; |
| 147 | 176 | |
| 148 | 177 | useEffect(() => { |
| 178 | + if (view === "carte") return; // la vue carte a sa propre requête unifiée | |
| 149 | 179 | let cancelled = false; |
| 150 | 180 | setListings(null); |
| 151 | 181 | setError(null); |
@@ -163,7 +193,7 @@ export default function Home() { | ||
| 163 | 193 | return () => { |
| 164 | 194 | cancelled = true; |
| 165 | 195 | }; |
| 166 | − }, [filters, page]); | |
| 196 | + }, [filters, page, view]); | |
| 167 | 197 | |
| 168 | 198 | const availableTypes = useMemo(() => { |
| 169 | 199 | const set = new Set(facets?.unit_types ?? []); |
@@ -453,7 +483,11 @@ export default function Home() { | ||
| 453 | 483 | <div className="results-head" ref={resultsRef}> |
| 454 | 484 | <h2>Logements disponibles</h2> |
| 455 | 485 | <div className="results-tools"> |
| 456 | − {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>} | |
| 486 | + {view === "carte" | |
| 487 | + ? mapTotal != null && ( | |
| 488 | + <span>{mapTotal.toLocaleString("fr-CA")} résultat{mapTotal > 1 ? "s" : ""}</span> | |
| 489 | + ) | |
| 490 | + : listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>} | |
| 457 | 491 | <div className="view-toggle" role="tablist" aria-label="Mode d'affichage"> |
| 458 | 492 | <button |
| 459 | 493 | role="tab" aria-selected={view === "liste"} |
@@ -510,30 +544,15 @@ export default function Home() { | ||
| 510 | 544 | )} |
| 511 | 545 | |
| 512 | 546 | {!error && view === "carte" && ( |
| 513 | − <div className="map-split"> | |
| 514 | − <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}> | |
| 515 | − {(listings ?? []).map((l) => ( | |
| 516 | − <div | |
| 517 | − key={l.uid} | |
| 518 | − data-uid={l.uid} | |
| 519 | − className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`} | |
| 520 | − onMouseEnter={() => setMapHoveredUid(l.uid)} | |
| 521 | − onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))} | |
| 522 | − > | |
| 523 | − <ListingCard l={l} /> | |
| 524 | − </div> | |
| 525 | − ))} | |
| 526 | − <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} /> | |
| 527 | − </div> | |
| 528 | − <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}> | |
| 529 | − <MapView | |
| 530 | − filters={filters} | |
| 531 | − selectedUid={mapSelectedUid} | |
| 532 | − hoveredUid={mapHoveredUid} | |
| 533 | − onSelect={setMapSelectedUid} | |
| 534 | − /> | |
| 535 | − </Suspense> | |
| 536 | − </div> | |
| 547 | + <Suspense | |
| 548 | + fallback={ | |
| 549 | + <div className="map-split"> | |
| 550 | + <div className="mapview map-loading">Chargement de la carte…</div> | |
| 551 | + </div> | |
| 552 | + } | |
| 553 | + > | |
| 554 | + <MapSearch filters={filters} onTotal={setMapTotal} /> | |
| 555 | + </Suspense> | |
| 537 | 556 | )} |
| 538 | 557 | |
| 539 | 558 | {!error && view === "liste" && listings !== null && listings.length > 0 && ( |
@@ -549,7 +568,7 @@ export default function Home() { | ||
| 549 | 568 | |
| 550 | 569 | {/* Bouton flottant mobile — ouvre la feuille de filtres */} |
| 551 | 570 | <button |
| 552 | − className="fab" | |
| 571 | + className={`fab${view === "carte" ? " fab-carte" : ""}`} | |
| 553 | 572 | onClick={() => setSheetOpen(true)} |
| 554 | 573 | aria-label="Ouvrir les filtres" |
| 555 | 574 | > |
modified
frontend/src/pages/Listing.tsx
+2 −0
@@ -16,6 +16,7 @@ import SmartImg from "../components/SmartImg"; | ||
| 16 | 16 | import FairValueBadge from "../components/FairValueBadge"; |
| 17 | 17 | import PriceAnalysis from "../components/PriceAnalysis"; |
| 18 | 18 | import { IcoAlert, IcoDoc } from "../components/Icons"; |
| 19 | +import { markSeen } from "../search/seen"; | |
| 19 | 20 | |
| 20 | 21 | // Mini-carte 3D (Mapbox) — chargée paresseusement, comme la grande carte. |
| 21 | 22 | const ListingMap3D = lazy(() => import("../components/ListingMap3D")); |
@@ -273,6 +274,7 @@ export default function ListingPage() { | ||
| 273 | 274 | fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
| 274 | 275 | if (!uid) return; |
| 275 | 276 | fetchListing(uid).then(setL).catch((e) => setError(String(e))); |
| 277 | + markSeen(uid); // marqueur atténué « déjà vu » sur la carte de recherche | |
| 276 | 278 | window.scrollTo(0, 0); |
| 277 | 279 | }, [uid]); |
| 278 | 280 | |
added
frontend/src/search/MapSearch.tsx
+862 −0
@@ -0,0 +1,862 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// search/MapSearch.tsx : recherche synchronisée liste ↔ carte (Ka Maps). | |
| 5 | +// | |
| 6 | +// UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent | |
| 7 | +// dans un état unique ; chaque changement déclenche UNE requête /api/search | |
| 8 | +// qui renvoie à la fois la page de liste et tous les points carte, triés | |
| 9 | +// pareil : le compteur de la liste est par construction égal au nombre de | |
| 10 | +// marqueurs, et l'index d'un point donne directement sa page de liste. | |
| 11 | +// | |
| 12 | +// · carte → liste : déplacement (fin de geste, avec debounce), case | |
| 13 | +// « Rechercher quand je déplace la carte », bouton « Rechercher dans | |
| 14 | +// cette zone », polygone dessiné (puce retirable), clic cluster ; | |
| 15 | +// · liste → carte : filtres (recadrage animé sauf si l'utilisateur a pris | |
| 16 | +// la main — bouton « Recadrer sur les résultats »), tri partagé ; | |
| 17 | +// · miroirs : survol carte↔liste (< 100 ms, feature-state GPU), clic | |
| 18 | +// marqueur → défilement de la liste (page ajustée au besoin) + pulsation, | |
| 19 | +// clic annonce → recentrage doux + mini-fiche ; | |
| 20 | +// · mobile : carrousel bas d'écran synchronisé avec les marqueurs ; | |
| 21 | +// · URL : caméra + tri + page + zone dessinée + mode de recherche. | |
| 22 | +// | |
| 23 | +// Ce composant est le modèle réutilisable Groupe-KA (immo-ka, resto-ka, | |
| 24 | +// sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md. | |
| 25 | +// ----------------------------------------------------------------------------- | |
| 26 | +import { | |
| 27 | + useCallback, useEffect, useMemo, useRef, useState, | |
| 28 | +} from "react"; | |
| 29 | +import { Link, useNavigate } from "react-router-dom"; | |
| 30 | +import "mapbox-gl/dist/mapbox-gl.css"; | |
| 31 | +import "@groupe-ka/ka-maps/styles.css"; | |
| 32 | +import type { MapProperty } from "@groupe-ka/ka-maps"; | |
| 33 | +import { | |
| 34 | + bboxToString, cameraFromParams, cameraToParams, formatCompactPrice, | |
| 35 | +} from "@groupe-ka/ka-maps"; | |
| 36 | +import type { KaMap } from "@groupe-ka/ka-maps"; | |
| 37 | +import { | |
| 38 | + DrawControl, KaBrandBadge, KaMapView, LocateControl, PropertyPreview, | |
| 39 | + Tilt3DControl, useKaMap, | |
| 40 | +} from "@groupe-ka/ka-maps/react"; | |
| 41 | +import { | |
| 42 | + Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort, | |
| 43 | + fetchListing, fetchSearch, fmtPrice, sourceName, thumb, | |
| 44 | +} from "../api"; | |
| 45 | +import ListingCard from "../components/ListingCard"; | |
| 46 | +import Pager from "../components/Pager"; | |
| 47 | +import SmartImg from "../components/SmartImg"; | |
| 48 | +import FairValueBadge from "../components/FairValueBadge"; | |
| 49 | +import { IcoSearch } from "../components/Icons"; | |
| 50 | +import { louKaMapTheme } from "../kamaps/theme"; | |
| 51 | +import { MAPBOX_TOKEN } from "../kamaps/config"; | |
| 52 | +import { seenUids } from "./seen"; | |
| 53 | + | |
| 54 | +const PAGE_SIZE = 20; // annonces par page (vue carte) | |
| 55 | +const QUEBEC = { lat: 46.82, lng: -71.25, zoom: 11 }; | |
| 56 | + | |
| 57 | +const SORT_CHOICES: { key: SearchSort; label: string }[] = [ | |
| 58 | + { key: "prix", label: "Prix croissant" }, | |
| 59 | + { key: "prix_desc", label: "Prix décroissant" }, | |
| 60 | + { key: "recent", label: "Plus récentes" }, | |
| 61 | + { key: "deal", label: "Meilleures affaires" }, | |
| 62 | +]; | |
| 63 | + | |
| 64 | +/** Points compacts → modèle canonique Ka Maps, avec écartement léger des | |
| 65 | + * coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que | |
| 66 | + * chaque pastille reste cliquable au zoom maximal (spiderfy discret). */ | |
| 67 | +function pointsToProperties(points: SearchPoint[]): MapProperty[] { | |
| 68 | + const byCoord = new Map<string, number>(); | |
| 69 | + const out: MapProperty[] = []; | |
| 70 | + for (const [uid, lng, lat, price, fv] of points) { | |
| 71 | + const key = `${lng},${lat}`; | |
| 72 | + const n = byCoord.get(key) ?? 0; | |
| 73 | + byCoord.set(key, n + 1); | |
| 74 | + let lng2 = lng, lat2 = lat; | |
| 75 | + if (n > 0) { | |
| 76 | + // cercle de ~9 m autour du point d'origine, déterministe | |
| 77 | + const angle = n * 2.4; // angle d'or : pas d'alignement | |
| 78 | + lng2 = lng + Math.cos(angle) * 0.00011; | |
| 79 | + lat2 = lat + Math.sin(angle) * 0.00008; | |
| 80 | + } | |
| 81 | + out.push({ | |
| 82 | + id: uid, | |
| 83 | + appSource: "lou-ka", | |
| 84 | + longitude: lng2, | |
| 85 | + latitude: lat2, | |
| 86 | + kind: "listing", | |
| 87 | + listingType: "rent", | |
| 88 | + price: price ?? undefined, | |
| 89 | + highlight: fv === "s", // sous le marché | |
| 90 | + originalUrl: `/logement/${encodeURIComponent(uid)}`, | |
| 91 | + }); | |
| 92 | + } | |
| 93 | + return out; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */ | |
| 97 | +const polyToString = (poly: [number, number][]): string => | |
| 98 | + poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";"); | |
| 99 | + | |
| 100 | +function polyFromString(text: string | null): [number, number][] | null { | |
| 101 | + if (!text) return null; | |
| 102 | + const pts: [number, number][] = []; | |
| 103 | + for (const part of text.split(";")) { | |
| 104 | + const [lng, lat] = part.split(",").map(Number); | |
| 105 | + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; | |
| 106 | + pts.push([lng as number, lat as number]); | |
| 107 | + } | |
| 108 | + return pts.length >= 3 ? pts : null; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */ | |
| 112 | +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) { | |
| 113 | + const map = useKaMap(); | |
| 114 | + useEffect(() => { | |
| 115 | + onEngine(map); | |
| 116 | + return () => onEngine(null); | |
| 117 | + }, [map, onEngine]); | |
| 118 | + return null; | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Mini-fiche de marqueur : les points carte sont volontairement compacts, | |
| 122 | + * le détail (photo, titre, dispo) est chargé à la sélection (rapide, mis | |
| 123 | + * en cache) — même langage visuel que les cartes de la liste. */ | |
| 124 | +const previewCache = new Map<string, Listing>(); | |
| 125 | + | |
| 126 | +function LazyPreview({ p }: { p: MapProperty }) { | |
| 127 | + const navigate = useNavigate(); | |
| 128 | + const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null); | |
| 129 | + useEffect(() => { | |
| 130 | + if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; } | |
| 131 | + let dead = false; | |
| 132 | + setL(null); | |
| 133 | + fetchListing(p.id) | |
| 134 | + .then((r) => { | |
| 135 | + previewCache.set(p.id, r); | |
| 136 | + if (previewCache.size > 80) { | |
| 137 | + const first = previewCache.keys().next().value; | |
| 138 | + if (first) previewCache.delete(first); | |
| 139 | + } | |
| 140 | + if (!dead) setL(r); | |
| 141 | + }) | |
| 142 | + .catch(() => {}); | |
| 143 | + return () => { dead = true; }; | |
| 144 | + }, [p.id]); | |
| 145 | + | |
| 146 | + const img = l?.images?.[0] ?? null; | |
| 147 | + return ( | |
| 148 | + <div className="mv-pop"> | |
| 149 | + {img ? ( | |
| 150 | + <img src={thumb(img, 480)} alt="" loading="lazy" /> | |
| 151 | + ) : ( | |
| 152 | + <div className="mv-noimg" aria-hidden="true">⌂</div> | |
| 153 | + )} | |
| 154 | + <div className="mv-pop-body"> | |
| 155 | + <div className="mv-pop-price"> | |
| 156 | + {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"} | |
| 157 | + {(l?.price ?? p.price) != null && <small> /mois</small>} | |
| 158 | + {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />} | |
| 159 | + </div> | |
| 160 | + <div className="mv-pop-title">{l ? (l.title || l.address) : "Chargement…"}</div> | |
| 161 | + <div className="mv-pop-meta"> | |
| 162 | + {[l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", l?.availability] | |
| 163 | + .filter(Boolean).join(" · ")} | |
| 164 | + </div> | |
| 165 | + <div className="mv-pop-src">{l ? sourceName(l.source) : ""}</div> | |
| 166 | + <a | |
| 167 | + className="mv-pop-cta" | |
| 168 | + href={`/logement/${encodeURIComponent(p.id)}`} | |
| 169 | + onClick={(e) => { e.preventDefault(); navigate(`/logement/${encodeURIComponent(p.id)}`); }} | |
| 170 | + > | |
| 171 | + Voir la fiche → | |
| 172 | + </a> | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + ); | |
| 176 | +} | |
| 177 | + | |
| 178 | +export interface MapSearchProps { | |
| 179 | + filters: ListingFilters; | |
| 180 | + /** Compteur partagé remonté à l'entête de résultats de la page. */ | |
| 181 | + onTotal?: (total: number | null) => void; | |
| 182 | +} | |
| 183 | + | |
| 184 | +interface Query { | |
| 185 | + bboxStr: string | null; // zone visible (gestes utilisateur uniquement) | |
| 186 | + poly: string | null; // zone dessinée (prime sur la zone visible) | |
| 187 | + sort: SearchSort; | |
| 188 | + page: number; | |
| 189 | +} | |
| 190 | + | |
| 191 | +export default function MapSearch({ filters, onTotal }: MapSearchProps) { | |
| 192 | + const initialParams = useMemo( | |
| 193 | + () => new URLSearchParams(window.location.search), []); | |
| 194 | + const initialCamera = useMemo( | |
| 195 | + () => cameraFromParams(initialParams), [initialParams]); | |
| 196 | + | |
| 197 | + // --- état de recherche partagé (la « seule vérité ») --- | |
| 198 | + const [qy, setQy] = useState<Query>(() => { | |
| 199 | + const sort = (initialParams.get("tri") as SearchSort | null) ?? "prix"; | |
| 200 | + const p = Number(initialParams.get("page")); | |
| 201 | + return { | |
| 202 | + bboxStr: null, // posé au premier geste ou « zone » restaurée | |
| 203 | + poly: initialParams.get("zone"), | |
| 204 | + sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "prix", | |
| 205 | + page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1, | |
| 206 | + }; | |
| 207 | + }); | |
| 208 | + const [searchOnMove, setSearchOnMove] = useState( | |
| 209 | + initialParams.get("move") !== "0"); | |
| 210 | + const [dirty, setDirty] = useState(false); // zone divergée (mode manuel) | |
| 211 | + const [userLocked, setUserLocked] = useState(initialCamera !== null); | |
| 212 | + const [drawing, setDrawing] = useState(false); | |
| 213 | + | |
| 214 | + // URL avec caméra partagée : attendre la carte pour connaître la zone | |
| 215 | + // visible AVANT la première requête (la liste doit refléter cette zone). | |
| 216 | + const [bootstrapped, setBootstrapped] = useState(initialCamera === null); | |
| 217 | + const bootstrappedRef = useRef(bootstrapped); | |
| 218 | + | |
| 219 | + const [res, setRes] = useState<SearchResponse | null>(null); | |
| 220 | + const [points, setPoints] = useState<SearchPoint[]>([]); | |
| 221 | + const [loading, setLoading] = useState(false); | |
| 222 | + const [error, setError] = useState<string | null>(null); | |
| 223 | + | |
| 224 | + const [engine, setEngine] = useState<KaMap | null>(null); | |
| 225 | + const [selectedUid, setSelectedUid] = useState<string | null>(null); | |
| 226 | + const [mapHoverUid, setMapHoverUid] = useState<string | null>(null); | |
| 227 | + const [hoverOffscreen, setHoverOffscreen] = useState<"haut" | "bas" | null>(null); | |
| 228 | + const [clusterTip, setClusterTip] = useState< | |
| 229 | + { count: number; min: number | null; max: number | null; x: number; y: number } | null | |
| 230 | + >(null); | |
| 231 | + const [isMobile, setIsMobile] = useState( | |
| 232 | + () => window.matchMedia("(max-width: 780px)").matches); | |
| 233 | + | |
| 234 | + // --- refs de pilotage (valeurs à jour dans les callbacks carte) --- | |
| 235 | + const engineRef = useRef<KaMap | null>(null); | |
| 236 | + const qyRef = useRef(qy); qyRef.current = qy; | |
| 237 | + const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove; | |
| 238 | + const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked; | |
| 239 | + const seqRef = useRef(0); | |
| 240 | + const ctrlRef = useRef<AbortController | null>(null); | |
| 241 | + const debounceMsRef = useRef(0); | |
| 242 | + const pointsKeyRef = useRef<string>(""); | |
| 243 | + const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat | |
| 244 | + const pendingScrollUidRef = useRef<string | null>(null); | |
| 245 | + const carouselDrivenRef = useRef(false); | |
| 246 | + const listRef = useRef<HTMLDivElement | null>(null); | |
| 247 | + const carouselRef = useRef<HTMLDivElement | null>(null); | |
| 248 | + | |
| 249 | + const filtersClean = useMemo(() => { | |
| 250 | + const f: Record<string, string> = {}; | |
| 251 | + for (const [k, v] of Object.entries(filters)) { | |
| 252 | + if (v && k !== "sort") f[k] = String(v); | |
| 253 | + } | |
| 254 | + return f; | |
| 255 | + }, [filters]); | |
| 256 | + const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]); | |
| 257 | + | |
| 258 | + useEffect(() => { | |
| 259 | + const mq = window.matchMedia("(max-width: 780px)"); | |
| 260 | + const update = () => setIsMobile(mq.matches); | |
| 261 | + mq.addEventListener("change", update); | |
| 262 | + return () => mq.removeEventListener("change", update); | |
| 263 | + }, []); | |
| 264 | + | |
| 265 | + // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main | |
| 266 | + const firstFiltersRun = useRef(true); | |
| 267 | + useEffect(() => { | |
| 268 | + if (firstFiltersRun.current) { firstFiltersRun.current = false; return; } | |
| 269 | + if (!userLockedRef.current) { | |
| 270 | + fitAfterRef.current = true; | |
| 271 | + setQy((q) => ({ ...q, page: 1, bboxStr: null })); | |
| 272 | + } else { | |
| 273 | + setQy((q) => ({ ...q, page: 1 })); | |
| 274 | + } | |
| 275 | + }, [filtersKey]); | |
| 276 | + | |
| 277 | + // --- LA requête unifiée : une réponse = page de liste + points carte --- | |
| 278 | + const qyKey = useMemo(() => JSON.stringify(qy), [qy]); | |
| 279 | + useEffect(() => { | |
| 280 | + if (!bootstrapped) return; | |
| 281 | + const { bboxStr, poly, sort, page } = qyRef.current; | |
| 282 | + const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`; | |
| 283 | + const wait = debounceMsRef.current; | |
| 284 | + debounceMsRef.current = 0; | |
| 285 | + | |
| 286 | + const timer = window.setTimeout(() => { | |
| 287 | + ctrlRef.current?.abort(); | |
| 288 | + const ctrl = new AbortController(); | |
| 289 | + ctrlRef.current = ctrl; | |
| 290 | + const mySeq = ++seqRef.current; | |
| 291 | + setLoading(true); | |
| 292 | + setError(null); | |
| 293 | + fetchSearch( | |
| 294 | + { | |
| 295 | + ...filtersClean, | |
| 296 | + bbox: poly ? undefined : bboxStr ?? undefined, | |
| 297 | + poly: poly ?? undefined, | |
| 298 | + sort, page, page_size: PAGE_SIZE, | |
| 299 | + include: pKey === pointsKeyRef.current ? "liste" : "tout", | |
| 300 | + }, | |
| 301 | + ctrl.signal, | |
| 302 | + ) | |
| 303 | + .then((r) => { | |
| 304 | + if (mySeq !== seqRef.current) return; // réponse périmée : ignorée | |
| 305 | + setLoading(false); | |
| 306 | + setRes(r); | |
| 307 | + if (r.points) { | |
| 308 | + setPoints(r.points); | |
| 309 | + pointsKeyRef.current = pKey; | |
| 310 | + } | |
| 311 | + if (r.page !== qyRef.current.page) { | |
| 312 | + // page ramenée dans les bornes par le serveur (filtre resserré) | |
| 313 | + setQy((q) => ({ ...q, page: r.page })); | |
| 314 | + } | |
| 315 | + // recadrage sur les résultats — si la carte n'est pas encore | |
| 316 | + // prête (premier chargement), l'effet données→carte le fera | |
| 317 | + if (fitAfterRef.current && engineRef.current && r.points?.length) { | |
| 318 | + fitAfterRef.current = false; | |
| 319 | + engineRef.current.fitToProperties(pointsToProperties(r.points), { | |
| 320 | + padding: 72, maxZoom: 16, | |
| 321 | + }); | |
| 322 | + } | |
| 323 | + }) | |
| 324 | + .catch((e) => { | |
| 325 | + if (ctrl.signal.aborted || mySeq !== seqRef.current) return; | |
| 326 | + setLoading(false); | |
| 327 | + setError(String(e)); | |
| 328 | + }); | |
| 329 | + }, wait); | |
| 330 | + return () => window.clearTimeout(timer); | |
| 331 | + }, [filtersKey, qyKey, filtersClean, bootstrapped]); | |
| 332 | + | |
| 333 | + // compteur partagé remonté à la page | |
| 334 | + useEffect(() => { | |
| 335 | + onTotal?.(res?.total ?? null); | |
| 336 | + return () => onTotal?.(null); | |
| 337 | + }, [res?.total, onTotal]); | |
| 338 | + | |
| 339 | + // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) --- | |
| 340 | + useEffect(() => { | |
| 341 | + const url = new URL(window.location.href); | |
| 342 | + const set = (k: string, v: string | null) => | |
| 343 | + v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v); | |
| 344 | + set("tri", qy.sort === "prix" ? null : qy.sort); | |
| 345 | + set("page", qy.page > 1 ? String(qy.page) : null); | |
| 346 | + set("zone", qy.poly); | |
| 347 | + set("move", searchOnMove ? null : "0"); | |
| 348 | + window.history.replaceState(null, "", url); | |
| 349 | + }, [qy.sort, qy.page, qy.poly, searchOnMove]); | |
| 350 | + | |
| 351 | + // --- moteur prêt : polygone restauré, annonces « vues » --- | |
| 352 | + const handleEngine = useCallback((m: KaMap | null) => { | |
| 353 | + engineRef.current = m; | |
| 354 | + setEngine(m); | |
| 355 | + if (!m) return; | |
| 356 | + m.setSeenIds(seenUids()); | |
| 357 | + const zone = polyFromString(qyRef.current.poly); | |
| 358 | + if (zone) m.setDrawnPolygon(zone); | |
| 359 | + // Caméra restaurée depuis l'URL : la zone visible devient la contrainte | |
| 360 | + // spatiale de la première requête (lien partagé = même recherche). | |
| 361 | + if (!bootstrappedRef.current) { | |
| 362 | + bootstrappedRef.current = true; | |
| 363 | + const b = m.currentBBox(); | |
| 364 | + if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) })); | |
| 365 | + setBootstrapped(true); | |
| 366 | + } | |
| 367 | + }, []); | |
| 368 | + | |
| 369 | + // --- données → carte (poussées, pas d'adaptateur : une seule requête) --- | |
| 370 | + const mapProperties = useMemo(() => pointsToProperties(points), [points]); | |
| 371 | + useEffect(() => { | |
| 372 | + if (!engine) return; | |
| 373 | + engine.setProperties(mapProperties); | |
| 374 | + engine.setSeenIds(seenUids()); | |
| 375 | + // premier chargement : la réponse est souvent arrivée avant la carte — | |
| 376 | + // le recadrage en attente s'applique dès que les deux sont prêts | |
| 377 | + if (fitAfterRef.current && mapProperties.length > 0) { | |
| 378 | + fitAfterRef.current = false; | |
| 379 | + engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 }); | |
| 380 | + } | |
| 381 | + }, [engine, mapProperties]); | |
| 382 | + | |
| 383 | + // index uid → position dans le tri (= page de liste, sans requête) | |
| 384 | + const uidIndex = useMemo(() => { | |
| 385 | + const m = new Map<string, number>(); | |
| 386 | + points.forEach((p, i) => m.set(p[0], i)); | |
| 387 | + return m; | |
| 388 | + }, [points]); | |
| 389 | + | |
| 390 | + // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la | |
| 391 | + // position et synchroniser la liste sur cette zone --- | |
| 392 | + useEffect(() => { | |
| 393 | + const handler = (e: Event) => { | |
| 394 | + const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail; | |
| 395 | + const m = engineRef.current; | |
| 396 | + if (!m) return; | |
| 397 | + setUserLocked(true); | |
| 398 | + userLockedRef.current = true; | |
| 399 | + m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 }); | |
| 400 | + window.setTimeout(() => { | |
| 401 | + const b = engineRef.current?.currentBBox(); | |
| 402 | + if (b) { | |
| 403 | + setDirty(false); | |
| 404 | + setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) })); | |
| 405 | + } | |
| 406 | + }, 950); | |
| 407 | + }; | |
| 408 | + window.addEventListener("louka:geolocate", handler); | |
| 409 | + return () => window.removeEventListener("louka:geolocate", handler); | |
| 410 | + }, []); | |
| 411 | + | |
| 412 | + // --- geste sur la carte --- | |
| 413 | + const onMoveEnd = useCallback( | |
| 414 | + (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => { | |
| 415 | + const url = new URL(window.location.href); | |
| 416 | + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString(); | |
| 417 | + window.history.replaceState(null, "", url); | |
| 418 | + if (!byUser) return; // fitBounds/recentrages : rien | |
| 419 | + setUserLocked(true); | |
| 420 | + userLockedRef.current = true; | |
| 421 | + if (qyRef.current.poly) return; // la zone dessinée prime | |
| 422 | + const engineNow = engineRef.current; | |
| 423 | + const b = engineNow?.currentBBox(); | |
| 424 | + if (!b) return; | |
| 425 | + const bboxStr = bboxToString(b); | |
| 426 | + if (searchOnMoveRef.current) { | |
| 427 | + debounceMsRef.current = 280; // fin de geste + petite marge | |
| 428 | + setDirty(false); | |
| 429 | + setQy((q) => ({ ...q, page: 1, bboxStr })); | |
| 430 | + } else { | |
| 431 | + setDirty(true); | |
| 432 | + } | |
| 433 | + }, []); | |
| 434 | + | |
| 435 | + const searchThisArea = useCallback(() => { | |
| 436 | + const b = engineRef.current?.currentBBox(); | |
| 437 | + if (!b) return; | |
| 438 | + setDirty(false); | |
| 439 | + setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) })); | |
| 440 | + }, []); | |
| 441 | + | |
| 442 | + const refitToResults = useCallback(() => { | |
| 443 | + setUserLocked(false); | |
| 444 | + userLockedRef.current = false; | |
| 445 | + setDirty(false); | |
| 446 | + if (qyRef.current.bboxStr === null) { | |
| 447 | + // requête déjà « tous les résultats » : recadrage immédiat | |
| 448 | + engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 }); | |
| 449 | + } else { | |
| 450 | + fitAfterRef.current = true; | |
| 451 | + setQy((q) => ({ ...q, page: 1, bboxStr: null })); | |
| 452 | + } | |
| 453 | + }, []); | |
| 454 | + | |
| 455 | + const widenArea = useCallback(() => { | |
| 456 | + const m = engineRef.current; | |
| 457 | + if (!m) return; | |
| 458 | + // « Élargir la zone » : dézoome — le moveend (byUser côté moteur ? non, | |
| 459 | + // easeTo interne) ; on déclenche la recherche élargie explicitement. | |
| 460 | + const b = m.currentBBox(); | |
| 461 | + m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, { | |
| 462 | + zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650, | |
| 463 | + }); | |
| 464 | + if (b) { | |
| 465 | + window.setTimeout(() => { | |
| 466 | + const nb = engineRef.current?.currentBBox(); | |
| 467 | + if (nb) { | |
| 468 | + setDirty(false); | |
| 469 | + setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(nb) })); | |
| 470 | + } | |
| 471 | + }, 700); | |
| 472 | + } | |
| 473 | + }, []); | |
| 474 | + | |
| 475 | + // --- polygone dessiné --- | |
| 476 | + const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => { | |
| 477 | + setDrawing(isDrawing); | |
| 478 | + if (isDrawing) return; | |
| 479 | + const polyStr = polygon ? polyToString(polygon) : null; | |
| 480 | + if (polyStr === qyRef.current.poly) return; | |
| 481 | + if (polyStr) { | |
| 482 | + fitAfterRef.current = true; | |
| 483 | + setDirty(false); | |
| 484 | + setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null })); | |
| 485 | + } else { | |
| 486 | + const b = engineRef.current?.currentBBox(); | |
| 487 | + setQy((q) => ({ | |
| 488 | + ...q, page: 1, poly: null, | |
| 489 | + bboxStr: userLockedRef.current && b ? bboxToString(b) : null, | |
| 490 | + })); | |
| 491 | + } | |
| 492 | + }, []); | |
| 493 | + | |
| 494 | + const clearZone = useCallback(() => { | |
| 495 | + engineRef.current?.clearDrawnPolygon(); // onDraw fera le reste | |
| 496 | + }, []); | |
| 497 | + | |
| 498 | + // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page --- | |
| 499 | + const scrollToCard = useCallback((uid: string, pulse: boolean) => { | |
| 500 | + const sel = `[data-uid="${CSS.escape(uid)}"]`; | |
| 501 | + const card = listRef.current?.querySelector<HTMLElement>(sel); | |
| 502 | + if (card) { | |
| 503 | + card.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
| 504 | + if (pulse) { | |
| 505 | + card.classList.remove("map-card-pulse"); | |
| 506 | + void card.offsetWidth; // relance l'animation | |
| 507 | + card.classList.add("map-card-pulse"); | |
| 508 | + } | |
| 509 | + } | |
| 510 | + const carItem = carouselRef.current?.querySelector<HTMLElement>(sel); | |
| 511 | + if (carItem && carouselRef.current) { | |
| 512 | + carouselRef.current.scrollTo({ | |
| 513 | + left: carItem.offsetLeft - 12, behavior: "smooth", | |
| 514 | + }); | |
| 515 | + } | |
| 516 | + }, []); | |
| 517 | + | |
| 518 | + const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => { | |
| 519 | + const uid = p?.id ?? null; | |
| 520 | + setSelectedUid(uid); | |
| 521 | + if (!uid) return; | |
| 522 | + if (carouselDrivenRef.current) { // déjà au bon endroit | |
| 523 | + carouselDrivenRef.current = false; | |
| 524 | + return; | |
| 525 | + } | |
| 526 | + const idx = uidIndex.get(uid); | |
| 527 | + if (idx === undefined) return; | |
| 528 | + const targetPage = Math.floor(idx / PAGE_SIZE) + 1; | |
| 529 | + if (targetPage !== qyRef.current.page) { | |
| 530 | + pendingScrollUidRef.current = uid; | |
| 531 | + setQy((q) => ({ ...q, page: targetPage })); | |
| 532 | + } else { | |
| 533 | + scrollToCard(uid, origin === "map"); | |
| 534 | + } | |
| 535 | + }, [uidIndex, scrollToCard]); | |
| 536 | + | |
| 537 | + // défilement différé (la page vient de changer pour révéler l'annonce) | |
| 538 | + useEffect(() => { | |
| 539 | + const uid = pendingScrollUidRef.current; | |
| 540 | + if (!uid || !res) return; | |
| 541 | + if (res.listings.some((l) => l.uid === uid)) { | |
| 542 | + pendingScrollUidRef.current = null; | |
| 543 | + requestAnimationFrame(() => scrollToCard(uid, true)); | |
| 544 | + } | |
| 545 | + }, [res, scrollToCard]); | |
| 546 | + | |
| 547 | + // --- survol carte → liste : surlignage + indicateur hors écran --- | |
| 548 | + const onHover = useCallback((p: MapProperty | null) => { | |
| 549 | + setMapHoverUid(p?.id ?? null); | |
| 550 | + }, []); | |
| 551 | + useEffect(() => { | |
| 552 | + if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; } | |
| 553 | + const card = listRef.current.querySelector<HTMLElement>( | |
| 554 | + `[data-uid="${CSS.escape(mapHoverUid)}"]`); | |
| 555 | + if (!card) { setHoverOffscreen(null); return; } | |
| 556 | + const list = listRef.current.getBoundingClientRect(); | |
| 557 | + const rect = card.getBoundingClientRect(); | |
| 558 | + if (rect.bottom < list.top + 8) setHoverOffscreen("haut"); | |
| 559 | + else if (rect.top > list.bottom - 8) setHoverOffscreen("bas"); | |
| 560 | + else setHoverOffscreen(null); | |
| 561 | + }, [mapHoverUid]); | |
| 562 | + | |
| 563 | + // --- carrousel mobile : balayage → sélection sur la carte --- | |
| 564 | + const carouselScrollTimer = useRef<number | null>(null); | |
| 565 | + const onCarouselScroll = useCallback(() => { | |
| 566 | + if (carouselScrollTimer.current) window.clearTimeout(carouselScrollTimer.current); | |
| 567 | + carouselScrollTimer.current = window.setTimeout(() => { | |
| 568 | + const el = carouselRef.current; | |
| 569 | + if (!el || !res) return; | |
| 570 | + const children = Array.from(el.children) as HTMLElement[]; | |
| 571 | + const center = el.scrollLeft + el.clientWidth / 2; | |
| 572 | + let best = 0, bestD = Infinity; | |
| 573 | + children.forEach((c, i) => { | |
| 574 | + const d = Math.abs(c.offsetLeft + c.offsetWidth / 2 - center); | |
| 575 | + if (d < bestD) { bestD = d; best = i; } | |
| 576 | + }); | |
| 577 | + const uid = res.listings[best]?.uid; | |
| 578 | + if (uid && uid !== selectedUid) { | |
| 579 | + carouselDrivenRef.current = true; | |
| 580 | + engineRef.current?.select(uid, "app"); | |
| 581 | + setSelectedUid(uid); | |
| 582 | + } | |
| 583 | + }, 140); | |
| 584 | + }, [res, selectedUid]); | |
| 585 | + | |
| 586 | + // --- clic sur une carte d'annonce : recentre + mini-fiche (1er clic), | |
| 587 | + // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) --- | |
| 588 | + const onCardClick = useCallback((e: React.MouseEvent, uid: string) => { | |
| 589 | + if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet | |
| 590 | + if (selectedUid === uid) return; // 2e clic : fiche | |
| 591 | + e.preventDefault(); | |
| 592 | + engineRef.current?.select(uid, "app"); | |
| 593 | + }, [selectedUid]); | |
| 594 | + | |
| 595 | + // Amener l'écran sur la recherche à l'ouverture de la vue carte (arrivée | |
| 596 | + // directe /?view=carte, géolocalisation, ou clic sur l'onglet Carte) — | |
| 597 | + // sinon le héro cache la carte et la synchro se joue sous le pli. | |
| 598 | + const rootRef = useRef<HTMLDivElement | null>(null); | |
| 599 | + useEffect(() => { | |
| 600 | + const t = window.setTimeout(() => { | |
| 601 | + rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); | |
| 602 | + }, 120); | |
| 603 | + return () => window.clearTimeout(t); | |
| 604 | + }, []); | |
| 605 | + | |
| 606 | + const listings = res?.listings ?? []; | |
| 607 | + const total = res?.total ?? null; | |
| 608 | + const unpositioned = res?.unpositioned ?? 0; | |
| 609 | + const hasZone = qy.poly !== null; | |
| 610 | + | |
| 611 | + return ( | |
| 612 | + <div className="map-split ms" ref={rootRef}> | |
| 613 | + {/* ---------------- volet liste (desktop / tablette) ---------------- */} | |
| 614 | + <div className="map-list ms-list" aria-label="Résultats en liste" ref={listRef}> | |
| 615 | + <div className="ms-list-head"> | |
| 616 | + <div className="ms-count" role="status" aria-live="polite"> | |
| 617 | + {total === null ? "…" : ( | |
| 618 | + <> | |
| 619 | + <b>{total.toLocaleString("fr-CA")}</b> | |
| 620 | + {" "}logement{total > 1 ? "s" : ""} | |
| 621 | + {hasZone ? " dans la zone dessinée" : ""} | |
| 622 | + </> | |
| 623 | + )} | |
| 624 | + {unpositioned > 0 && ( | |
| 625 | + <span | |
| 626 | + className="ms-nopos" | |
| 627 | + title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement" | |
| 628 | + > | |
| 629 | + +{unpositioned} sans position | |
| 630 | + </span> | |
| 631 | + )} | |
| 632 | + </div> | |
| 633 | + <label className="ms-sort"> | |
| 634 | + <span>Tri</span> | |
| 635 | + <select | |
| 636 | + value={qy.sort} | |
| 637 | + onChange={(e) => setQy((q) => ({ | |
| 638 | + ...q, page: 1, sort: e.target.value as SearchSort, | |
| 639 | + }))} | |
| 640 | + > | |
| 641 | + {SORT_CHOICES.map((s) => ( | |
| 642 | + <option key={s.key} value={s.key}>{s.label}</option> | |
| 643 | + ))} | |
| 644 | + </select> | |
| 645 | + </label> | |
| 646 | + </div> | |
| 647 | + | |
| 648 | + {hasZone && ( | |
| 649 | + <div className="pills ms-pills"> | |
| 650 | + <button className="pill" onClick={clearZone} aria-label="Retirer la zone dessinée"> | |
| 651 | + Zone dessinée <span className="pill-x">✕</span> | |
| 652 | + </button> | |
| 653 | + </div> | |
| 654 | + )} | |
| 655 | + {userLocked && total !== null && total > 0 && !hasZone && ( | |
| 656 | + <button className="ms-refit" onClick={refitToResults}> | |
| 657 | + ⌖ Recadrer sur les résultats | |
| 658 | + </button> | |
| 659 | + )} | |
| 660 | + | |
| 661 | + {loading && <div className="ms-progress" aria-hidden="true" />} | |
| 662 | + | |
| 663 | + {error && ( | |
| 664 | + <div className="ms-empty"> | |
| 665 | + <h3>Impossible de charger les résultats</h3> | |
| 666 | + <p>{error}</p> | |
| 667 | + </div> | |
| 668 | + )} | |
| 669 | + | |
| 670 | + {!error && total === 0 && ( | |
| 671 | + <div className="ms-empty" role="status"> | |
| 672 | + <div className="big"><IcoSearch size={34} /></div> | |
| 673 | + <h3>Aucun logement dans cette zone</h3> | |
| 674 | + <p>Élargissez la carte ou modifiez vos filtres.</p> | |
| 675 | + <div className="ms-empty-actions"> | |
| 676 | + <button className="btn btn-primary" onClick={widenArea}> | |
| 677 | + Élargir la zone | |
| 678 | + </button> | |
| 679 | + {hasZone && ( | |
| 680 | + <button className="btn btn-ghost" onClick={clearZone}> | |
| 681 | + Effacer la zone dessinée | |
| 682 | + </button> | |
| 683 | + )} | |
| 684 | + </div> | |
| 685 | + </div> | |
| 686 | + )} | |
| 687 | + | |
| 688 | + {!error && listings.map((l) => ( | |
| 689 | + <div | |
| 690 | + key={l.uid} | |
| 691 | + data-uid={l.uid} | |
| 692 | + className={ | |
| 693 | + "map-card" + | |
| 694 | + (selectedUid === l.uid ? " map-card-sel" : "") + | |
| 695 | + (mapHoverUid === l.uid ? " map-card-hover" : "") | |
| 696 | + } | |
| 697 | + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} | |
| 698 | + onMouseLeave={() => engineRef.current?.setHovered(null, "app")} | |
| 699 | + onFocus={() => engineRef.current?.setHovered(l.uid, "app")} | |
| 700 | + onBlur={() => engineRef.current?.setHovered(null, "app")} | |
| 701 | + onClickCapture={(e) => onCardClick(e, l.uid)} | |
| 702 | + > | |
| 703 | + <ListingCard l={l} /> | |
| 704 | + </div> | |
| 705 | + ))} | |
| 706 | + | |
| 707 | + {!error && total !== null && total > 0 && ( | |
| 708 | + <Pager | |
| 709 | + page={qy.page} pageSize={PAGE_SIZE} total={total} | |
| 710 | + onPage={(p) => { | |
| 711 | + setQy((q) => ({ ...q, page: p })); | |
| 712 | + listRef.current?.scrollTo({ top: 0, behavior: "smooth" }); | |
| 713 | + }} | |
| 714 | + /> | |
| 715 | + )} | |
| 716 | + | |
| 717 | + {hoverOffscreen && ( | |
| 718 | + <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true"> | |
| 719 | + {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"} | |
| 720 | + </div> | |
| 721 | + )} | |
| 722 | + </div> | |
| 723 | + | |
| 724 | + {/* ------------------------------ carte ------------------------------ */} | |
| 725 | + <div className="mapview" role="application" aria-label="Carte des logements"> | |
| 726 | + <KaMapView | |
| 727 | + theme={louKaMapTheme} | |
| 728 | + mapboxToken={MAPBOX_TOKEN} | |
| 729 | + center={initialCamera ?? QUEBEC} | |
| 730 | + zoom={(initialCamera ?? QUEBEC).zoom} | |
| 731 | + pitch={50} | |
| 732 | + basemap={{ theme: "default", showLandmarks: true }} | |
| 733 | + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }} | |
| 734 | + onMoveEnd={onMoveEnd} | |
| 735 | + onSelect={onSelect} | |
| 736 | + onHover={onHover} | |
| 737 | + onDraw={onDraw} | |
| 738 | + onClusterHover={setClusterTip} | |
| 739 | + > | |
| 740 | + <EngineBridge onEngine={handleEngine} /> | |
| 741 | + <SelectionMirror selectedUid={selectedUid} /> | |
| 742 | + <KaBrandBadge /> | |
| 743 | + <Tilt3DControl /> | |
| 744 | + <DrawControl /> | |
| 745 | + <LocateControl /> | |
| 746 | + | |
| 747 | + {/* « Rechercher quand je déplace la carte » + zone divergée */} | |
| 748 | + <div className="ka-search-area" role="group" aria-label="Recherche géographique"> | |
| 749 | + {dirty && !searchOnMove && !hasZone && ( | |
| 750 | + <button type="button" className="ka-search-area-btn" | |
| 751 | + onClick={searchThisArea} disabled={loading}> | |
| 752 | + {loading ? "Recherche…" : "Rechercher dans cette zone"} | |
| 753 | + </button> | |
| 754 | + )} | |
| 755 | + {!hasZone && !drawing && ( | |
| 756 | + <label className="ka-search-area-auto"> | |
| 757 | + <input | |
| 758 | + type="checkbox" | |
| 759 | + checked={searchOnMove} | |
| 760 | + onChange={() => { | |
| 761 | + const next = !searchOnMove; | |
| 762 | + setSearchOnMove(next); | |
| 763 | + if (next && dirty) searchThisArea(); | |
| 764 | + }} | |
| 765 | + /> | |
| 766 | + <span>Rechercher quand je déplace la carte</span> | |
| 767 | + </label> | |
| 768 | + )} | |
| 769 | + </div> | |
| 770 | + | |
| 771 | + {loading && ( | |
| 772 | + <div className="ka-loading" role="status" aria-live="polite"> | |
| 773 | + <span className="ka-loading-dot" aria-hidden="true" /> | |
| 774 | + Mise à jour des logements… | |
| 775 | + </div> | |
| 776 | + )} | |
| 777 | + | |
| 778 | + {total === 0 && !loading && ( | |
| 779 | + <div className="ka-empty" role="status"> | |
| 780 | + <strong>Aucun logement dans cette zone.</strong> | |
| 781 | + <span>Élargissez la carte ou modifiez vos filtres.</span> | |
| 782 | + </div> | |
| 783 | + )} | |
| 784 | + | |
| 785 | + {clusterTip && ( | |
| 786 | + <div | |
| 787 | + className="ka-cluster-tip" | |
| 788 | + style={{ left: clusterTip.x, top: clusterTip.y }} | |
| 789 | + > | |
| 790 | + {clusterTip.count.toLocaleString("fr-CA")} logements | |
| 791 | + {clusterTip.min != null && clusterTip.max != null && ( | |
| 792 | + <small> | |
| 793 | + {clusterTip.min === clusterTip.max | |
| 794 | + ? formatCompactPrice(clusterTip.min) | |
| 795 | + : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`} | |
| 796 | + {" "}/mois | |
| 797 | + </small> | |
| 798 | + )} | |
| 799 | + </div> | |
| 800 | + )} | |
| 801 | + | |
| 802 | + {!isMobile && <PropertyPreview render={(p) => <LazyPreview p={p} />} />} | |
| 803 | + </KaMapView> | |
| 804 | + | |
| 805 | + {/* ---------- carrousel mobile synchronisé aux marqueurs ---------- */} | |
| 806 | + {isMobile && listings.length > 0 && ( | |
| 807 | + <div | |
| 808 | + className="ms-carousel" | |
| 809 | + ref={carouselRef} | |
| 810 | + onScroll={onCarouselScroll} | |
| 811 | + aria-label="Annonces de la zone visible" | |
| 812 | + > | |
| 813 | + {listings.map((l) => ( | |
| 814 | + <Link | |
| 815 | + key={l.uid} | |
| 816 | + to={`/logement/${encodeURIComponent(l.uid)}`} | |
| 817 | + data-uid={l.uid} | |
| 818 | + className={`ms-car-item${selectedUid === l.uid ? " sel" : ""}`} | |
| 819 | + > | |
| 820 | + <SmartImg | |
| 821 | + src={l.images?.[0] ?? null} width_={160} | |
| 822 | + fallbackLabel={l.unit_type || undefined} | |
| 823 | + alt="" loading="lazy" decoding="async" | |
| 824 | + /> | |
| 825 | + <div className="ms-car-body"> | |
| 826 | + <div className="ms-car-price"> | |
| 827 | + {fmtPrice(l.price, l.price_label)} | |
| 828 | + <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact /> | |
| 829 | + </div> | |
| 830 | + <div className="ms-car-title">{l.title || l.address}</div> | |
| 831 | + <div className="ms-car-meta"> | |
| 832 | + {[l.unit_type, l.sector || l.city].filter(Boolean).join(" · ")} | |
| 833 | + </div> | |
| 834 | + </div> | |
| 835 | + </Link> | |
| 836 | + ))} | |
| 837 | + {total !== null && total > qy.page * PAGE_SIZE && ( | |
| 838 | + <button | |
| 839 | + className="ms-car-more" | |
| 840 | + onClick={(e) => { | |
| 841 | + e.preventDefault(); | |
| 842 | + setQy((q) => ({ ...q, page: q.page + 1 })); | |
| 843 | + carouselRef.current?.scrollTo({ left: 0 }); | |
| 844 | + }} | |
| 845 | + > | |
| 846 | + Page suivante → | |
| 847 | + </button> | |
| 848 | + )} | |
| 849 | + </div> | |
| 850 | + )} | |
| 851 | + </div> | |
| 852 | + </div> | |
| 853 | + ); | |
| 854 | +} | |
| 855 | + | |
| 856 | +/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. | |
| 857 | + * (Le survol liste→carte passe directement par setHovered — < 100 ms.) */ | |
| 858 | +function SelectionMirror({ selectedUid }: { selectedUid: string | null }) { | |
| 859 | + const map = useKaMap(); | |
| 860 | + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]); | |
| 861 | + return null; | |
| 862 | +} | |
added
frontend/src/search/seen.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// search/seen.ts : annonces déjà consultées (état « vu » des marqueurs). | |
| 5 | +// Persisté en localStorage, plafonné aux 400 visites les plus récentes. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | + | |
| 8 | +const KEY = "louka_vus"; | |
| 9 | +const MAX = 400; | |
| 10 | + | |
| 11 | +export function seenUids(): Set<string> { | |
| 12 | + try { | |
| 13 | + const raw = localStorage.getItem(KEY); | |
| 14 | + return new Set(raw ? (JSON.parse(raw) as string[]) : []); | |
| 15 | + } catch { | |
| 16 | + return new Set(); | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +export function markSeen(uid: string): void { | |
| 21 | + try { | |
| 22 | + const raw = localStorage.getItem(KEY); | |
| 23 | + const list = raw ? (JSON.parse(raw) as string[]) : []; | |
| 24 | + const next = [uid, ...list.filter((u) => u !== uid)].slice(0, MAX); | |
| 25 | + localStorage.setItem(KEY, JSON.stringify(next)); | |
| 26 | + } catch { | |
| 27 | + // stockage indisponible (navigation privée) : l'état « vu » est optionnel | |
| 28 | + } | |
| 29 | +} | |
modified
frontend/src/styles.css
+138 −0
@@ -1630,3 +1630,141 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | ||
| 1630 | 1630 | .fv-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: var(--ink-2); margin: 6px 0 4px; } |
| 1631 | 1631 | .fv-demo { display: flex; flex-wrap: wrap; gap: 8px; } |
| 1632 | 1632 | .chip.chip-deal.on { background: var(--blue); border-color: var(--blue); color: var(--white); } |
| 1633 | + | |
| 1634 | +/* ============================================================================= | |
| 1635 | + MapSearch — recherche synchronisée liste ↔ carte (search/MapSearch.tsx) | |
| 1636 | + Une seule vérité partagée : compteur, tri, survols et sélections miroirs. | |
| 1637 | +============================================================================= */ | |
| 1638 | + | |
| 1639 | +.ms { position: relative; } | |
| 1640 | +.ms-list { position: relative; scroll-behavior: smooth; } | |
| 1641 | + | |
| 1642 | +.ms-list-head { | |
| 1643 | + display: flex; align-items: center; justify-content: space-between; | |
| 1644 | + gap: 10px; flex-wrap: wrap; padding: 2px 2px 4px; | |
| 1645 | +} | |
| 1646 | +.ms-count { font-size: 13.5px; color: var(--ink-2); } | |
| 1647 | +.ms-count b { color: var(--ink); font-size: 15px; } | |
| 1648 | +.ms-nopos { | |
| 1649 | + margin-left: 8px; font-size: 11px; color: var(--ink-3); | |
| 1650 | + border: 1px dashed var(--line-strong); border-radius: var(--r-pill); | |
| 1651 | + padding: 2px 8px; cursor: help; | |
| 1652 | +} | |
| 1653 | +.ms-sort { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-3); } | |
| 1654 | +.ms-sort select { | |
| 1655 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 1656 | + background: var(--surface); padding: 6px 8px; font-size: 12.5px; | |
| 1657 | + font-weight: 600; color: var(--ink); | |
| 1658 | +} | |
| 1659 | + | |
| 1660 | +.ms-pills { margin: 0 0 4px; } | |
| 1661 | + | |
| 1662 | +.ms-refit { | |
| 1663 | + align-self: flex-start; border: 1.5px solid var(--ink); | |
| 1664 | + border-radius: var(--r-pill); background: var(--surface); | |
| 1665 | + padding: 6px 12px; font-size: 12px; font-weight: 600; color: var(--ink-2); | |
| 1666 | + cursor: pointer; box-shadow: var(--shadow-off-soft); | |
| 1667 | +} | |
| 1668 | +.ms-refit:hover { background: var(--navy); color: var(--white); border-color: var(--navy); } | |
| 1669 | + | |
| 1670 | +/* barre de progression fine — jamais d'écran qui clignote */ | |
| 1671 | +.ms-progress { | |
| 1672 | + position: sticky; top: 0; z-index: 4; height: 3px; border-radius: 2px; | |
| 1673 | + background: linear-gradient(90deg, transparent, var(--accent), transparent); | |
| 1674 | + background-size: 200% 100%; animation: ms-progress 1s linear infinite; | |
| 1675 | +} | |
| 1676 | +@keyframes ms-progress { from { background-position: 200% 0; } to { background-position: -200% 0; } } | |
| 1677 | + | |
| 1678 | +.ms-empty { | |
| 1679 | + border: 1.5px dashed var(--line-strong); border-radius: var(--r-card); | |
| 1680 | + padding: 26px 18px; text-align: center; color: var(--ink-2); | |
| 1681 | +} | |
| 1682 | +.ms-empty .big { margin-bottom: 6px; color: var(--ink-3); } | |
| 1683 | +.ms-empty h3 { margin: 0 0 6px; font-size: 16px; color: var(--ink); } | |
| 1684 | +.ms-empty p { margin: 0 0 14px; font-size: 13px; } | |
| 1685 | +.ms-empty-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } | |
| 1686 | + | |
| 1687 | +/* miroirs survol / sélection venus de la carte */ | |
| 1688 | +.map-card { transition: transform 0.15s ease, box-shadow 0.15s ease; } | |
| 1689 | +.map-card-hover { | |
| 1690 | + outline: 2px solid var(--accent); outline-offset: 2px; | |
| 1691 | + transform: translateY(-1px); | |
| 1692 | +} | |
| 1693 | +.map-card-pulse { animation: ms-pulse 0.9s ease 1; } | |
| 1694 | +@keyframes ms-pulse { | |
| 1695 | + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } | |
| 1696 | + 70% { box-shadow: 0 0 0 12px transparent; } | |
| 1697 | + 100% { box-shadow: 0 0 0 0 transparent; } | |
| 1698 | +} | |
| 1699 | + | |
| 1700 | +/* indicateur discret : l'annonce survolée sur la carte est hors écran */ | |
| 1701 | +.ms-hover-hint { | |
| 1702 | + position: sticky; z-index: 5; align-self: center; | |
| 1703 | + background: var(--navy); color: var(--white); | |
| 1704 | + border-radius: var(--r-pill); padding: 5px 12px; | |
| 1705 | + font-size: 11.5px; font-weight: 600; pointer-events: none; | |
| 1706 | + box-shadow: var(--shadow-off-soft); | |
| 1707 | +} | |
| 1708 | +.ms-hover-hint.haut { top: 8px; order: -1; } | |
| 1709 | +.ms-hover-hint.bas { bottom: 8px; } | |
| 1710 | + | |
| 1711 | +/* ---------------- carrousel mobile synchronisé aux marqueurs ------------- */ | |
| 1712 | +.ms-carousel { | |
| 1713 | + position: absolute; left: 0; right: 0; bottom: 10px; z-index: 12; | |
| 1714 | + display: flex; gap: 10px; overflow-x: auto; padding: 4px 12px; | |
| 1715 | + scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; | |
| 1716 | + scrollbar-width: none; | |
| 1717 | +} | |
| 1718 | +.ms-carousel::-webkit-scrollbar { display: none; } | |
| 1719 | +.ms-car-item { | |
| 1720 | + flex: 0 0 78%; max-width: 340px; scroll-snap-align: center; | |
| 1721 | + display: grid; grid-template-columns: 108px 1fr; gap: 0; | |
| 1722 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 1723 | + border-radius: var(--r-card); overflow: hidden; | |
| 1724 | + box-shadow: var(--shadow-off-soft); text-decoration: none; color: inherit; | |
| 1725 | +} | |
| 1726 | +.ms-car-item.sel { outline: 2.5px solid var(--accent); outline-offset: 1px; } | |
| 1727 | +.ms-car-item img, .ms-car-item .smart-img, .ms-car-item .card-fallback { | |
| 1728 | + width: 108px; height: 96px; object-fit: cover; display: block; | |
| 1729 | +} | |
| 1730 | +.ms-car-body { padding: 9px 12px; min-width: 0; } | |
| 1731 | +.ms-car-price { font-size: 14.5px; font-weight: 700; color: var(--ink); display: flex; gap: 6px; align-items: center; } | |
| 1732 | +.ms-car-title { | |
| 1733 | + font-size: 12px; color: var(--ink-2); margin-top: 2px; | |
| 1734 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 1735 | +} | |
| 1736 | +.ms-car-meta { font-size: 11px; color: var(--ink-3); margin-top: 3px; } | |
| 1737 | +.ms-car-more { | |
| 1738 | + flex: 0 0 auto; scroll-snap-align: center; align-self: stretch; | |
| 1739 | + border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 1740 | + background: var(--navy); color: var(--white); | |
| 1741 | + font-size: 12.5px; font-weight: 600; padding: 0 18px; cursor: pointer; | |
| 1742 | +} | |
| 1743 | + | |
| 1744 | +/* mobile : carte plein cadre, liste masquée, carrousel par-dessus la carte */ | |
| 1745 | +@media (max-width: 780px) { | |
| 1746 | + .ms { height: calc(100dvh - 190px); } | |
| 1747 | + .ms-list { display: none; } | |
| 1748 | + .ms .mapview { height: 100%; } | |
| 1749 | + .ka-search-area { top: 8px; } | |
| 1750 | +} | |
| 1751 | +@media (min-width: 781px) { | |
| 1752 | + .ms-carousel { display: none; } | |
| 1753 | +} | |
| 1754 | + | |
| 1755 | +/* mobile : réorganisation du chrome carte autour du carrousel */ | |
| 1756 | +@media (max-width: 780px) { | |
| 1757 | + .ms .ka-draw-btn { top: 56px; left: 10px; bottom: auto; } | |
| 1758 | + .ms .ka-loading { bottom: 132px; } | |
| 1759 | + .ms .ka-empty { transform: translate(-50%, -70%); } | |
| 1760 | +} | |
| 1761 | +/* l'auto-défilement vers la carte laisse l'entête de résultats visible */ | |
| 1762 | +.ms { scroll-margin-top: 108px; } | |
| 1763 | + | |
| 1764 | +/* mobile, vue carte : chrome sans chevauchement (case en haut, outils sous | |
| 1765 | + elle, FAB Filtres au-dessus du carrousel) */ | |
| 1766 | +@media (max-width: 780px) { | |
| 1767 | + .ms .ka-draw-btn { top: 88px; left: 10px; bottom: auto; } | |
| 1768 | + .ms .ka-locate { top: 88px; right: 10px; } | |
| 1769 | + .fab-carte { bottom: 148px; } | |
| 1770 | +} | |
deleted
frontend/tsconfig.tsbuildinfo
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −{"root":["./src/app.tsx","./src/logo.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/fairvaluebadge.tsx","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/listingmap3d.tsx","./src/components/logo.tsx","./src/components/mapview.tsx","./src/components/pager.tsx","./src/components/priceanalysis.tsx","./src/components/quartierblock.tsx","./src/components/smartimg.tsx","./src/ka/groupekabadge.tsx","./src/ka/kafooter.tsx","./src/ka/stats/kacharts.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/bienvenue.tsx","./src/pages/bot.tsx","./src/pages/contact.tsx","./src/pages/favoris.tsx","./src/pages/gestion.tsx","./src/pages/gestionpublic.tsx","./src/pages/home.tsx","./src/pages/justevaleur.tsx","./src/pages/listing.tsx","./src/pages/passerelle.tsx","./src/pages/privacy.tsx","./src/pages/profile.tsx","./src/pages/publicprofile.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx","./src/pages/terms.tsx","./src/pages/ville.tsx"],"version":"5.9.3"} | |
| \ No newline at end of file | ||
modified
louka/web.py
+170 −0
@@ -255,6 +255,176 @@ def listings_geojson( | ||
| 255 | 255 | "totalGeocoded": total_geo, "totalMatching": total_all} |
| 256 | 256 | |
| 257 | 257 | |
| 258 | +# --- Recherche unifiée liste + carte ----------------------------------------- | |
| 259 | +# Une seule vérité partagée : la même requête sert la page de liste ET les | |
| 260 | +# points de la carte, dans la même réponse. Le compteur affiché en tête de | |
| 261 | +# liste est par construction égal au nombre de points envoyés à la carte. | |
| 262 | + | |
| 263 | +_SEARCH_SORTS = { | |
| 264 | + "prix": " ORDER BY price IS NULL, price ASC", | |
| 265 | + "prix_desc": " ORDER BY price IS NULL, price DESC", | |
| 266 | + "recent": " ORDER BY first_seen DESC", | |
| 267 | + "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC," | |
| 268 | + " price IS NULL, price ASC"), | |
| 269 | +} | |
| 270 | +_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"} | |
| 271 | + | |
| 272 | + | |
| 273 | +def _parse_poly(poly: str) -> list[tuple[float, float]]: | |
| 274 | + """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400.""" | |
| 275 | + try: | |
| 276 | + pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p] | |
| 277 | + except ValueError: | |
| 278 | + raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…") | |
| 279 | + if len(pts) < 3 or any(len(p) != 2 for p in pts): | |
| 280 | + raise HTTPException(400, "poly : au moins 3 sommets lng,lat") | |
| 281 | + return pts # type: ignore[return-value] | |
| 282 | + | |
| 283 | + | |
| 284 | +def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool: | |
| 285 | + """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon).""" | |
| 286 | + inside = False | |
| 287 | + j = len(ring) - 1 | |
| 288 | + for i in range(len(ring)): | |
| 289 | + xi, yi = ring[i] | |
| 290 | + xj, yj = ring[j] | |
| 291 | + if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi: | |
| 292 | + inside = not inside | |
| 293 | + j = i | |
| 294 | + return inside | |
| 295 | + | |
| 296 | + | |
| 297 | +@app.get("/api/search") | |
| 298 | +def search_unified( | |
| 299 | + city: str | None = None, | |
| 300 | + sector: str | None = None, | |
| 301 | + unit_type: str | None = None, | |
| 302 | + source: str | None = None, | |
| 303 | + price_max: float | None = None, | |
| 304 | + price_min: float | None = None, | |
| 305 | + pets: str | None = None, | |
| 306 | + furnished: int | None = None, | |
| 307 | + available_by: str | None = None, | |
| 308 | + area_min: float | None = None, | |
| 309 | + q: str | None = None, | |
| 310 | + deal: str | None = None, # sous | marche | sur (juste valeur) | |
| 311 | + bbox: str | None = None, # ouest,sud,est,nord (zone visible carte) | |
| 312 | + poly: str | None = None, # lng,lat;… (zone dessinée) | |
| 313 | + sort: str = "prix", # prix | prix_desc | recent | deal | |
| 314 | + page: int = 1, | |
| 315 | + page_size: int = Query(20, ge=1, le=100), | |
| 316 | + include: str = "tout", # tout | liste (points inchangés côté client) | |
| 317 | +): | |
| 318 | + """Recherche synchronisée liste ↔ carte (une réponse = les deux vues). | |
| 319 | + | |
| 320 | + Retourne `total` (compteur partagé), `listings` (la page demandée) et | |
| 321 | + `points` (TOUS les points correspondants, format compact | |
| 322 | + [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la | |
| 323 | + position d'un point dans `points` donne directement sa page de liste. | |
| 324 | + Seules les annonces géolocalisées participent (la règle d'or : chaque | |
| 325 | + annonce de la liste est visible sur la carte) ; `unpositioned` compte | |
| 326 | + les annonces filtrées sans coordonnées, pour l'afficher honnêtement. | |
| 327 | + """ | |
| 328 | + if sort not in _SEARCH_SORTS: | |
| 329 | + raise HTTPException(400, f"sort inconnu : {sort}") | |
| 330 | + ring = _parse_poly(poly) if poly else None | |
| 331 | + | |
| 332 | + con = db.connect() | |
| 333 | + sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" | |
| 334 | + " WHERE dup_of IS NULL AND active=1 AND published=1" | |
| 335 | + " AND lat IS NOT NULL AND lng IS NOT NULL") | |
| 336 | + args: list = [] | |
| 337 | + if bbox: | |
| 338 | + try: | |
| 339 | + west, south, east, north = (float(v) for v in bbox.split(",")) | |
| 340 | + except ValueError: | |
| 341 | + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord") | |
| 342 | + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" | |
| 343 | + args += [south, north, west, east] | |
| 344 | + if ring: | |
| 345 | + # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval | |
| 346 | + lngs = [p[0] for p in ring]; lats = [p[1] for p in ring] | |
| 347 | + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" | |
| 348 | + args += [min(lats), max(lats), min(lngs), max(lngs)] | |
| 349 | + if city: | |
| 350 | + sql += " AND city=?"; args.append(city) | |
| 351 | + if sector: | |
| 352 | + sql += " AND sector LIKE ?"; args.append(f"%{sector}%") | |
| 353 | + if unit_type: | |
| 354 | + sql += " AND unit_type=?"; args.append(unit_type) | |
| 355 | + if source: | |
| 356 | + sql += " AND source=?"; args.append(source) | |
| 357 | + if price_max is not None: | |
| 358 | + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) | |
| 359 | + if price_min is not None: | |
| 360 | + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) | |
| 361 | + if pets == "oui": | |
| 362 | + sql += " AND pets IN ('oui','conditions')" | |
| 363 | + elif pets: | |
| 364 | + sql += " AND pets=?"; args.append(pets) | |
| 365 | + if furnished in (0, 1): | |
| 366 | + sql += " AND furnished=?"; args.append(furnished) | |
| 367 | + if available_by: | |
| 368 | + sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ | |
| 369 | + " AND availability_date<=?))" | |
| 370 | + args.append(available_by) | |
| 371 | + if area_min is not None: | |
| 372 | + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) | |
| 373 | + if q: | |
| 374 | + sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" | |
| 375 | + args += [f"%{q}%"] * 3 | |
| 376 | + if deal in ("sous", "marche", "sur"): | |
| 377 | + sql += " AND fv.verdict=?"; args.append(deal) | |
| 378 | + | |
| 379 | + rows = con.execute( | |
| 380 | + "SELECT uid, lng, lat, price, fv.verdict AS v" + sql + _SEARCH_SORTS[sort], | |
| 381 | + args).fetchall() | |
| 382 | + if ring: | |
| 383 | + rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)] | |
| 384 | + total = len(rows) | |
| 385 | + | |
| 386 | + # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce | |
| 387 | + # tableau détermine la page (points et liste partagent le même tri) | |
| 388 | + points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"], | |
| 389 | + _FV_CODES.get(r["v"] or "", None)] for r in rows] | |
| 390 | + | |
| 391 | + # page demandée, ramenée dans les bornes si les filtres l'ont dépassée | |
| 392 | + last = max(1, -(-total // page_size)) | |
| 393 | + page = min(max(1, page), last) | |
| 394 | + page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]] | |
| 395 | + | |
| 396 | + listings: list[dict] = [] | |
| 397 | + if page_uids: | |
| 398 | + marks = ",".join("?" * len(page_uids)) | |
| 399 | + by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute( | |
| 400 | + "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high," | |
| 401 | + " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict," | |
| 402 | + " fv.confidence AS fv_confidence" | |
| 403 | + " FROM listings LEFT JOIN fairvalue fv USING (uid)" | |
| 404 | + f" WHERE uid IN ({marks})", page_uids).fetchall()} | |
| 405 | + listings = [by_uid[u] for u in page_uids if u in by_uid] | |
| 406 | + | |
| 407 | + # annonces filtrées mais sans coordonnées (affichage honnête, hors carte) | |
| 408 | + sql_nogeo = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", | |
| 409 | + " AND (lat IS NULL OR lng IS NULL)", 1) | |
| 410 | + args_nogeo = list(args) | |
| 411 | + for spatial in (bbox, poly): | |
| 412 | + if spatial: | |
| 413 | + sql_nogeo = sql_nogeo.replace( | |
| 414 | + " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "", 1) | |
| 415 | + del args_nogeo[0:4] | |
| 416 | + unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo, | |
| 417 | + args_nogeo).fetchone()["c"] | |
| 418 | + con.close() | |
| 419 | + | |
| 420 | + return { | |
| 421 | + "total": total, "page": page, "page_size": page_size, "sort": sort, | |
| 422 | + "listings": listings, | |
| 423 | + "points": points if include != "liste" else None, | |
| 424 | + "unpositioned": unpositioned, | |
| 425 | + } | |
| 426 | + | |
| 427 | + | |
| 258 | 428 | # --- Miniatures optimisées (WebP, taille écran) ------------------------------ |
| 259 | 429 | # Sert les images des annonces redimensionnées et compressées (perf mobile) : |
| 260 | 430 | # /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle |
added
package-lock.json
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +{ | |
| 2 | + "name": "lou-ka", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "lou-ka", | |
| 9 | + "version": "1.0.0", | |
| 10 | + "license": "ISC", | |
| 11 | + "devDependencies": { | |
| 12 | + "playwright": "^1.62.1" | |
| 13 | + } | |
| 14 | + }, | |
| 15 | + "node_modules/fsevents": { | |
| 16 | + "version": "2.3.2", | |
| 17 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", | |
| 18 | + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | |
| 19 | + "dev": true, | |
| 20 | + "hasInstallScript": true, | |
| 21 | + "license": "MIT", | |
| 22 | + "optional": true, | |
| 23 | + "os": [ | |
| 24 | + "darwin" | |
| 25 | + ], | |
| 26 | + "engines": { | |
| 27 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 28 | + } | |
| 29 | + }, | |
| 30 | + "node_modules/playwright": { | |
| 31 | + "version": "1.62.1", | |
| 32 | + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", | |
| 33 | + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", | |
| 34 | + "dev": true, | |
| 35 | + "license": "Apache-2.0", | |
| 36 | + "dependencies": { | |
| 37 | + "playwright-core": "1.62.1" | |
| 38 | + }, | |
| 39 | + "bin": { | |
| 40 | + "playwright": "cli.js" | |
| 41 | + }, | |
| 42 | + "engines": { | |
| 43 | + "node": ">=20" | |
| 44 | + }, | |
| 45 | + "optionalDependencies": { | |
| 46 | + "fsevents": "2.3.2" | |
| 47 | + } | |
| 48 | + }, | |
| 49 | + "node_modules/playwright-core": { | |
| 50 | + "version": "1.62.1", | |
| 51 | + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", | |
| 52 | + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", | |
| 53 | + "dev": true, | |
| 54 | + "license": "Apache-2.0", | |
| 55 | + "bin": { | |
| 56 | + "playwright-core": "cli.js" | |
| 57 | + }, | |
| 58 | + "engines": { | |
| 59 | + "node": ">=20" | |
| 60 | + } | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
added
package.json
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +{ | |
| 2 | + "name": "lou-ka", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "description": "**[www.lou-ka.com](https://www.lou-ka.com)** — un service **[Groupe Ka](https://www.groupe-ka.com)**", | |
| 5 | + "main": "index.js", | |
| 6 | + "directories": { | |
| 7 | + "doc": "docs", | |
| 8 | + "test": "tests" | |
| 9 | + }, | |
| 10 | + "scripts": { | |
| 11 | + "test": "echo \"Error: no test specified\" && exit 1" | |
| 12 | + }, | |
| 13 | + "repository": { | |
| 14 | + "type": "git", | |
| 15 | + "url": "gitsrv:srv/git/lou-ka.git" | |
| 16 | + }, | |
| 17 | + "keywords": [], | |
| 18 | + "author": "", | |
| 19 | + "license": "ISC", | |
| 20 | + "type": "commonjs", | |
| 21 | + "devDependencies": { | |
| 22 | + "playwright": "^1.62.1" | |
| 23 | + } | |
| 24 | +} | |
added
reports/sync-validation/01-carte-desktop.png
+0 −0
Binary file not shown.
added
reports/sync-validation/02-selection-miroir.png
+0 −0
Binary file not shown.
added
reports/sync-validation/03-filtre-tri.png
+0 −0
Binary file not shown.
added
reports/sync-validation/04-polygone.png
+0 −0
Binary file not shown.
added
reports/sync-validation/05-zero-resultat.png
+0 −0
Binary file not shown.
added
reports/sync-validation/06-url-partagee.png
+0 −0
Binary file not shown.
added
reports/sync-validation/07-mobile-carrousel.png
+0 −0
Binary file not shown.
added
reports/sync-validation/08-mobile-carte.png
+0 −0
Binary file not shown.
added
reports/sync-validation/09-geolocalisation.png
+0 −0
Binary file not shown.
added
scripts/test-sync.mjs
+350 −0
@@ -0,0 +1,350 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// scripts/test-sync.mjs : validation E2E de la synchronisation liste ↔ carte | |
| 5 | +// (critères d'acceptation 1-9 du chantier « sync légendaire »). | |
| 6 | +// Usage : node scripts/test-sync.mjs (LOUKA_BASE pour cibler un déploiement) | |
| 7 | +// Captures : reports/sync-validation/*.png | |
| 8 | +// ----------------------------------------------------------------------------- | |
| 9 | +import { chromium, devices } from 'playwright'; | |
| 10 | +import { mkdirSync } from 'fs'; | |
| 11 | + | |
| 12 | +const BASE = process.env.LOUKA_BASE || 'http://localhost:8095'; | |
| 13 | +const OUT = new URL('../reports/sync-validation/', import.meta.url).pathname; | |
| 14 | +mkdirSync(OUT, { recursive: true }); | |
| 15 | + | |
| 16 | +let passed = 0, failed = 0; | |
| 17 | +const results = []; | |
| 18 | +function check(name, ok, detail = '') { | |
| 19 | + results.push({ name, ok, detail }); | |
| 20 | + if (ok) { passed++; console.log(` ✓ ${name}${detail ? ` — ${detail}` : ''}`); } | |
| 21 | + else { failed++; console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); } | |
| 22 | +} | |
| 23 | + | |
| 24 | +const browser = await chromium.launch(); | |
| 25 | + | |
| 26 | +const initScript = () => { | |
| 27 | + localStorage.setItem('louka-consent-v1', | |
| 28 | + JSON.stringify({ essential: true, preferences: true, statistics: false, ts: 1 })); | |
| 29 | + sessionStorage.setItem('louka_geo_session', '1'); // pas de prompt géoloc ici | |
| 30 | +}; | |
| 31 | + | |
| 32 | +async function newDesktop() { | |
| 33 | + const ctx = await browser.newContext({ | |
| 34 | + viewport: { width: 1440, height: 900 }, locale: 'fr-CA', | |
| 35 | + }); | |
| 36 | + await ctx.addInitScript(initScript); | |
| 37 | + return ctx; | |
| 38 | +} | |
| 39 | + | |
| 40 | +const engineState = (page) => page.evaluate(() => { | |
| 41 | + const m = globalThis.__kaMap; | |
| 42 | + if (!m) return null; | |
| 43 | + const st = m.getState(); | |
| 44 | + return { ...st, nProps: m.getState().activeLayers.length >= 0 | |
| 45 | + ? (m.map.querySourceFeatures ? undefined : undefined) : undefined }; | |
| 46 | +}); | |
| 47 | + | |
| 48 | +const engineCount = (page) => page.evaluate(() => { | |
| 49 | + const m = globalThis.__kaMap; | |
| 50 | + // byId est privé : compter via les données GeoJSON poussées | |
| 51 | + return m ? m['byId'].size : -1; | |
| 52 | +}); | |
| 53 | + | |
| 54 | +const counterTotal = async (page) => { | |
| 55 | + const txt = await page.locator('.ms-count b').first().textContent({ timeout: 15000 }); | |
| 56 | + return Number(txt.replace(/\s| |,/g, '')); | |
| 57 | +}; | |
| 58 | + | |
| 59 | +const canvasBox = async (page) => { | |
| 60 | + await page.locator('.mapview').scrollIntoViewIfNeeded(); | |
| 61 | + await page.waitForTimeout(400); | |
| 62 | + return page.locator('.ka-map-canvas').boundingBox(); | |
| 63 | +}; | |
| 64 | + | |
| 65 | +const waitSettled = async (page, ms = 1200) => { | |
| 66 | + await page.waitForTimeout(ms); | |
| 67 | + await page.waitForFunction(() => !document.querySelector('.ms-progress'), null, { timeout: 20000 }) | |
| 68 | + .catch(() => {}); | |
| 69 | +}; | |
| 70 | + | |
| 71 | +// =============================== TESTS ===================================== | |
| 72 | +console.log('— Vue carte desktop —'); | |
| 73 | +{ | |
| 74 | + const ctx = await newDesktop(); | |
| 75 | + const page = await ctx.newPage(); | |
| 76 | + await page.goto(`${BASE}/?view=carte`, { waitUntil: 'domcontentloaded' }); | |
| 77 | + await page.waitForSelector('.ms-count b', { timeout: 30000 }); | |
| 78 | + await page.waitForFunction(() => { | |
| 79 | + const m = globalThis.__kaMap; | |
| 80 | + return m && m['byId'].size > 0; | |
| 81 | + }, null, { timeout: 30000 }); | |
| 82 | + await waitSettled(page, 2500); | |
| 83 | + | |
| 84 | + // C1 — compteur liste = points carte | |
| 85 | + const total = await counterTotal(page); | |
| 86 | + const nPts = await engineCount(page); | |
| 87 | + check('C1 compteur liste = points carte (arrivée)', total === nPts, `${total} = ${nPts}`); | |
| 88 | + await page.screenshot({ path: `${OUT}01-carte-desktop.png` }); | |
| 89 | + | |
| 90 | + // C2/C5 — déplacement carte → liste mise à jour, jamais de résultat périmé | |
| 91 | + const urlBefore = page.url(); | |
| 92 | + const cb = await canvasBox(page); | |
| 93 | + const mx = cb.x + cb.width / 2, my = cb.y + cb.height / 2; | |
| 94 | + await page.mouse.move(mx, my); | |
| 95 | + await page.mouse.down(); | |
| 96 | + await page.mouse.move(mx - 260, my - 60, { steps: 12 }); | |
| 97 | + await page.mouse.up(); | |
| 98 | + await waitSettled(page, 1800); | |
| 99 | + const total2 = await counterTotal(page); | |
| 100 | + const nPts2 = await engineCount(page); | |
| 101 | + check('C2 déplacement → compteur/liste rafraîchis, égalité conservée', | |
| 102 | + total2 === nPts2, `${total2} = ${nPts2}`); | |
| 103 | + check('C7 caméra dans l’URL après déplacement', | |
| 104 | + page.url() !== urlBefore && /lat=/.test(page.url())); | |
| 105 | + | |
| 106 | + // rafale de mouvements rapides : la dernière réponse gagne toujours | |
| 107 | + for (let i = 0; i < 4; i++) { | |
| 108 | + await page.mouse.move(mx, my); | |
| 109 | + await page.mouse.down(); | |
| 110 | + await page.mouse.move(mx - 70 * (i + 1), my + 20, { steps: 4 }); | |
| 111 | + await page.mouse.up(); | |
| 112 | + await page.waitForTimeout(120); | |
| 113 | + } | |
| 114 | + await waitSettled(page, 2200); | |
| 115 | + const total3 = await counterTotal(page); | |
| 116 | + const nPts3 = await engineCount(page); | |
| 117 | + check('C5 mouvements rapides successifs → aucun résultat périmé', | |
| 118 | + total3 === nPts3, `${total3} = ${nPts3}`); | |
| 119 | + | |
| 120 | + // retour dans une zone dense avant les tests de miroirs | |
| 121 | + await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, | |
| 122 | + { waitUntil: 'domcontentloaded' }); | |
| 123 | + await page.waitForSelector('.map-card', { timeout: 30000 }); | |
| 124 | + await waitSettled(page, 2500); | |
| 125 | + | |
| 126 | + // C3 — survol d'une carte de liste → marqueur en survol < 100 ms | |
| 127 | + const firstCard = page.locator('.map-card').first(); | |
| 128 | + const uid = await firstCard.getAttribute('data-uid'); | |
| 129 | + await firstCard.hover(); | |
| 130 | + await page.waitForTimeout(90); | |
| 131 | + const hovered = await page.evaluate(() => globalThis.__kaMap.getState().hoveredPropertyId); | |
| 132 | + check('C3 survol liste → marqueur illuminé (< 100 ms)', hovered === uid, `${hovered}`); | |
| 133 | + | |
| 134 | + // C3 inverse — survol venu de la carte → carte de liste surlignée | |
| 135 | + await page.mouse.move(10, 10); // souris hors de la liste | |
| 136 | + await page.evaluate(() => globalThis.__kaMap.setHovered(null, 'map')); | |
| 137 | + await page.waitForTimeout(60); | |
| 138 | + await page.evaluate((u) => globalThis.__kaMap.setHovered(u, 'map'), uid); | |
| 139 | + await page.waitForTimeout(90); | |
| 140 | + const hl = await page.locator(`.map-card[data-uid="${uid}"]`).getAttribute('class'); | |
| 141 | + check('C3 survol carte → annonce surlignée dans la liste', hl.includes('map-card-hover')); | |
| 142 | + await page.evaluate(() => globalThis.__kaMap.setHovered(null, 'map')); | |
| 143 | + | |
| 144 | + // C4 — clic marqueur (simulé moteur) → liste défile vers l'annonce + mini-fiche | |
| 145 | + const farUid = await page.evaluate(() => { | |
| 146 | + const m = globalThis.__kaMap; | |
| 147 | + const ids = [...m['byId'].keys()]; | |
| 148 | + return ids[Math.min(ids.length - 1, 30)]; | |
| 149 | + }); | |
| 150 | + await page.evaluate((u) => globalThis.__kaMap.select(u, 'map'), farUid); | |
| 151 | + await page.waitForTimeout(1400); | |
| 152 | + const selVisible = await page.evaluate((u) => { | |
| 153 | + const el = document.querySelector(`.map-card[data-uid="${CSS.escape(u)}"]`); | |
| 154 | + if (!el) return 'absente'; | |
| 155 | + const r = el.getBoundingClientRect(); | |
| 156 | + const c = el.closest('.ms-list').getBoundingClientRect(); | |
| 157 | + return r.bottom > c.top && r.top < c.bottom ? 'visible' : 'hors-vue'; | |
| 158 | + }, farUid); | |
| 159 | + check('C4 clic marqueur → défilement de la liste jusqu’à l’annonce', | |
| 160 | + selVisible === 'visible', selVisible); | |
| 161 | + const preview = await page.locator('.ka-preview').count(); | |
| 162 | + check('C4 mini-fiche ouverte au clic marqueur', preview === 1); | |
| 163 | + await page.screenshot({ path: `${OUT}02-selection-miroir.png` }); | |
| 164 | + | |
| 165 | + // C4 inverse — clic annonce → carte recentrée sur le marqueur + sélection | |
| 166 | + await page.evaluate(() => globalThis.__kaMap.select(null, 'app')); | |
| 167 | + const cardUid = await page.locator('.map-card').nth(2).getAttribute('data-uid'); | |
| 168 | + await page.locator('.map-card').nth(2).click(); | |
| 169 | + await page.waitForTimeout(900); | |
| 170 | + const selId = await page.evaluate(() => globalThis.__kaMap.getSelectedId()); | |
| 171 | + check('C4 clic annonce → marqueur sélectionné sur la carte', selId === cardUid); | |
| 172 | + | |
| 173 | + // C2 — filtre resserré depuis l'URL partagée → les deux vues suivent | |
| 174 | + await ctx.close(); | |
| 175 | +} | |
| 176 | + | |
| 177 | +console.log('— Filtres → carte (compteur partagé) —'); | |
| 178 | +{ | |
| 179 | + const ctx = await newDesktop(); | |
| 180 | + const page = await ctx.newPage(); | |
| 181 | + await page.goto(`${BASE}/?view=carte&price_max=900`, { waitUntil: 'domcontentloaded' }); | |
| 182 | + await page.waitForSelector('.ms-count b', { timeout: 30000 }); | |
| 183 | + await waitSettled(page, 2500); | |
| 184 | + const total = await counterTotal(page); | |
| 185 | + const nPts = await engineCount(page); | |
| 186 | + check('C1 filtre loyer ≤ 900 $ : compteur = points', total === nPts, `${total} = ${nPts}`); | |
| 187 | + | |
| 188 | + // tri partagé : passer en « Meilleures affaires » réordonne liste + points | |
| 189 | + await page.locator('.ms-sort select').selectOption('deal'); | |
| 190 | + await waitSettled(page, 1800); | |
| 191 | + const firstUid = await page.locator('.map-card').first().getAttribute('data-uid'); | |
| 192 | + const firstPointUid = await page.evaluate(() => [...globalThis.__kaMap['byId'].keys()][0]); | |
| 193 | + check('C1 tri partagé : 1er de la liste = 1er point du tri', firstUid === firstPointUid, | |
| 194 | + `${firstUid}`); | |
| 195 | + await page.screenshot({ path: `${OUT}03-filtre-tri.png` }); | |
| 196 | + await ctx.close(); | |
| 197 | +} | |
| 198 | + | |
| 199 | +console.log('— Zone dessinée (polygone) —'); | |
| 200 | +{ | |
| 201 | + const ctx = await newDesktop(); | |
| 202 | + const page = await ctx.newPage(); | |
| 203 | + await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, | |
| 204 | + { waitUntil: 'domcontentloaded' }); | |
| 205 | + await page.waitForSelector('.ka-draw-btn', { timeout: 30000 }); | |
| 206 | + await waitSettled(page, 2500); | |
| 207 | + const box = await canvasBox(page); | |
| 208 | + await page.locator('.ka-draw-btn').click(); | |
| 209 | + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; | |
| 210 | + await page.mouse.click(cx - 160, cy - 80); | |
| 211 | + await page.mouse.click(cx + 160, cy - 60); | |
| 212 | + await page.mouse.click(cx + 40, cy + 140); | |
| 213 | + await page.mouse.click(cx - 160, cy - 80); // fermeture sur le 1er sommet | |
| 214 | + await waitSettled(page, 2000); | |
| 215 | + const chip = await page.locator('.ms-pills .pill').count(); | |
| 216 | + check('Polygone → puce « Zone dessinée » visible', chip >= 1); | |
| 217 | + const total = await counterTotal(page); | |
| 218 | + const nPts = await engineCount(page); | |
| 219 | + check('C1 polygone : compteur = points', total === nPts && total > 0, `${total} = ${nPts}`); | |
| 220 | + check('C7 zone dans l’URL', /zone=/.test(page.url())); | |
| 221 | + await page.screenshot({ path: `${OUT}04-polygone.png` }); | |
| 222 | + // retrait de la puce → retour à la zone visible | |
| 223 | + await page.locator('.ms-pills .pill').first().click(); | |
| 224 | + await waitSettled(page, 1500); | |
| 225 | + check('Puce retirée → polygone effacé', !/zone=/.test(page.url())); | |
| 226 | + await ctx.close(); | |
| 227 | +} | |
| 228 | + | |
| 229 | +console.log('— Mode manuel & zéro résultat —'); | |
| 230 | +{ | |
| 231 | + const ctx = await newDesktop(); | |
| 232 | + const page = await ctx.newPage(); | |
| 233 | + await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=12`, | |
| 234 | + { waitUntil: 'domcontentloaded' }); | |
| 235 | + await page.waitForSelector('.ka-search-area-auto input', { timeout: 30000 }); | |
| 236 | + await waitSettled(page, 2500); | |
| 237 | + await page.locator('.ka-search-area-auto input').setChecked(false); | |
| 238 | + const mb = await canvasBox(page); | |
| 239 | + const mmx = mb.x + mb.width / 2, mmy = mb.y + mb.height / 2; | |
| 240 | + await page.mouse.move(mmx, mmy); | |
| 241 | + await page.mouse.down(); | |
| 242 | + await page.mouse.move(mmx - 320, mmy - 30, { steps: 10 }); | |
| 243 | + await page.mouse.up(); | |
| 244 | + await page.waitForTimeout(900); | |
| 245 | + const btn = await page.locator('.ka-search-area-btn').count(); | |
| 246 | + check('Case décochée + déplacement → « Rechercher dans cette zone »', btn === 1); | |
| 247 | + check('C7 move=0 dans l’URL', /move=0/.test(page.url())); | |
| 248 | + | |
| 249 | + // zéro résultat : plein nord | |
| 250 | + await page.goto(`${BASE}/?view=carte&lat=55.5&lng=-70.0&zoom=9`, | |
| 251 | + { waitUntil: 'domcontentloaded' }); | |
| 252 | + await page.waitForSelector('.ms-empty, .ka-empty', { timeout: 30000 }); | |
| 253 | + await waitSettled(page, 2000); | |
| 254 | + const emptyTxt = await page.locator('.ms-empty h3').first().textContent().catch(() => ''); | |
| 255 | + check('C9 zone vide → état vide propre + « Élargir la zone »', | |
| 256 | + (emptyTxt || '').includes('Aucun logement'), | |
| 257 | + emptyTxt?.trim()); | |
| 258 | + const widen = await page.locator('.ms-empty .btn-primary').count(); | |
| 259 | + check('C9 bouton « Élargir la zone » présent', widen === 1); | |
| 260 | + await page.screenshot({ path: `${OUT}05-zero-resultat.png` }); | |
| 261 | + await ctx.close(); | |
| 262 | +} | |
| 263 | + | |
| 264 | +console.log('— URL partagée = recherche reproduite —'); | |
| 265 | +{ | |
| 266 | + const ctx = await newDesktop(); | |
| 267 | + const page = await ctx.newPage(); | |
| 268 | + const shared = `${BASE}/?view=carte&price_max=1200&unit_type=3%C2%BD&lat=46.80&lng=-71.24&zoom=13&tri=recent`; | |
| 269 | + await page.goto(shared, { waitUntil: 'domcontentloaded' }); | |
| 270 | + await page.waitForSelector('.ms-count b', { timeout: 30000 }); | |
| 271 | + await waitSettled(page, 2500); | |
| 272 | + const total = await counterTotal(page); | |
| 273 | + const nPts = await engineCount(page); | |
| 274 | + const sortVal = await page.locator('.ms-sort select').inputValue(); | |
| 275 | + const zoom = await page.evaluate(() => Math.round(globalThis.__kaMap.getState().zoom * 10) / 10); | |
| 276 | + check('C7 URL partagée : filtres + zone + tri reproduits', | |
| 277 | + total === nPts && sortVal === 'recent' && Math.abs(zoom - 13) < 0.6, | |
| 278 | + `${total} résultats, tri=${sortVal}, zoom=${zoom}`); | |
| 279 | + await page.screenshot({ path: `${OUT}06-url-partagee.png` }); | |
| 280 | + await ctx.close(); | |
| 281 | +} | |
| 282 | + | |
| 283 | +console.log('— Mobile : bascule + carrousel synchronisé —'); | |
| 284 | +{ | |
| 285 | + const ctx = await browser.newContext({ ...devices['iPhone 14'], locale: 'fr-CA' }); | |
| 286 | + await ctx.addInitScript(initScript); | |
| 287 | + const page = await ctx.newPage(); | |
| 288 | + await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, | |
| 289 | + { waitUntil: 'domcontentloaded' }); | |
| 290 | + await page.waitForSelector('.ms-carousel', { timeout: 30000 }); | |
| 291 | + await waitSettled(page, 3000); | |
| 292 | + const items = await page.locator('.ms-car-item').count(); | |
| 293 | + check('C8 carrousel mobile présent', items > 0, `${items} cartes`); | |
| 294 | + | |
| 295 | + // marqueur → carrousel : sélection du 5e point → carte correspondante mise en avant | |
| 296 | + const uid5 = await page.evaluate(() => [...globalThis.__kaMap['byId'].keys()][4]); | |
| 297 | + await page.evaluate((u) => globalThis.__kaMap.select(u, 'map'), uid5); | |
| 298 | + await page.waitForTimeout(1200); | |
| 299 | + const selCar = await page.locator('.ms-car-item.sel').getAttribute('data-uid').catch(() => null); | |
| 300 | + check('C8 taper un marqueur → carrousel défile sur l’annonce', selCar === uid5, `${selCar}`); | |
| 301 | + | |
| 302 | + // carrousel → marqueur : balayage → sélection carte | |
| 303 | + await page.locator('.ms-carousel').evaluate((el) => { el.scrollLeft += el.clientWidth * 2; }); | |
| 304 | + await page.waitForTimeout(700); | |
| 305 | + const selId = await page.evaluate(() => globalThis.__kaMap.getSelectedId()); | |
| 306 | + check('C8 balayer le carrousel → sélection déplacée sur la carte', | |
| 307 | + selId !== null && selId !== uid5, `${selId}`); | |
| 308 | + await page.screenshot({ path: `${OUT}07-mobile-carrousel.png` }); | |
| 309 | + | |
| 310 | + // bascule Liste ↔ Carte sans perte d'état | |
| 311 | + const urlCarte = page.url(); | |
| 312 | + await page.locator('.view-toggle button').first().click(); // Liste | |
| 313 | + await page.waitForTimeout(800); | |
| 314 | + await page.locator('.view-toggle button').nth(1).click(); // Carte | |
| 315 | + await page.waitForSelector('.ms-carousel', { timeout: 20000 }); | |
| 316 | + await waitSettled(page, 2500); | |
| 317 | + check('C8 bascule Liste ↔ Carte : état conservé (URL stable)', | |
| 318 | + /view=carte/.test(page.url()) && /lat=/.test(page.url()), | |
| 319 | + page.url().replace(BASE, '')); | |
| 320 | + await page.screenshot({ path: `${OUT}08-mobile-carte.png` }); | |
| 321 | + await ctx.close(); | |
| 322 | +} | |
| 323 | + | |
| 324 | +console.log('— Géolocalisation d’accueil —'); | |
| 325 | +{ | |
| 326 | + const ctx = await browser.newContext({ | |
| 327 | + viewport: { width: 1440, height: 900 }, locale: 'fr-CA', | |
| 328 | + geolocation: { latitude: 46.8139, longitude: -71.2080 }, | |
| 329 | + permissions: ['geolocation'], | |
| 330 | + }); | |
| 331 | + await ctx.addInitScript(() => { | |
| 332 | + localStorage.setItem('louka-consent-v1', | |
| 333 | + JSON.stringify({ essential: true, preferences: true, statistics: false, ts: 1 })); | |
| 334 | + }); | |
| 335 | + const page = await ctx.newPage(); | |
| 336 | + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); | |
| 337 | + await page.waitForFunction(() => /view=carte/.test(location.search), null, { timeout: 20000 }) | |
| 338 | + .catch(() => {}); | |
| 339 | + const ok = /view=carte/.test(page.url()) && /lat=46\.81/.test(page.url()); | |
| 340 | + check('Arrivée + géoloc accordée → vue carte centrée sur l’utilisateur', ok, | |
| 341 | + page.url().replace(BASE, '')); | |
| 342 | + await page.waitForSelector('.ms-count b', { timeout: 30000 }); | |
| 343 | + await waitSettled(page, 2500); | |
| 344 | + await page.screenshot({ path: `${OUT}09-geolocalisation.png` }); | |
| 345 | + await ctx.close(); | |
| 346 | +} | |
| 347 | + | |
| 348 | +await browser.close(); | |
| 349 | +console.log(`\n${passed} réussis, ${failed} échoués`); | |
| 350 | +process.exit(failed > 0 ? 1 : 0); | |
added
tests/test_search.py
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# test_search.py : /api/search — la recherche unifiée liste + carte. | |
| 5 | +# Règle d'or : total == len(points), quelles que soient les contraintes. | |
| 6 | +# ----------------------------------------------------------------------------- | |
| 7 | +import pytest | |
| 8 | +from fastapi.testclient import TestClient | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.fixture(scope="module") | |
| 12 | +def client(): | |
| 13 | + from louka import web | |
| 14 | + return TestClient(web.app) | |
| 15 | + | |
| 16 | + | |
| 17 | +def _get(client, **params): | |
| 18 | + r = client.get("/api/search", params=params) | |
| 19 | + assert r.status_code == 200, r.text | |
| 20 | + return r.json() | |
| 21 | + | |
| 22 | + | |
| 23 | +def test_regle_dor_total_egale_points(client): | |
| 24 | + d = _get(client, page_size=5) | |
| 25 | + assert d["total"] == len(d["points"]) | |
| 26 | + assert len(d["listings"]) <= 5 | |
| 27 | + # points triés comme la liste : le 1er point est la 1re annonce | |
| 28 | + if d["listings"]: | |
| 29 | + assert d["points"][0][0] == d["listings"][0]["uid"] | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_bbox_restreint_et_conserve_egalite(client): | |
| 33 | + tout = _get(client, page_size=1) | |
| 34 | + zone = _get(client, bbox="-71.45,46.70,-71.10,46.92", page_size=1) | |
| 35 | + assert zone["total"] <= tout["total"] | |
| 36 | + assert zone["total"] == len(zone["points"]) | |
| 37 | + for _uid, lng, lat, _p, _v in zone["points"]: | |
| 38 | + assert -71.45 <= lng <= -71.10 and 46.70 <= lat <= 46.92 | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_polygone_sous_ensemble_de_son_emprise(client): | |
| 42 | + poly = "-71.24,46.80;-71.20,46.82;-71.24,46.83" | |
| 43 | + dedans = _get(client, poly=poly, page_size=1) | |
| 44 | + emprise = _get(client, bbox="-71.24,46.80,-71.20,46.83", page_size=1) | |
| 45 | + assert dedans["total"] == len(dedans["points"]) | |
| 46 | + assert dedans["total"] <= emprise["total"] | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_polygone_invalide(client): | |
| 50 | + r = client.get("/api/search", params={"poly": "abc"}) | |
| 51 | + assert r.status_code == 400 | |
| 52 | + r = client.get("/api/search", params={"poly": "-71,46;-71.2,46.2"}) | |
| 53 | + assert r.status_code == 400 # 2 sommets | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_tris(client): | |
| 57 | + asc = _get(client, sort="prix", page_size=10, | |
| 58 | + bbox="-71.45,46.70,-71.10,46.92") | |
| 59 | + prix = [p[3] for p in asc["points"] if p[3] is not None] | |
| 60 | + assert prix == sorted(prix) | |
| 61 | + desc = _get(client, sort="prix_desc", page_size=10, | |
| 62 | + bbox="-71.45,46.70,-71.10,46.92") | |
| 63 | + prix_d = [p[3] for p in desc["points"] if p[3] is not None] | |
| 64 | + assert prix_d == sorted(prix_d, reverse=True) | |
| 65 | + | |
| 66 | + | |
| 67 | +def test_tri_inconnu(client): | |
| 68 | + r = client.get("/api/search", params={"sort": "alea"}) | |
| 69 | + assert r.status_code == 400 | |
| 70 | + | |
| 71 | + | |
| 72 | +def test_page_ramenee_dans_les_bornes(client): | |
| 73 | + d = _get(client, page=99999, page_size=50, | |
| 74 | + bbox="-71.45,46.70,-71.10,46.92") | |
| 75 | + assert d["page"] == max(1, -(-d["total"] // 50)) | |
| 76 | + | |
| 77 | + | |
| 78 | +def test_include_liste_sans_points(client): | |
| 79 | + d = _get(client, include="liste", page_size=5) | |
| 80 | + assert d["points"] is None | |
| 81 | + assert d["total"] > 0 or d["listings"] == [] | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_filtre_deal_et_verdicts(client): | |
| 85 | + d = _get(client, deal="sous", page_size=5) | |
| 86 | + assert d["total"] == len(d["points"]) | |
| 87 | + assert all(p[4] == "s" for p in d["points"]) | |
| 88 | ||