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%

Mode carte Ka Map System v2 — expérience immersive plein viewport

- MapSearch réécrit autour de KaMapShell : mobile edge-to-edge + bottom sheet
  de résultats à 3 crans, desktop split liste|carte redimensionnable (ratio
  persisté) avec carte quasi plein écran d un clic
- Toolbar unifiée (zoom, 3D, dessiner, me localiser) au lieu des pilules
  flottantes ; « Rechercher dans cette zone » contextuel
- Mode dessin temporaire avec CTA « N logements dans cette zone »
- Fiche contextuelle v2 : photo, prix, favori, swipe/chevrons entre voisines
  (remplace le carrousel mobile)
- Thème markers v2 : pilules off-white/encre premium, orange réservé au
  « sous le marché » et à la sélection ; clusters légers à anneau orange
  avec loyer moyen sous la bulle
- Home : vue carte mémorisée dans l URL, feuille de filtres en modale
  au-dessus du mode carte (desktop), widget Ka Agent masqué en mode carte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 29 days ago (Aug 26, 2026) parent e911a65

4 changed files +441 −361

modified frontend/src/kamaps/theme.ts +24 −20
@@ -2,52 +2,56 @@
2 2 // Author: Simon-Pierre Boucher
3 3 // Contact: contact@spboucher.ai
4 4 // Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5 −// kamaps/theme.ts : thème Lou-Ka Maps — identité officielle Lou-Ka
6 −// (marine #0B1330 / bleu vif #2258FF / orange #FF6A00) appliquée à Ka Maps.
5 +// kamaps/theme.ts : thème Lou-Ka Maps v2 — identité orange / off-white / noir.
6 +// Pastilles premium : off-white texte encre (la carte respire), l'ORANGE est
7 +// réservé aux moments de marque (sous le marché, sélection, contrôles actifs).
7 8 // -----------------------------------------------------------------------------
8 9 import type { KaMapTheme } from "@groupe-ka/ka-maps";
9 10
10 −const NAVY = "#0b1330";
11 −const BLUE = "#2258ff";
11 +const INK = "#141814";
12 12 const ORANGE = "#ff6a00";
13 +const OFFWHITE = "#fffdf8";
13 14 const WHITE = "#ffffff";
14 15
15 16 export const louKaMapTheme: KaMapTheme = {
16 17 id: "lou-ka",
17 18 productName: "Lou-Ka Maps",
18 − accent: BLUE,
19 + accent: ORANGE,
19 20 onAccent: WHITE,
20 21 fontFamily:
21 22 '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
22 23 markers: {
23 − // Prix : pastille bleu marine, texte BLANC — contraste maximal sur fond clair.
24 − // L'orange n'apparaît qu'à la sélection (le moment de marque).
24 + // Loyer : pastille off-white, texte encre — lisible sans écraser la carte.
25 + // Sélection/survol : encre pleine (texte blanc), le repère absolu.
25 26 sale: {
26 − background: NAVY,
27 − text: WHITE,
28 − halo: "rgba(255,255,255,0.45)",
29 − selectedBackground: ORANGE,
27 + background: OFFWHITE,
28 + text: INK,
29 + halo: "rgba(20, 24, 20, 0.30)",
30 + selectedBackground: INK,
30 31 selectedText: WHITE,
31 32 },
32 33 rent: {
33 − background: NAVY,
34 − text: WHITE,
35 − halo: "rgba(255,255,255,0.45)",
36 − selectedBackground: ORANGE,
34 + background: OFFWHITE,
35 + text: INK,
36 + halo: "rgba(20, 24, 20, 0.30)",
37 + selectedBackground: INK,
37 38 selectedText: WHITE,
38 39 },
40 + // « Sous le marché » : orange Lou-Ka d'office — LE signal d'affaire.
39 41 highlight: {
40 − background: BLUE,
42 + background: ORANGE,
41 43 text: WHITE,
42 − halo: "rgba(255,255,255,0.45)",
43 − selectedBackground: ORANGE,
44 + halo: "rgba(255, 255, 255, 0.55)",
45 + selectedBackground: INK,
44 46 selectedText: WHITE,
45 47 },
46 48 },
47 49 cluster: {
48 − background: NAVY,
49 − text: WHITE,
50 + background: OFFWHITE,
51 + text: INK,
50 52 border: ORANGE,
53 + valueText: "#9c4200",
54 + valueHalo: "rgba(255, 253, 248, 0.92)",
51 55 },
52 56 // Cartographie Lou-Ka : fond neutre et raffiné — gris très pâle,
53 57 // eau bleu désaturé, parcs verts subtils, bâtiments bien définis.
modified frontend/src/pages/Home.tsx +19 −4
@@ -98,6 +98,17 @@ export default function Home() {
98 98 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte)
99 99 setView(params.get("view") === "carte" ? "carte" : "liste");
100 100 }, [params]);
101 + // mode carte (Ka Map System v2) : classe body — la feuille de filtres passe
102 + // en modale AU-DESSUS du shell — et vue mémorisée dans l'URL (rechargement,
103 + // retour depuis une fiche : on retombe sur la carte, caméra comprise).
104 + useEffect(() => {
105 + document.body.classList.toggle("ka-map-mode", view === "carte");
106 + const url = new URL(window.location.href);
107 + if (view === "carte") url.searchParams.set("view", "carte");
108 + else url.searchParams.delete("view");
109 + window.history.replaceState(null, "", url);
110 + return () => document.body.classList.remove("ka-map-mode");
111 + }, [view]);
101 112
102 113 const qDebounced = useDebounced(q);
103 114 const filters: ListingFilters = useMemo(() => {
@@ -587,12 +598,16 @@ export default function Home() {
587 598 <div className="view-pane" key="carte">
588 599 <Suspense
589 600 fallback={
590 − <div className="map-split">
591 − <div className="mapview map-loading">Chargement de la carte…</div>
592 − </div>
601 + <div className="ka-shell-fallback">Chargement de la carte…</div>
593 602 }
594 603 >
595 − <MapSearch filters={filters} onTotal={setMapTotal} />
604 + <MapSearch
605 + filters={filters}
606 + onTotal={setMapTotal}
607 + onExit={() => setView("liste")}
608 + onOpenFilters={() => setSheetOpen(true)}
609 + filtersCount={activeFilters}
610 + />
596 611 </Suspense>
597 612 </div>
598 613 )}
modified frontend/src/search/MapSearch.tsx +314 −337
@@ -1,7 +1,8 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// search/MapSearch.tsx : recherche synchronisée liste ↔ carte (Ka Maps).
4 +// search/MapSearch.tsx : le MODE CARTE (Ka Map System v2) — recherche
5 +// synchronisée liste ↔ carte, plein viewport.
5 6 //
6 7 // UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent
7 8 // dans un état unique ; chaque changement déclenche UNE requête /api/search
@@ -9,25 +10,24 @@
9 10 // pareil : le compteur de la liste est par construction égal au nombre de
10 11 // marqueurs, et l'index d'un point donne directement sa page de liste.
11 12 //
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.
13 +// v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est
14 +// une prise de contrôle du viewport.
15 +// · mobile : carte edge-to-edge, barre supérieure compacte, résultats en
16 +// bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ;
17 +// · desktop : split résultats|carte redimensionnable (ratio persisté),
18 +// carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ;
19 +// · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation),
20 +// « Rechercher dans cette zone » contextuel, mode dessin temporaire avec
21 +// CTA « N logements dans cette zone ».
22 22 //
23 −// Ce composant est le modèle réutilisable Groupe-KA (immo-ka, resto-ka,
23 +// Ce composant reste le modèle réutilisable Groupe-KA (immo-ka, resto-ka,
24 24 // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md.
25 25 // -----------------------------------------------------------------------------
26 26 import MetroLignes from "../components/MetroLignes";
27 27 import {
28 28 useCallback, useEffect, useMemo, useRef, useState,
29 29 } from "react";
30 −import { Link, useNavigate } from "react-router-dom";
30 +import { useNavigate } from "react-router-dom";
31 31 import "mapbox-gl/dist/mapbox-gl.css";
32 32 import "@groupe-ka/ka-maps/styles.css";
33 33 import type { MapProperty } from "@groupe-ka/ka-maps";
@@ -36,18 +36,19 @@ import {
36 36 } from "@groupe-ka/ka-maps";
37 37 import type { KaMap } from "@groupe-ka/ka-maps";
38 38 import {
39 − DrawControl, KaBrandBadge, KaMapView, LocateControl, PropertyPreview,
40 − Tilt3DControl, useKaMap,
39 + KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView,
40 + KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup,
41 + KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell,
41 42 } from "@groupe-ka/ka-maps/react";
42 43 import {
43 44 Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,
44 45 fetchListing, fetchSearch, fmtPrice, sourceName, thumb,
45 46 } from "../api";
47 +import { useAccount } from "../account";
46 48 import ListingCard from "../components/ListingCard";
47 49 import Pager from "../components/Pager";
48 −import SmartImg from "../components/SmartImg";
49 50 import FairValueBadge from "../components/FairValueBadge";
50 −import { IcoSearch } from "../components/Icons";
51 +import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons";
51 52 import { louKaMapTheme } from "../kamaps/theme";
52 53 import { MAPBOX_TOKEN } from "../kamaps/config";
53 54 import { seenUids } from "./seen";
@@ -120,13 +121,34 @@ function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
120 121 return null;
121 122 }
122 123
123 −/** Mini-fiche de marqueur : les points carte sont volontairement compacts,
124 − * le détail (photo, titre, dispo) est chargé à la sélection (rapide, mis
125 − * en cache) — même langage visuel que les cartes de la liste. */
124 +/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */
125 +function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {
126 + const map = useKaMap();
127 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
128 + return null;
129 +}
130 +
131 +/** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser
132 + * toute la place à la carte + fiche contextuelle. */
133 +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {
134 + const shell = useKaShell();
135 + const shellRef = useRef(shell);
136 + shellRef.current = shell;
137 + useEffect(() => {
138 + const s = shellRef.current;
139 + if (selectedUid && s?.isMobile) s.setSheet("mini");
140 + }, [selectedUid]);
141 + return null;
142 +}
143 +
144 +/** Fiche contextuelle : les points carte sont volontairement compacts, le
145 + * détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en
146 + * cache) — structure ka-prevcard du Ka Map System, contenu Lou-Ka. */
126 147 const previewCache = new Map<string, Listing>();
127 148
128 149 function LazyPreview({ p }: { p: MapProperty }) {
129 150 const navigate = useNavigate();
151 + const { favs, toggleFav } = useAccount();
130 152 const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);
131 153 useEffect(() => {
132 154 if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }
@@ -146,34 +168,52 @@ function LazyPreview({ p }: { p: MapProperty }) {
146 168 }, [p.id]);
147 169
148 170 const img = l?.images?.[0] ?? null;
171 + const fav = favs.has(p.id);
172 + const fiche = `/logement/${encodeURIComponent(p.id)}`;
149 173 return (
150 − <div className="mv-pop">
151 − {img ? (
152 − <img src={thumb(img, 480)} alt="" loading="lazy" />
153 − ) : (
154 − <div className="mv-noimg" aria-hidden="true">⌂</div>
155 − )}
156 − <div className="mv-pop-body">
157 − <div className="mv-pop-price">
174 + <>
175 + <div className="ka-prevcard-media">
176 + {img ? (
177 + <img src={thumb(img, 480)} alt="" loading="lazy" />
178 + ) : (
179 + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
180 + )}
181 + </div>
182 + <div className="ka-prevcard-body">
183 + <div className="ka-prevcard-price">
158 184 {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"}
159 − {(l?.price ?? p.price) != null && <small> /mois</small>}
185 + {(l?.price ?? p.price) != null && <small>/mois</small>}
160 186 {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}
161 187 </div>
162 − <div className="mv-pop-title">{l ? (l.title || l.address) : "Chargement…"}</div>
163 − <div className="mv-pop-meta">
164 − {[l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", l?.availability]
165 − .filter(Boolean).join(" · ")}
188 + <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Chargement…"}</div>
189 + <div className="ka-prevcard-meta">
190 + {[
191 + l?.unit_type,
192 + l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "",
193 + l?.availability,
194 + l ? sourceName(l.source) : "",
195 + ].filter(Boolean).join(" · ")}
196 + </div>
197 + <div className="ka-prevcard-actions">
198 + <a
199 + className="ka-prevcard-cta"
200 + href={fiche}
201 + onClick={(e) => { e.preventDefault(); navigate(fiche); }}
202 + >
203 + Voir la fiche →
204 + </a>
205 + <button
206 + type="button"
207 + className={`ka-prevcard-fav${fav ? " on" : ""}`}
208 + onClick={() => toggleFav(p.id)}
209 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
210 + aria-pressed={fav}
211 + >
212 + <IcoHeart size={16} filled={fav} />
213 + </button>
166 214 </div>
167 − <div className="mv-pop-src">{l ? sourceName(l.source) : ""}</div>
168 − <a
169 − className="mv-pop-cta"
170 − href={`/logement/${encodeURIComponent(p.id)}`}
171 − onClick={(e) => { e.preventDefault(); navigate(`/logement/${encodeURIComponent(p.id)}`); }}
172 − >
173 − Voir la fiche →
174 − </a>
175 215 </div>
176 − </div>
216 + </>
177 217 );
178 218 }
179 219
@@ -181,6 +221,12 @@ export interface MapSearchProps {
181 221 filters: ListingFilters;
182 222 /** Compteur partagé remonté à l'entête de résultats de la page. */
183 223 onTotal?: (total: number | null) => void;
224 + /** Quitter le mode carte (retour à la vue liste). */
225 + onExit?: () => void;
226 + /** Ouvrir la feuille de filtres de la page (le shell n'a qu'un bouton). */
227 + onOpenFilters?: () => void;
228 + /** Nombre de filtres actifs (badge du bouton Filtres). */
229 + filtersCount?: number;
184 230 }
185 231
186 232 interface Query {
@@ -190,7 +236,9 @@ interface Query {
190 236 page: number;
191 237 }
192 238
193 −export default function MapSearch({ filters, onTotal }: MapSearchProps) {
239 +export default function MapSearch({
240 + filters, onTotal, onExit, onOpenFilters, filtersCount = 0,
241 +}: MapSearchProps) {
194 242 const initialParams = useMemo(
195 243 () => new URLSearchParams(window.location.search), []);
196 244 const initialCamera = useMemo(
@@ -244,9 +292,7 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
244 292 const pointsKeyRef = useRef<string>("");
245 293 const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat
246 294 const pendingScrollUidRef = useRef<string | null>(null);
247 − const carouselDrivenRef = useRef(false);
248 295 const listRef = useRef<HTMLDivElement | null>(null);
249 − const carouselRef = useRef<HTMLDivElement | null>(null);
250 296
251 297 const filtersClean = useMemo(() => {
252 298 const f: Record<string, string> = {};
@@ -391,6 +437,16 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
391 437
392 438 // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la
393 439 // position et synchroniser la liste sur cette zone ---
440 + const syncListToViewport = useCallback((delayMs: number) => {
441 + window.setTimeout(() => {
442 + const b = engineRef.current?.currentBBox();
443 + if (b) {
444 + setDirty(false);
445 + setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) }));
446 + }
447 + }, delayMs);
448 + }, []);
449 +
394 450 useEffect(() => {
395 451 const handler = (e: Event) => {
396 452 const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;
@@ -399,17 +455,11 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
399 455 setUserLocked(true);
400 456 userLockedRef.current = true;
401 457 m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });
402 − window.setTimeout(() => {
403 − const b = engineRef.current?.currentBBox();
404 − if (b) {
405 − setDirty(false);
406 − setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) }));
407 − }
408 − }, 950);
458 + syncListToViewport(950);
409 459 };
410 460 window.addEventListener("louka:geolocate", handler);
411 461 return () => window.removeEventListener("louka:geolocate", handler);
412 − }, []);
462 + }, [syncListToViewport]);
413 463
414 464 // --- geste sur la carte ---
415 465 const onMoveEnd = useCallback(
@@ -457,22 +507,12 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
457 507 const widenArea = useCallback(() => {
458 508 const m = engineRef.current;
459 509 if (!m) return;
460 − // « Élargir la zone » : dézoome — le moveend (byUser côté moteur ? non,
461 − // easeTo interne) ; on déclenche la recherche élargie explicitement.
462 − const b = m.currentBBox();
510 + // « Élargir la zone » : dézoome, puis recherche élargie explicite.
463 511 m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {
464 512 zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,
465 513 });
466 − if (b) {
467 − window.setTimeout(() => {
468 − const nb = engineRef.current?.currentBBox();
469 − if (nb) {
470 − setDirty(false);
471 − setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(nb) }));
472 − }
473 − }, 700);
474 − }
475 − }, []);
514 + syncListToViewport(700);
515 + }, [syncListToViewport]);
476 516
477 517 // --- polygone dessiné ---
478 518 const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {
@@ -509,22 +549,12 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
509 549 card.classList.add("map-card-pulse");
510 550 }
511 551 }
512 − const carItem = carouselRef.current?.querySelector<HTMLElement>(sel);
513 − if (carItem && carouselRef.current) {
514 − carouselRef.current.scrollTo({
515 − left: carItem.offsetLeft - 12, behavior: "smooth",
516 − });
517 − }
518 552 }, []);
519 553
520 554 const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {
521 555 const uid = p?.id ?? null;
522 556 setSelectedUid(uid);
523 557 if (!uid) return;
524 − if (carouselDrivenRef.current) { // déjà au bon endroit
525 − carouselDrivenRef.current = false;
526 − return;
527 − }
528 558 const idx = uidIndex.get(uid);
529 559 if (idx === undefined) return;
530 560 const targetPage = Math.floor(idx / PAGE_SIZE) + 1;
@@ -562,30 +592,7 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
562 592 else setHoverOffscreen(null);
563 593 }, [mapHoverUid]);
564 594
565 − // --- carrousel mobile : balayage → sélection sur la carte ---
566 − const carouselScrollTimer = useRef<number | null>(null);
567 − const onCarouselScroll = useCallback(() => {
568 − if (carouselScrollTimer.current) window.clearTimeout(carouselScrollTimer.current);
569 − carouselScrollTimer.current = window.setTimeout(() => {
570 − const el = carouselRef.current;
571 − if (!el || !res) return;
572 − const children = Array.from(el.children) as HTMLElement[];
573 − const center = el.scrollLeft + el.clientWidth / 2;
574 − let best = 0, bestD = Infinity;
575 − children.forEach((c, i) => {
576 − const d = Math.abs(c.offsetLeft + c.offsetWidth / 2 - center);
577 − if (d < bestD) { bestD = d; best = i; }
578 − });
579 − const uid = res.listings[best]?.uid;
580 − if (uid && uid !== selectedUid) {
581 − carouselDrivenRef.current = true;
582 − engineRef.current?.select(uid, "app");
583 − setSelectedUid(uid);
584 − }
585 − }, 140);
586 − }, [res, selectedUid]);
587 −
588 − // --- clic sur une carte d'annonce : recentre + mini-fiche (1er clic),
595 + // --- clic sur une carte d'annonce : recentre + fiche (1er clic),
589 596 // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---
590 597 const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {
591 598 if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet
@@ -594,272 +601,242 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
594 601 engineRef.current?.select(uid, "app");
595 602 }, [selectedUid]);
596 603
597 − // Amener l'écran sur la recherche à l'ouverture de la vue carte (arrivée
598 − // directe /?view=carte, géolocalisation, ou clic sur l'onglet Carte) —
599 − // sinon le héro cache la carte et la synchro se joue sous le pli.
600 − const rootRef = useRef<HTMLDivElement | null>(null);
601 − useEffect(() => {
602 − const t = window.setTimeout(() => {
603 − rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
604 − }, 120);
605 − return () => window.clearTimeout(t);
606 − }, []);
607 −
608 604 const listings = res?.listings ?? [];
609 605 const total = res?.total ?? null;
610 606 const unpositioned = res?.unpositioned ?? 0;
611 607 const hasZone = qy.poly !== null;
612 608
613 − return (
614 − <div className="map-split ms" ref={rootRef}>
615 − {/* ---------------- volet liste (desktop / tablette) ---------------- */}
616 − <div className="map-list ms-list" aria-label="Résultats en liste" ref={listRef}>
617 − <div className="ms-list-head">
618 − <div className="ms-count" role="status" aria-live="polite">
619 − {total === null ? "…" : (
620 − <>
621 − <b>{total.toLocaleString("fr-CA")}</b>
622 − {" "}logement{total > 1 ? "s" : ""}
623 − {hasZone ? " dans la zone dessinée" : ""}
624 − </>
625 − )}
626 − {unpositioned > 0 && (
627 − <span
628 − className="ms-nopos"
629 − title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement"
630 − >
631 − +{unpositioned} sans position
632 − </span>
633 − )}
634 − </div>
635 − <label className="ms-sort">
636 − <span>Tri</span>
637 − <select
638 − value={qy.sort}
639 − onChange={(e) => setQy((q) => ({
640 − ...q, page: 1, sort: e.target.value as SearchSort,
641 − }))}
642 − >
643 − {SORT_CHOICES.map((s) => (
644 − <option key={s.key} value={s.key}>{s.label}</option>
645 − ))}
646 − </select>
647 − </label>
648 − </div>
649 −
650 − {hasZone && (
651 − <div className="pills ms-pills">
652 − <button className="pill" onClick={clearZone} aria-label="Retirer la zone dessinée">
653 − Zone dessinée <span className="pill-x">✕</span>
654 − </button>
655 − </div>
609 + /* ------------------------- volets du shell ------------------------- */
610 +
611 + const listHeader = (
612 + <div className="ms2-head">
613 + <div className="ms2-count" role="status" aria-live="polite">
614 + {total === null ? "…" : (
615 + <>
616 + <b>{total.toLocaleString("fr-CA")}</b>
617 + {" "}logement{total !== null && total > 1 ? "s" : ""}
618 + {hasZone ? " · zone dessinée" : ""}
619 + </>
656 620 )}
657 − {userLocked && total !== null && total > 0 && !hasZone && (
658 − <button className="ms-refit" onClick={refitToResults}>
659 − ⌖ Recadrer sur les résultats
660 − </button>
621 + {unpositioned > 0 && (
622 + <span
623 + className="ms-nopos"
624 + title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement"
625 + >
626 + +{unpositioned} sans position
627 + </span>
661 628 )}
629 + </div>
630 + <label className="ms2-sort">
631 + <select
632 + value={qy.sort}
633 + onChange={(e) => setQy((q) => ({
634 + ...q, page: 1, sort: e.target.value as SearchSort,
635 + }))}
636 + aria-label="Trier les résultats"
637 + >
638 + {SORT_CHOICES.map((s) => (
639 + <option key={s.key} value={s.key}>{s.label}</option>
640 + ))}
641 + </select>
642 + </label>
643 + </div>
644 + );
662 645
663 − {loading && <div className="ms-progress" aria-hidden="true" />}
646 + const listPane = (
647 + <div className="ms2-list" aria-label="Résultats en liste" ref={listRef}>
648 + {userLocked && total !== null && total > 0 && !hasZone && (
649 + <button className="ms-refit" onClick={refitToResults}>
650 + ⌖ Recadrer sur les résultats
651 + </button>
652 + )}
664 653
665 − {error && (
666 − <div className="ms-empty">
667 − <h3>Impossible de charger les résultats</h3>
668 − <p>{error}</p>
669 − </div>
670 − )}
654 + {loading && <div className="ms-progress" aria-hidden="true" />}
655 +
656 + {error && (
657 + <div className="ms-empty">
658 + <h3>Impossible de charger les résultats</h3>
659 + <p>{error}</p>
660 + </div>
661 + )}
671 662
672 − {!error && total === 0 && (
673 − <div className="ms-empty" role="status">
674 − <div className="big"><IcoSearch size={34} /></div>
675 − <h3>Aucun logement dans cette zone</h3>
676 − <p>Élargissez la carte ou modifiez vos filtres.</p>
677 − <div className="ms-empty-actions">
678 − <button className="btn btn-primary" onClick={widenArea}>
679 − Élargir la zone
663 + {!error && total === 0 && (
664 + <div className="ms-empty" role="status">
665 + <div className="big"><IcoSearch size={34} /></div>
666 + <h3>Aucun logement dans cette zone</h3>
667 + <p>Élargissez la carte ou modifiez vos filtres.</p>
668 + <div className="ms-empty-actions">
669 + <button className="btn btn-primary" onClick={widenArea}>
670 + Élargir la zone
671 + </button>
672 + {hasZone && (
673 + <button className="btn btn-ghost" onClick={clearZone}>
674 + Effacer la zone dessinée
680 675 </button>
681 − {hasZone && (
682 − <button className="btn btn-ghost" onClick={clearZone}>
683 − Effacer la zone dessinée
684 − </button>
685 − )}
686 − </div>
676 + )}
687 677 </div>
688 − )}
678 + </div>
679 + )}
689 680
690 − {!error && listings.map((l) => (
691 − <div
692 − key={l.uid}
693 − data-uid={l.uid}
694 − className={
695 − "map-card" +
696 − (selectedUid === l.uid ? " map-card-sel" : "") +
697 − (mapHoverUid === l.uid ? " map-card-hover" : "")
698 − }
699 − onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
700 − onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
701 − onFocus={() => engineRef.current?.setHovered(l.uid, "app")}
702 − onBlur={() => engineRef.current?.setHovered(null, "app")}
703 − onClickCapture={(e) => onCardClick(e, l.uid)}
704 − >
705 − <ListingCard l={l} />
706 − </div>
707 − ))}
708 −
709 − {!error && total !== null && total > 0 && (
710 − <Pager
711 − page={qy.page} pageSize={PAGE_SIZE} total={total}
712 − onPage={(p) => {
713 − setQy((q) => ({ ...q, page: p }));
714 − listRef.current?.scrollTo({ top: 0, behavior: "smooth" });
715 − }}
716 − />
717 − )}
681 + {!error && listings.map((l) => (
682 + <div
683 + key={l.uid}
684 + data-uid={l.uid}
685 + className={
686 + "map-card" +
687 + (selectedUid === l.uid ? " map-card-sel" : "") +
688 + (mapHoverUid === l.uid ? " map-card-hover" : "")
689 + }
690 + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
691 + onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
692 + onFocus={() => engineRef.current?.setHovered(l.uid, "app")}
693 + onBlur={() => engineRef.current?.setHovered(null, "app")}
694 + onClickCapture={(e) => onCardClick(e, l.uid)}
695 + >
696 + <ListingCard l={l} />
697 + </div>
698 + ))}
699 +
700 + {!error && total !== null && total > 0 && (
701 + <Pager
702 + page={qy.page} pageSize={PAGE_SIZE} total={total}
703 + onPage={(p) => {
704 + setQy((q) => ({ ...q, page: p }));
705 + // le défileur est le parent (ka-pane-body / ka-sheet-body)
706 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
707 + }}
708 + />
709 + )}
718 710
719 − {hoverOffscreen && (
720 − <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">
721 − {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"}
722 − </div>
723 − )}
724 − </div>
711 + {hoverOffscreen && (
712 + <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">
713 + {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"}
714 + </div>
715 + )}
716 + </div>
717 + );
725 718
726 − {/* ------------------------------ carte ------------------------------ */}
727 − <div className="mapview" role="application" aria-label="Carte des logements">
728 − <KaMapView
729 − theme={louKaMapTheme}
730 − mapboxToken={MAPBOX_TOKEN}
731 − center={initialCamera ?? QUEBEC}
732 − zoom={(initialCamera ?? QUEBEC).zoom}
733 − pitch={50}
734 − basemap={{ theme: "default", showLandmarks: true }}
735 − cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
736 − onMoveEnd={onMoveEnd}
737 − onSelect={onSelect}
738 − onHover={onHover}
739 − onDraw={onDraw}
740 − onClusterHover={setClusterTip}
741 − >
742 − <EngineBridge onEngine={handleEngine} />
743 − <SelectionMirror selectedUid={selectedUid} />
744 − <KaBrandBadge />
745 − <MetroLignes />
746 − <Tilt3DControl />
747 − <DrawControl />
748 − <LocateControl />
749 −
750 − {/* « Rechercher quand je déplace la carte » + zone divergée */}
719 + return (
720 + <KaMapShell
721 + className="ms2"
722 + brand={<span className="ms2-brand"><b>Lou·Ka</b><span>Carte</span></span>}
723 + onExit={onExit}
724 + exitLabel="Liste"
725 + storageKey="louka-map-split"
726 + listHeader={listHeader}
727 + list={listPane}
728 + topExtras={
729 + onOpenFilters ? (
730 + <button className="ka-top-btn" onClick={onOpenFilters}>
731 + <IcoSliders size={13} /> Filtres
732 + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
733 + </button>
734 + ) : null
735 + }
736 + >
737 + <KaMapView
738 + theme={louKaMapTheme}
739 + mapboxToken={MAPBOX_TOKEN}
740 + center={initialCamera ?? QUEBEC}
741 + zoom={(initialCamera ?? QUEBEC).zoom}
742 + pitch={50}
743 + basemap={{ theme: "default", showLandmarks: true }}
744 + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
745 + navControl={false}
746 + onMoveEnd={onMoveEnd}
747 + onSelect={onSelect}
748 + onHover={onHover}
749 + onDraw={onDraw}
750 + onClusterHover={setClusterTip}
751 + >
752 + <EngineBridge onEngine={handleEngine} />
753 + <SelectionMirror selectedUid={selectedUid} />
754 + <SheetAutoCollapse selectedUid={selectedUid} />
755 + <KaBrandBadge />
756 + <MetroLignes />
757 +
758 + {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */}
759 + <KaMapToolbar>
760 + {!isMobile && (
761 + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>
762 + )}
763 + <KaToolbarGroup>
764 + <KaToolbar3D />
765 + <KaToolbarDraw />
766 + <KaToolbarLocate
767 + onLocated={() => {
768 + setUserLocked(true);
769 + userLockedRef.current = true;
770 + syncListToViewport(750);
771 + }}
772 + />
773 + </KaToolbarGroup>
774 + </KaMapToolbar>
775 +
776 + {/* mode dessin : bandeau temporaire + CTA « N logements » */}
777 + <KaDrawAreaMode
778 + count={hasZone ? total : null}
779 + formatCount={(n) => `${n.toLocaleString("fr-CA")} logement${n > 1 ? "s" : ""}`}
780 + onClear={clearZone}
781 + />
782 +
783 + {/* « Rechercher quand je déplace la carte » + zone divergée */}
784 + {!hasZone && !drawing && (
751 785 <div className="ka-search-area" role="group" aria-label="Recherche géographique">
752 − {dirty && !searchOnMove && !hasZone && (
786 + {dirty && !searchOnMove && (
753 787 <button type="button" className="ka-search-area-btn"
754 788 onClick={searchThisArea} disabled={loading}>
755 789 {loading ? "Recherche…" : "Rechercher dans cette zone"}
756 790 </button>
757 791 )}
758 − {!hasZone && !drawing && (
759 − <label className="ka-search-area-auto">
760 − <input
761 − type="checkbox"
762 − checked={searchOnMove}
763 − onChange={() => {
764 − const next = !searchOnMove;
765 − setSearchOnMove(next);
766 − if (next && dirty) searchThisArea();
767 − }}
768 − />
769 − <span>Rechercher quand je déplace la carte</span>
770 − </label>
771 − )}
792 + <label className="ka-search-area-auto">
793 + <input
794 + type="checkbox"
795 + checked={searchOnMove}
796 + onChange={() => {
797 + const next = !searchOnMove;
798 + setSearchOnMove(next);
799 + if (next && dirty) searchThisArea();
800 + }}
801 + />
802 + <span>Rechercher en déplaçant la carte</span>
803 + </label>
772 804 </div>
805 + )}
773 806
774 − {loading && (
775 − <div className="ka-loading" role="status" aria-live="polite">
776 − <span className="ka-loading-dot" aria-hidden="true" />
777 − Mise à jour des logements…
778 − </div>
779 − )}
780 −
781 − {total === 0 && !loading && (
782 − <div className="ka-empty" role="status">
783 − <strong>Aucun logement dans cette zone.</strong>
784 − <span>Élargissez la carte ou modifiez vos filtres.</span>
785 − </div>
786 − )}
787 −
788 − {clusterTip && (
789 − <div
790 − className="ka-cluster-tip"
791 − style={{ left: clusterTip.x, top: clusterTip.y }}
792 − >
793 − {clusterTip.count.toLocaleString("fr-CA")} logements
794 − {clusterTip.min != null && clusterTip.max != null && (
795 − <small>
796 − {clusterTip.min === clusterTip.max
797 − ? formatCompactPrice(clusterTip.min)
798 − : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}
799 − {" "}/mois
800 − </small>
801 − )}
802 − </div>
803 − )}
807 + {loading && (
808 + <div className="ka-loading" role="status" aria-live="polite">
809 + <span className="ka-loading-dot" aria-hidden="true" />
810 + Mise à jour des logements…
811 + </div>
812 + )}
804 813
805 − {!isMobile && <PropertyPreview render={(p) => <LazyPreview p={p} />} />}
806 − </KaMapView>
814 + {total === 0 && !loading && (
815 + <div className="ka-empty" role="status">
816 + <strong>Aucun logement dans cette zone.</strong>
817 + <span>Élargissez la carte ou modifiez vos filtres.</span>
818 + </div>
819 + )}
807 820
808 − {/* ---------- carrousel mobile synchronisé aux marqueurs ---------- */}
809 − {isMobile && listings.length > 0 && (
821 + {clusterTip && !isMobile && (
810 822 <div
811 − className="ms-carousel"
812 − ref={carouselRef}
813 − onScroll={onCarouselScroll}
814 − aria-label="Annonces de la zone visible"
823 + className="ka-cluster-tip"
824 + style={{ left: clusterTip.x, top: clusterTip.y }}
815 825 >
816 − {listings.map((l) => (
817 − <Link
818 − key={l.uid}
819 − to={`/logement/${encodeURIComponent(l.uid)}`}
820 − data-uid={l.uid}
821 − className={`ms-car-item${selectedUid === l.uid ? " sel" : ""}`}
822 − >
823 − <SmartImg
824 − src={l.images?.[0] ?? null} width_={160}
825 − fallbackLabel={l.unit_type || undefined}
826 − alt="" loading="lazy" decoding="async"
827 − />
828 − <div className="ms-car-body">
829 − <div className="ms-car-price">
830 − {fmtPrice(l.price, l.price_label)}
831 − <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />
832 − </div>
833 − <div className="ms-car-title">{l.title || l.address}</div>
834 − <div className="ms-car-meta">
835 − {[l.unit_type, l.sector || l.city].filter(Boolean).join(" · ")}
836 − </div>
837 − </div>
838 − </Link>
839 − ))}
840 − {total !== null && total > qy.page * PAGE_SIZE && (
841 − <button
842 − className="ms-car-more"
843 − onClick={(e) => {
844 − e.preventDefault();
845 − setQy((q) => ({ ...q, page: q.page + 1 }));
846 − carouselRef.current?.scrollTo({ left: 0 });
847 − }}
848 − >
849 − Page suivante →
850 − </button>
826 + {clusterTip.count.toLocaleString("fr-CA")} logements
827 + {clusterTip.min != null && clusterTip.max != null && (
828 + <small>
829 + {clusterTip.min === clusterTip.max
830 + ? formatCompactPrice(clusterTip.min)
831 + : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}
832 + {" "}/mois
833 + </small>
851 834 )}
852 835 </div>
853 836 )}
854 − </div>
855 − </div>
856 − );
857 −}
858 837
859 −/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur.
860 − * (Le survol liste→carte passe directement par setHovered — < 100 ms.) */
861 −function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {
862 − const map = useKaMap();
863 − useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
864 − return null;
838 + <KaPropertyPreview render={(p) => <LazyPreview p={p} />} />
839 + </KaMapView>
840 + </KaMapShell>
841 + );
865 842 }
modified frontend/src/styles.css +84 −0
@@ -2775,3 +2775,87 @@ body:has(.cta-sticky) .tabbar { display: none; }
2775 2775 .kv .cell { border: 0; border-bottom: 1px solid var(--hairline); box-shadow: none; }
2776 2776 /* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */
2777 2777 .f-bloc > h2::before, .f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; }
2778 +
2779 +/* =============================================================================
2780 + KA MAP SYSTEM v2 — MODE CARTE LOU-KA (shell plein viewport)
2781 + Jetons du design system appliqués au shell ; la feuille de filtres de la
2782 + page passe en modale au-dessus du mode carte (body.ka-map-mode).
2783 +============================================================================= */
2784 +.ka-shell {
2785 + --ka-accent: var(--accent);
2786 + --ka-on-accent: var(--on-accent, #fff);
2787 + --ka-surface: var(--surface);
2788 + --ka-ink: var(--ink);
2789 + --ka-line: var(--line-strong);
2790 + --ka-radius: var(--r-ctl);
2791 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14);
2792 + --ka-font: var(--font-body);
2793 +}
2794 +.ka-shell-fallback {
2795 + position: fixed; inset: 0; z-index: 640;
2796 + display: grid; place-items: center;
2797 + background: var(--surface); color: var(--ink-3); font-size: 13px;
2798 +}
2799 +.ms2-brand { display: inline-flex; align-items: baseline; gap: 7px; font-family: var(--font-display); min-width: 0; }
2800 +.ms2-brand b { font-size: 15px; letter-spacing: 0.01em; }
2801 +.ms2-brand span {
2802 + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.14em;
2803 + color: var(--ink-3); font-weight: 600;
2804 +}
2805 +.ka-top-btn {
2806 + display: inline-flex; align-items: center; gap: 6px;
2807 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
2808 + background: var(--surface); color: var(--ink);
2809 + font-weight: 600; font-size: 12.5px; padding: 8px 13px;
2810 + cursor: pointer; min-height: 38px; white-space: nowrap;
2811 +}
2812 +.ka-top-btn:hover { background: var(--ink); color: var(--white, #fff); }
2813 +.ka-top-badge {
2814 + min-width: 17px; height: 17px; border-radius: 999px;
2815 + background: var(--accent); color: var(--on-accent, #fff);
2816 + font-size: 10.5px; font-weight: 700; line-height: 17px;
2817 + text-align: center; padding: 0 4px;
2818 +}
2819 +.ms2-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-width: 0; }
2820 +.ms2-count { font-size: 13px; color: var(--ink-2); min-width: 0; }
2821 +.ms2-count b { color: var(--ink); font-size: 15px; }
2822 +.ms2-sort select {
2823 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
2824 + background: var(--surface); padding: 6px 8px; font-size: 12.5px;
2825 + font-weight: 600; color: var(--ink); max-width: 168px;
2826 +}
2827 +.ms2-list { display: flex; flex-direction: column; gap: 14px; position: relative; }
2828 +.ms2-list .map-card { flex: 0 0 auto; }
2829 +.ka-sheet .ms2-list { gap: 12px; padding-bottom: 10px; }
2830 +.ka-sheet .ms2-head { padding-right: 2px; }
2831 +
2832 +/* filtres de la page AU-DESSUS du mode carte — desktop : modale centrée */
2833 +@media (min-width: 781px) {
2834 + body.ka-map-mode .sheet-backdrop {
2835 + display: block; position: fixed; inset: 0;
2836 + z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.45);
2837 + }
2838 + body.ka-map-mode .search-zone { display: none; }
2839 + body.ka-map-mode .search-zone.open {
2840 + display: block; position: fixed; top: 7dvh; left: 50%;
2841 + transform: translateX(-50%); width: min(760px, 94vw);
2842 + max-height: 84dvh; overflow-y: auto; z-index: var(--z-modal, 900);
2843 + background: var(--surface); border: 1.5px solid var(--ink);
2844 + border-radius: var(--r-card); box-shadow: var(--shadow-off);
2845 + padding: 16px 22px 20px; margin: 0;
2846 + }
2847 + body.ka-map-mode .search-zone.open .sheet-head {
2848 + display: flex; align-items: center; justify-content: space-between;
2849 + font-weight: 700; font-size: 14px; margin-bottom: 10px;
2850 + }
2851 + body.ka-map-mode .search-zone.open .sheet-close {
2852 + display: grid; place-items: center; width: 34px; height: 34px;
2853 + border: 1.5px solid var(--ink); border-radius: 50%;
2854 + background: var(--surface); cursor: pointer; font-size: 14px;
2855 + }
2856 +}
2857 +
2858 +/* mode carte : le widget Ka Agent s'efface (le sheet occupe son coin) */
2859 +body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; }
2860 +/* mobile : la puce « sans position » encombre l'en-tête du sheet */
2861 +@media (max-width: 780px) { .ka-shell .ms-nopos { display: none; } }
2778 2862