SPB Git

spb/lou-ka

Public

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

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 (Aug 26, 2026) parent e911a65 Browse files

4 changed files +441 −361

modified frontend/src/kamaps/theme.ts +24 −20
@@ -2,52 +2,56 @@
2 // Author: Simon-Pierre Boucher 2 // Author: Simon-Pierre Boucher
3 // Contact: contact@spboucher.ai 3 // Contact: contact@spboucher.ai
4 // Project: Groupe Ka / Ka Maps (Lou-Ka integration) 4 // Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5−// kamaps/theme.ts : thème Lou-Ka Maps — identité officielle Lou-Ka 5+// kamaps/theme.ts : thème Lou-Ka Maps v2 — identité orange / off-white / noir.
6−// (marine #0B1330 / bleu vif #2258FF / orange #FF6A00) appliquée à Ka Maps. 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 import type { KaMapTheme } from "@groupe-ka/ka-maps"; 9 import type { KaMapTheme } from "@groupe-ka/ka-maps";
9 10
10−const NAVY = "#0b1330"; 11+const INK = "#141814";
11−const BLUE = "#2258ff";
12 const ORANGE = "#ff6a00"; 12 const ORANGE = "#ff6a00";
13+const OFFWHITE = "#fffdf8";
13 const WHITE = "#ffffff"; 14 const WHITE = "#ffffff";
14 15
15 export const louKaMapTheme: KaMapTheme = { 16 export const louKaMapTheme: KaMapTheme = {
16 id: "lou-ka", 17 id: "lou-ka",
17 productName: "Lou-Ka Maps", 18 productName: "Lou-Ka Maps",
18− accent: BLUE, 19+ accent: ORANGE,
19 onAccent: WHITE, 20 onAccent: WHITE,
20 fontFamily: 21 fontFamily:
21 '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif', 22 '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
22 markers: { 23 markers: {
23− // Prix : pastille bleu marine, texte BLANC — contraste maximal sur fond clair. 24+ // Loyer : pastille off-white, texte encre — lisible sans écraser la carte.
24− // L'orange n'apparaît qu'à la sélection (le moment de marque). 25+ // Sélection/survol : encre pleine (texte blanc), le repère absolu.
25 sale: { 26 sale: {
26− background: NAVY, 27+ background: OFFWHITE,
27− text: WHITE, 28+ text: INK,
28− halo: "rgba(255,255,255,0.45)", 29+ halo: "rgba(20, 24, 20, 0.30)",
29− selectedBackground: ORANGE, 30+ selectedBackground: INK,
30 selectedText: WHITE, 31 selectedText: WHITE,
31 }, 32 },
32 rent: { 33 rent: {
33− background: NAVY, 34+ background: OFFWHITE,
34− text: WHITE, 35+ text: INK,
35− halo: "rgba(255,255,255,0.45)", 36+ halo: "rgba(20, 24, 20, 0.30)",
36− selectedBackground: ORANGE, 37+ selectedBackground: INK,
37 selectedText: WHITE, 38 selectedText: WHITE,
38 }, 39 },
40+ // « Sous le marché » : orange Lou-Ka d'office — LE signal d'affaire.
39 highlight: { 41 highlight: {
40− background: BLUE, 42+ background: ORANGE,
41 text: WHITE, 43 text: WHITE,
42− halo: "rgba(255,255,255,0.45)", 44+ halo: "rgba(255, 255, 255, 0.55)",
43− selectedBackground: ORANGE, 45+ selectedBackground: INK,
44 selectedText: WHITE, 46 selectedText: WHITE,
45 }, 47 },
46 }, 48 },
47 cluster: { 49 cluster: {
48− background: NAVY, 50+ background: OFFWHITE,
49− text: WHITE, 51+ text: INK,
50 border: ORANGE, 52 border: ORANGE,
53+ valueText: "#9c4200",
54+ valueHalo: "rgba(255, 253, 248, 0.92)",
51 }, 55 },
52 // Cartographie Lou-Ka : fond neutre et raffiné — gris très pâle, 56 // Cartographie Lou-Ka : fond neutre et raffiné — gris très pâle,
53 // eau bleu désaturé, parcs verts subtils, bâtiments bien définis. 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 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte) 98 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte)
99 setView(params.get("view") === "carte" ? "carte" : "liste"); 99 setView(params.get("view") === "carte" ? "carte" : "liste");
100 }, [params]); 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 const qDebounced = useDebounced(q); 113 const qDebounced = useDebounced(q);
103 const filters: ListingFilters = useMemo(() => { 114 const filters: ListingFilters = useMemo(() => {
@@ -587,12 +598,16 @@ export default function Home() {
587 <div className="view-pane" key="carte"> 598 <div className="view-pane" key="carte">
588 <Suspense 599 <Suspense
589 fallback={ 600 fallback={
590− <div className="map-split"> 601+ <div className="ka-shell-fallback">Chargement de la carte…</div>
591− <div className="mapview map-loading">Chargement de la carte…</div>
592− </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 </Suspense> 611 </Suspense>
597 </div> 612 </div>
598 )} 613 )}
modified frontend/src/search/MapSearch.tsx +314 −337
@@ -1,7 +1,8 @@
1 // ----------------------------------------------------------------------------- 1 // -----------------------------------------------------------------------------
2 // Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 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 // UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent 7 // 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 // dans un état unique ; chaque changement déclenche UNE requête /api/search
@@ -9,25 +10,24 @@
9 // pareil : le compteur de la liste est par construction égal au nombre de 10 // 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 // 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+// v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est
13−// « Rechercher quand je déplace la carte », bouton « Rechercher dans 14+// une prise de contrôle du viewport.
14−// cette zone », polygone dessiné (puce retirable), clic cluster ; 15+// · mobile : carte edge-to-edge, barre supérieure compacte, résultats en
15−// · liste → carte : filtres (recadrage animé sauf si l'utilisateur a pris 16+// bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ;
16−// la main — bouton « Recadrer sur les résultats »), tri partagé ; 17+// · desktop : split résultats|carte redimensionnable (ratio persisté),
17−// · miroirs : survol carte↔liste (< 100 ms, feature-state GPU), clic 18+// carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ;
18−// marqueur → défilement de la liste (page ajustée au besoin) + pulsation, 19+// · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation),
19−// clic annonce → recentrage doux + mini-fiche ; 20+// « Rechercher dans cette zone » contextuel, mode dessin temporaire avec
20−// · mobile : carrousel bas d'écran synchronisé avec les marqueurs ; 21+// CTA « N logements dans cette zone ».
21−// · URL : caméra + tri + page + zone dessinée + mode de recherche.
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 // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md. 24 // sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md.
25 // ----------------------------------------------------------------------------- 25 // -----------------------------------------------------------------------------
26 import MetroLignes from "../components/MetroLignes"; 26 import MetroLignes from "../components/MetroLignes";
27 import { 27 import {
28 useCallback, useEffect, useMemo, useRef, useState, 28 useCallback, useEffect, useMemo, useRef, useState,
29 } from "react"; 29 } from "react";
30−import { Link, useNavigate } from "react-router-dom"; 30+import { useNavigate } from "react-router-dom";
31 import "mapbox-gl/dist/mapbox-gl.css"; 31 import "mapbox-gl/dist/mapbox-gl.css";
32 import "@groupe-ka/ka-maps/styles.css"; 32 import "@groupe-ka/ka-maps/styles.css";
33 import type { MapProperty } from "@groupe-ka/ka-maps"; 33 import type { MapProperty } from "@groupe-ka/ka-maps";
@@ -36,18 +36,19 @@ import {
36 } from "@groupe-ka/ka-maps"; 36 } from "@groupe-ka/ka-maps";
37 import type { KaMap } from "@groupe-ka/ka-maps"; 37 import type { KaMap } from "@groupe-ka/ka-maps";
38 import { 38 import {
39− DrawControl, KaBrandBadge, KaMapView, LocateControl, PropertyPreview, 39+ KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView,
40− Tilt3DControl, useKaMap, 40+ KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup,
41+ KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell,
41 } from "@groupe-ka/ka-maps/react"; 42 } from "@groupe-ka/ka-maps/react";
42 import { 43 import {
43 Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort, 44 Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,
44 fetchListing, fetchSearch, fmtPrice, sourceName, thumb, 45 fetchListing, fetchSearch, fmtPrice, sourceName, thumb,
45 } from "../api"; 46 } from "../api";
47+import { useAccount } from "../account";
46 import ListingCard from "../components/ListingCard"; 48 import ListingCard from "../components/ListingCard";
47 import Pager from "../components/Pager"; 49 import Pager from "../components/Pager";
48−import SmartImg from "../components/SmartImg";
49 import FairValueBadge from "../components/FairValueBadge"; 50 import FairValueBadge from "../components/FairValueBadge";
50−import { IcoSearch } from "../components/Icons"; 51+import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons";
51 import { louKaMapTheme } from "../kamaps/theme"; 52 import { louKaMapTheme } from "../kamaps/theme";
52 import { MAPBOX_TOKEN } from "../kamaps/config"; 53 import { MAPBOX_TOKEN } from "../kamaps/config";
53 import { seenUids } from "./seen"; 54 import { seenUids } from "./seen";
@@ -120,13 +121,34 @@ function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
120 return null; 121 return null;
121 } 122 }
122 123
123−/** Mini-fiche de marqueur : les points carte sont volontairement compacts, 124+/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */
124− * le détail (photo, titre, dispo) est chargé à la sélection (rapide, mis 125+function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {
125− * en cache) — même langage visuel que les cartes de la liste. */ 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 const previewCache = new Map<string, Listing>(); 147 const previewCache = new Map<string, Listing>();
127 148
128 function LazyPreview({ p }: { p: MapProperty }) { 149 function LazyPreview({ p }: { p: MapProperty }) {
129 const navigate = useNavigate(); 150 const navigate = useNavigate();
151+ const { favs, toggleFav } = useAccount();
130 const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null); 152 const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);
131 useEffect(() => { 153 useEffect(() => {
132 if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; } 154 if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }
@@ -146,34 +168,52 @@ function LazyPreview({ p }: { p: MapProperty }) {
146 }, [p.id]); 168 }, [p.id]);
147 169
148 const img = l?.images?.[0] ?? null; 170 const img = l?.images?.[0] ?? null;
171+ const fav = favs.has(p.id);
172+ const fiche = `/logement/${encodeURIComponent(p.id)}`;
149 return ( 173 return (
150− <div className="mv-pop"> 174+ <>
151− {img ? ( 175+ <div className="ka-prevcard-media">
152− <img src={thumb(img, 480)} alt="" loading="lazy" /> 176+ {img ? (
153− ) : ( 177+ <img src={thumb(img, 480)} alt="" loading="lazy" />
154− <div className="mv-noimg" aria-hidden="true">⌂</div> 178+ ) : (
155− )} 179+ <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
156− <div className="mv-pop-body"> 180+ )}
157− <div className="mv-pop-price"> 181+ </div>
182+ <div className="ka-prevcard-body">
183+ <div className="ka-prevcard-price">
158 {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"} 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 {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />} 186 {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}
161 </div> 187 </div>
162− <div className="mv-pop-title">{l ? (l.title || l.address) : "Chargement…"}</div> 188+ <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Chargement…"}</div>
163− <div className="mv-pop-meta"> 189+ <div className="ka-prevcard-meta">
164− {[l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", l?.availability] 190+ {[
165− .filter(Boolean).join(" · ")} 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 </div> 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 </div> 215 </div>
176− </div> 216+ </>
177 ); 217 );
178 } 218 }
179 219
@@ -181,6 +221,12 @@ export interface MapSearchProps {
181 filters: ListingFilters; 221 filters: ListingFilters;
182 /** Compteur partagé remonté à l'entête de résultats de la page. */ 222 /** Compteur partagé remonté à l'entête de résultats de la page. */
183 onTotal?: (total: number | null) => void; 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 interface Query { 232 interface Query {
@@ -190,7 +236,9 @@ interface Query {
190 page: number; 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 const initialParams = useMemo( 242 const initialParams = useMemo(
195 () => new URLSearchParams(window.location.search), []); 243 () => new URLSearchParams(window.location.search), []);
196 const initialCamera = useMemo( 244 const initialCamera = useMemo(
@@ -244,9 +292,7 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
244 const pointsKeyRef = useRef<string>(""); 292 const pointsKeyRef = useRef<string>("");
245 const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat 293 const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat
246 const pendingScrollUidRef = useRef<string | null>(null); 294 const pendingScrollUidRef = useRef<string | null>(null);
247− const carouselDrivenRef = useRef(false);
248 const listRef = useRef<HTMLDivElement | null>(null); 295 const listRef = useRef<HTMLDivElement | null>(null);
249− const carouselRef = useRef<HTMLDivElement | null>(null);
250 296
251 const filtersClean = useMemo(() => { 297 const filtersClean = useMemo(() => {
252 const f: Record<string, string> = {}; 298 const f: Record<string, string> = {};
@@ -391,6 +437,16 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
391 437
392 // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la 438 // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la
393 // position et synchroniser la liste sur cette zone --- 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 useEffect(() => { 450 useEffect(() => {
395 const handler = (e: Event) => { 451 const handler = (e: Event) => {
396 const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail; 452 const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;
@@ -399,17 +455,11 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
399 setUserLocked(true); 455 setUserLocked(true);
400 userLockedRef.current = true; 456 userLockedRef.current = true;
401 m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 }); 457 m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });
402− window.setTimeout(() => { 458+ syncListToViewport(950);
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);
409 }; 459 };
410 window.addEventListener("louka:geolocate", handler); 460 window.addEventListener("louka:geolocate", handler);
411 return () => window.removeEventListener("louka:geolocate", handler); 461 return () => window.removeEventListener("louka:geolocate", handler);
412− }, []); 462+ }, [syncListToViewport]);
413 463
414 // --- geste sur la carte --- 464 // --- geste sur la carte ---
415 const onMoveEnd = useCallback( 465 const onMoveEnd = useCallback(
@@ -457,22 +507,12 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
457 const widenArea = useCallback(() => { 507 const widenArea = useCallback(() => {
458 const m = engineRef.current; 508 const m = engineRef.current;
459 if (!m) return; 509 if (!m) return;
460− // « Élargir la zone » : dézoome — le moveend (byUser côté moteur ? non, 510+ // « Élargir la zone » : dézoome, puis recherche élargie explicite.
461− // easeTo interne) ; on déclenche la recherche élargie explicitement.
462− const b = m.currentBBox();
463 m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, { 511 m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {
464 zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650, 512 zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,
465 }); 513 });
466− if (b) { 514+ syncListToViewport(700);
467− window.setTimeout(() => { 515+ }, [syncListToViewport]);
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− }, []);
476 516
477 // --- polygone dessiné --- 517 // --- polygone dessiné ---
478 const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => { 518 const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {
@@ -509,22 +549,12 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
509 card.classList.add("map-card-pulse"); 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 const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => { 554 const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {
521 const uid = p?.id ?? null; 555 const uid = p?.id ?? null;
522 setSelectedUid(uid); 556 setSelectedUid(uid);
523 if (!uid) return; 557 if (!uid) return;
524− if (carouselDrivenRef.current) { // déjà au bon endroit
525− carouselDrivenRef.current = false;
526− return;
527− }
528 const idx = uidIndex.get(uid); 558 const idx = uidIndex.get(uid);
529 if (idx === undefined) return; 559 if (idx === undefined) return;
530 const targetPage = Math.floor(idx / PAGE_SIZE) + 1; 560 const targetPage = Math.floor(idx / PAGE_SIZE) + 1;
@@ -562,30 +592,7 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
562 else setHoverOffscreen(null); 592 else setHoverOffscreen(null);
563 }, [mapHoverUid]); 593 }, [mapHoverUid]);
564 594
565− // --- carrousel mobile : balayage → sélection sur la carte --- 595+ // --- clic sur une carte d'annonce : recentre + fiche (1er clic),
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),
589 // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) --- 596 // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---
590 const onCardClick = useCallback((e: React.MouseEvent, uid: string) => { 597 const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {
591 if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet 598 if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet
@@ -594,272 +601,242 @@ export default function MapSearch({ filters, onTotal }: MapSearchProps) {
594 engineRef.current?.select(uid, "app"); 601 engineRef.current?.select(uid, "app");
595 }, [selectedUid]); 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 const listings = res?.listings ?? []; 604 const listings = res?.listings ?? [];
609 const total = res?.total ?? null; 605 const total = res?.total ?? null;
610 const unpositioned = res?.unpositioned ?? 0; 606 const unpositioned = res?.unpositioned ?? 0;
611 const hasZone = qy.poly !== null; 607 const hasZone = qy.poly !== null;
612 608
613− return ( 609+ /* ------------------------- volets du shell ------------------------- */
614− <div className="map-split ms" ref={rootRef}> 610+
615− {/* ---------------- volet liste (desktop / tablette) ---------------- */} 611+ const listHeader = (
616− <div className="map-list ms-list" aria-label="Résultats en liste" ref={listRef}> 612+ <div className="ms2-head">
617− <div className="ms-list-head"> 613+ <div className="ms2-count" role="status" aria-live="polite">
618− <div className="ms-count" role="status" aria-live="polite"> 614+ {total === null ? "…" : (
619− {total === null ? "…" : ( 615+ <>
620− <> 616+ <b>{total.toLocaleString("fr-CA")}</b>
621− <b>{total.toLocaleString("fr-CA")}</b> 617+ {" "}logement{total !== null && total > 1 ? "s" : ""}
622− {" "}logement{total > 1 ? "s" : ""} 618+ {hasZone ? " · zone dessinée" : ""}
623− {hasZone ? " dans la zone dessinée" : ""} 619+ </>
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>
656 )} 620 )}
657− {userLocked && total !== null && total > 0 && !hasZone && ( 621+ {unpositioned > 0 && (
658− <button className="ms-refit" onClick={refitToResults}> 622+ <span
659− ⌖ Recadrer sur les résultats 623+ className="ms-nopos"
660− </button> 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 && ( 654+ {loading && <div className="ms-progress" aria-hidden="true" />}
666− <div className="ms-empty"> 655+
667− <h3>Impossible de charger les résultats</h3> 656+ {error && (
668− <p>{error}</p> 657+ <div className="ms-empty">
669− </div> 658+ <h3>Impossible de charger les résultats</h3>
670− )} 659+ <p>{error}</p>
660+ </div>
661+ )}
671 662
672− {!error && total === 0 && ( 663+ {!error && total === 0 && (
673− <div className="ms-empty" role="status"> 664+ <div className="ms-empty" role="status">
674− <div className="big"><IcoSearch size={34} /></div> 665+ <div className="big"><IcoSearch size={34} /></div>
675− <h3>Aucun logement dans cette zone</h3> 666+ <h3>Aucun logement dans cette zone</h3>
676− <p>Élargissez la carte ou modifiez vos filtres.</p> 667+ <p>Élargissez la carte ou modifiez vos filtres.</p>
677− <div className="ms-empty-actions"> 668+ <div className="ms-empty-actions">
678− <button className="btn btn-primary" onClick={widenArea}> 669+ <button className="btn btn-primary" onClick={widenArea}>
679− Élargir la zone 670+ Élargir la zone
671+ </button>
672+ {hasZone && (
673+ <button className="btn btn-ghost" onClick={clearZone}>
674+ Effacer la zone dessinée
680 </button> 675 </button>
681− {hasZone && ( 676+ )}
682− <button className="btn btn-ghost" onClick={clearZone}>
683− Effacer la zone dessinée
684− </button>
685− )}
686− </div>
687 </div> 677 </div>
688− )} 678+ </div>
679+ )}
689 680
690− {!error && listings.map((l) => ( 681+ {!error && listings.map((l) => (
691− <div 682+ <div
692− key={l.uid} 683+ key={l.uid}
693− data-uid={l.uid} 684+ data-uid={l.uid}
694− className={ 685+ className={
695− "map-card" + 686+ "map-card" +
696− (selectedUid === l.uid ? " map-card-sel" : "") + 687+ (selectedUid === l.uid ? " map-card-sel" : "") +
697− (mapHoverUid === l.uid ? " map-card-hover" : "") 688+ (mapHoverUid === l.uid ? " map-card-hover" : "")
698− } 689+ }
699− onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} 690+ onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
700− onMouseLeave={() => engineRef.current?.setHovered(null, "app")} 691+ onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
701− onFocus={() => engineRef.current?.setHovered(l.uid, "app")} 692+ onFocus={() => engineRef.current?.setHovered(l.uid, "app")}
702− onBlur={() => engineRef.current?.setHovered(null, "app")} 693+ onBlur={() => engineRef.current?.setHovered(null, "app")}
703− onClickCapture={(e) => onCardClick(e, l.uid)} 694+ onClickCapture={(e) => onCardClick(e, l.uid)}
704− > 695+ >
705− <ListingCard l={l} /> 696+ <ListingCard l={l} />
706− </div> 697+ </div>
707− ))} 698+ ))}
708− 699+
709− {!error && total !== null && total > 0 && ( 700+ {!error && total !== null && total > 0 && (
710− <Pager 701+ <Pager
711− page={qy.page} pageSize={PAGE_SIZE} total={total} 702+ page={qy.page} pageSize={PAGE_SIZE} total={total}
712− onPage={(p) => { 703+ onPage={(p) => {
713− setQy((q) => ({ ...q, page: p })); 704+ setQy((q) => ({ ...q, page: p }));
714− listRef.current?.scrollTo({ top: 0, behavior: "smooth" }); 705+ // le défileur est le parent (ka-pane-body / ka-sheet-body)
715− }} 706+ listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
716− /> 707+ }}
717− )} 708+ />
709+ )}
718 710
719− {hoverOffscreen && ( 711+ {hoverOffscreen && (
720− <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true"> 712+ <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">
721− {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"} 713+ {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"}
722− </div> 714+ </div>
723− )} 715+ )}
724− </div> 716+ </div>
717+ );
725 718
726− {/* ------------------------------ carte ------------------------------ */} 719+ return (
727− <div className="mapview" role="application" aria-label="Carte des logements"> 720+ <KaMapShell
728− <KaMapView 721+ className="ms2"
729− theme={louKaMapTheme} 722+ brand={<span className="ms2-brand"><b>Lou·Ka</b><span>Carte</span></span>}
730− mapboxToken={MAPBOX_TOKEN} 723+ onExit={onExit}
731− center={initialCamera ?? QUEBEC} 724+ exitLabel="Liste"
732− zoom={(initialCamera ?? QUEBEC).zoom} 725+ storageKey="louka-map-split"
733− pitch={50} 726+ listHeader={listHeader}
734− basemap={{ theme: "default", showLandmarks: true }} 727+ list={listPane}
735− cluster={{ maxZoom: 15, valueClamp: [250, 8000] }} 728+ topExtras={
736− onMoveEnd={onMoveEnd} 729+ onOpenFilters ? (
737− onSelect={onSelect} 730+ <button className="ka-top-btn" onClick={onOpenFilters}>
738− onHover={onHover} 731+ <IcoSliders size={13} /> Filtres
739− onDraw={onDraw} 732+ {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
740− onClusterHover={setClusterTip} 733+ </button>
741− > 734+ ) : null
742− <EngineBridge onEngine={handleEngine} /> 735+ }
743− <SelectionMirror selectedUid={selectedUid} /> 736+ >
744− <KaBrandBadge /> 737+ <KaMapView
745− <MetroLignes /> 738+ theme={louKaMapTheme}
746− <Tilt3DControl /> 739+ mapboxToken={MAPBOX_TOKEN}
747− <DrawControl /> 740+ center={initialCamera ?? QUEBEC}
748− <LocateControl /> 741+ zoom={(initialCamera ?? QUEBEC).zoom}
749− 742+ pitch={50}
750− {/* « Rechercher quand je déplace la carte » + zone divergée */} 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 <div className="ka-search-area" role="group" aria-label="Recherche géographique"> 785 <div className="ka-search-area" role="group" aria-label="Recherche géographique">
752− {dirty && !searchOnMove && !hasZone && ( 786+ {dirty && !searchOnMove && (
753 <button type="button" className="ka-search-area-btn" 787 <button type="button" className="ka-search-area-btn"
754 onClick={searchThisArea} disabled={loading}> 788 onClick={searchThisArea} disabled={loading}>
755 {loading ? "Recherche…" : "Rechercher dans cette zone"} 789 {loading ? "Recherche…" : "Rechercher dans cette zone"}
756 </button> 790 </button>
757 )} 791 )}
758− {!hasZone && !drawing && ( 792+ <label className="ka-search-area-auto">
759− <label className="ka-search-area-auto"> 793+ <input
760− <input 794+ type="checkbox"
761− type="checkbox" 795+ checked={searchOnMove}
762− checked={searchOnMove} 796+ onChange={() => {
763− onChange={() => { 797+ const next = !searchOnMove;
764− const next = !searchOnMove; 798+ setSearchOnMove(next);
765− setSearchOnMove(next); 799+ if (next && dirty) searchThisArea();
766− if (next && dirty) searchThisArea(); 800+ }}
767− }} 801+ />
768− /> 802+ <span>Rechercher en déplaçant la carte</span>
769− <span>Rechercher quand je déplace la carte</span> 803+ </label>
770− </label>
771− )}
772 </div> 804 </div>
805+ )}
773 806
774− {loading && ( 807+ {loading && (
775− <div className="ka-loading" role="status" aria-live="polite"> 808+ <div className="ka-loading" role="status" aria-live="polite">
776− <span className="ka-loading-dot" aria-hidden="true" /> 809+ <span className="ka-loading-dot" aria-hidden="true" />
777− Mise à jour des logements… 810+ Mise à jour des logements…
778− </div> 811+ </div>
779− )} 812+ )}
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− )}
804 813
805− {!isMobile && <PropertyPreview render={(p) => <LazyPreview p={p} />} />} 814+ {total === 0 && !loading && (
806− </KaMapView> 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 ---------- */} 821+ {clusterTip && !isMobile && (
809− {isMobile && listings.length > 0 && (
810 <div 822 <div
811− className="ms-carousel" 823+ className="ka-cluster-tip"
812− ref={carouselRef} 824+ style={{ left: clusterTip.x, top: clusterTip.y }}
813− onScroll={onCarouselScroll}
814− aria-label="Annonces de la zone visible"
815 > 825 >
816− {listings.map((l) => ( 826+ {clusterTip.count.toLocaleString("fr-CA")} logements
817− <Link 827+ {clusterTip.min != null && clusterTip.max != null && (
818− key={l.uid} 828+ <small>
819− to={`/logement/${encodeURIComponent(l.uid)}`} 829+ {clusterTip.min === clusterTip.max
820− data-uid={l.uid} 830+ ? formatCompactPrice(clusterTip.min)
821− className={`ms-car-item${selectedUid === l.uid ? " sel" : ""}`} 831+ : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}
822− > 832+ {" "}/mois
823− <SmartImg 833+ </small>
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>
851 )} 834 )}
852 </div> 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. 838+ <KaPropertyPreview render={(p) => <LazyPreview p={p} />} />
860− * (Le survol liste→carte passe directement par setHovered — < 100 ms.) */ 839+ </KaMapView>
861−function SelectionMirror({ selectedUid }: { selectedUid: string | null }) { 840+ </KaMapShell>
862− const map = useKaMap(); 841+ );
863− useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
864− return null;
865 } 842 }
modified frontend/src/styles.css +84 −0
@@ -2775,3 +2775,87 @@ body:has(.cta-sticky) .tabbar { display: none; }
2775 .kv .cell { border: 0; border-bottom: 1px solid var(--hairline); box-shadow: none; } 2775 .kv .cell { border: 0; border-bottom: 1px solid var(--hairline); box-shadow: none; }
2776 /* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */ 2776 /* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */
2777 .f-bloc > h2::before, .f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; } 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
Menu

Theme