SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 3e74b24

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