| 2 |
2 |
// Author: Simon-Pierre Boucher |
| 3 |
3 |
// Contact: contact@spboucher.ai |
| 4 |
4 |
// Project: Groupe Ka / Ka Maps (Immo-Ka integration) |
| 5 |
|
−// components/MapView.tsx : Immo-Ka Maps — la MÊME carte que Lou-Ka, propulsée |
| 6 |
|
−// par le framework partagé Ka Maps (fond Mapbox Standard 3D réaliste, |
| 7 |
|
−// pastilles de prix GPU, clustering natif, « Rechercher dans cette zone », |
| 8 |
|
−// aperçu React, caméra partageable dans l'URL). |
| 9 |
|
−// · chargée paresseusement (React.lazy) comme avant ; |
|
5 |
+// components/MapView.tsx : le MODE CARTE Immo-Ka (Ka Map System v2) — la même |
|
6 |
+// architecture que Lou-Ka Maps, thémée rouge/off-white/encre. |
|
7 |
+// · prise de contrôle du viewport (KaMapShell) : mobile edge-to-edge + |
|
8 |
+// bottom sheet de résultats à 3 crans, desktop split liste|carte |
|
9 |
+// redimensionnable avec carte quasi plein écran ; |
| 10 |
10 |
// · données pilotées par le viewport : /api/listings.geojson?bbox=… |
| 11 |
|
−// (requêtes annulables, cache, jamais de tempête pendant le pan) ; |
| 12 |
|
−// · pastilles rouges = « bonne affaire » (prix sous l'estimation Vrai-Prix). |
|
11 |
+// (adaptateur Ka Maps — requêtes annulables, jamais de tempête) ; |
|
12 |
+// · toolbar unifiée (zoom, 3D, dessiner, me localiser), « Rechercher dans |
|
13 |
+// cette zone » contextuel, zone dessinée découpée CÔTÉ CLIENT |
|
14 |
+// (setClipPolygon) avec CTA « N propriétés dans cette zone » ; |
|
15 |
+// · fiche contextuelle v2 : photo, prix, badge « sous l'estimation |
|
16 |
+// Vrai-Prix », swipe/chevrons entre propriétés voisines. |
| 13 |
17 |
// ----------------------------------------------------------------------------- |
| 14 |
18 |
import MetroLignes from "./MetroLignes"; |
| 15 |
|
−import { useCallback, useEffect, useMemo } from "react"; |
|
19 |
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 16 |
20 |
import { useNavigate } from "react-router-dom"; |
| 17 |
21 |
import "mapbox-gl/dist/mapbox-gl.css"; |
| 18 |
22 |
import "@groupe-ka/ka-maps/styles.css"; |
| 19 |
|
−import type { MapProperty } from "@groupe-ka/ka-maps"; |
|
23 |
+import type { KaMap, MapProperty } from "@groupe-ka/ka-maps"; |
| 20 |
24 |
import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps"; |
| 21 |
25 |
import { |
| 22 |
26 |
KaBrandBadge, |
|
27 |
+ KaDrawAreaMode, |
|
28 |
+ KaMapShell, |
|
29 |
+ KaMapToolbar, |
| 23 |
30 |
KaMapView, |
|
31 |
+ KaPropertyPreview, |
|
32 |
+ KaToolbar3D, |
|
33 |
+ KaToolbarDraw, |
|
34 |
+ KaToolbarGroup, |
|
35 |
+ KaToolbarLocate, |
|
36 |
+ KaToolbarZoom, |
| 24 |
37 |
LoadingIndicator, |
| 25 |
|
− PropertyPreview, |
| 26 |
|
− ResultCount, |
| 27 |
38 |
SearchAreaControl, |
| 28 |
|
− Tilt3DControl, |
| 29 |
39 |
useKaMap, |
|
40 |
+ useKaShell, |
| 30 |
41 |
} from "@groupe-ka/ka-maps/react"; |
| 31 |
|
−import { ListingFilters, sourceName } from "../api"; |
|
42 |
+import { Listing, ListingFilters, sourceName } from "../api"; |
|
43 |
+import ListingCard from "./ListingCard"; |
| 32 |
44 |
import { immoKaMapTheme } from "../kamaps/theme"; |
| 33 |
45 |
import { immoKaMapAdapter } from "../kamaps/adapter"; |
| 34 |
46 |
import { MAPBOX_TOKEN } from "../kamaps/config"; |
| 35 |
47 |
|
| 36 |
48 |
const QUEBEC_CENTER = { lat: 46.82, lng: -71.25 }; |
| 37 |
49 |
|
| 38 |
|
−/** Aperçu compact d'une propriété — même langage visuel que ListingCard. */ |
|
50 |
+/** Fiche contextuelle — structure ka-prevcard du Ka Map System, contenu |
|
51 |
+ * Immo-Ka (l'adaptateur porte déjà photo/prix/traits : aucun fetch). */ |
| 39 |
52 |
function PreviewCard({ p }: { p: MapProperty }) { |
| 40 |
53 |
const navigate = useNavigate(); |
| 41 |
54 |
const extra = (p.extra ?? {}) as { |
| 49 |
62 |
p.propertyType, |
| 50 |
63 |
p.bedrooms != null ? `${p.bedrooms} ch.` : "", |
| 51 |
64 |
p.bathrooms != null ? `${p.bathrooms} sdb` : "", |
|
65 |
+ extra.source ? sourceName(extra.source) : "", |
| 52 |
66 |
].filter(Boolean).join(" · "); |
|
67 |
+ const fiche = p.originalUrl ?? "/"; |
| 53 |
68 |
return ( |
| 54 |
|
− <div className="mv-pop"> |
| 55 |
|
− {p.thumbnailUrl ? ( |
| 56 |
|
− <img |
| 57 |
|
− src={p.thumbnailUrl} alt="" loading="lazy" |
| 58 |
|
− onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} |
| 59 |
|
− /> |
| 60 |
|
− ) : ( |
| 61 |
|
− <div className="mv-noimg" aria-hidden="true">⌂</div> |
| 62 |
|
− )} |
| 63 |
|
− <div className="mv-pop-body"> |
| 64 |
|
− <div className="mv-pop-price"> |
|
69 |
+ <> |
|
70 |
+ <div className="ka-prevcard-media"> |
|
71 |
+ {p.thumbnailUrl ? ( |
|
72 |
+ <img |
|
73 |
+ src={p.thumbnailUrl} alt="" loading="lazy" |
|
74 |
+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} |
|
75 |
+ /> |
|
76 |
+ ) : ( |
|
77 |
+ <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div> |
|
78 |
+ )} |
|
79 |
+ </div> |
|
80 |
+ <div className="ka-prevcard-body"> |
|
81 |
+ <div className="ka-prevcard-price"> |
| 65 |
82 |
{prix} |
| 66 |
|
− {p.highlight && <span className="mv-pop-deal">Sous l'estimation Vrai-Prix</span>} |
|
83 |
+ {p.highlight && <span className="ka-prevcard-badge">Sous l'estimation Vrai-Prix</span>} |
|
84 |
+ </div> |
|
85 |
+ <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div> |
|
86 |
+ <div className="ka-prevcard-meta">{meta}</div> |
|
87 |
+ <div className="ka-prevcard-actions"> |
|
88 |
+ <a |
|
89 |
+ className="ka-prevcard-cta" |
|
90 |
+ href={fiche} |
|
91 |
+ onClick={(e) => { e.preventDefault(); navigate(fiche); }} |
|
92 |
+ > |
|
93 |
+ Voir la fiche → |
|
94 |
+ </a> |
| 67 |
95 |
</div> |
| 68 |
|
− <div className="mv-pop-title">{p.address ?? extra.title ?? ""}</div> |
| 69 |
|
− <div className="mv-pop-meta">{meta}</div> |
| 70 |
|
− <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div> |
| 71 |
|
− <a |
| 72 |
|
− className="mv-pop-cta" |
| 73 |
|
− href={p.originalUrl} |
| 74 |
|
− onClick={(e) => { |
| 75 |
|
− e.preventDefault(); |
| 76 |
|
− navigate(p.originalUrl ?? "/"); |
| 77 |
|
− }} |
| 78 |
|
− > |
| 79 |
|
− Voir la fiche → |
| 80 |
|
− </a> |
| 81 |
96 |
</div> |
| 82 |
|
− </div> |
|
97 |
+ </> |
| 83 |
98 |
); |
| 84 |
99 |
} |
| 85 |
100 |
|
| 86 |
|
−/** Pont déclaratif : reflète la sélection/le survol venus de la liste |
| 87 |
|
− (même mécanique que Lou-Ka Maps). */ |
| 88 |
|
−function SelectionBridge({ selectedUid, hoveredUid }: { |
| 89 |
|
− selectedUid: string | null; |
| 90 |
|
− hoveredUid: string | null; |
| 91 |
|
−}) { |
|
101 |
+/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */ |
|
102 |
+function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) { |
| 92 |
103 |
const map = useKaMap(); |
| 93 |
|
− useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]); |
| 94 |
|
− useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]); |
|
104 |
+ useEffect(() => { |
|
105 |
+ onEngine(map); |
|
106 |
+ return () => onEngine(null); |
|
107 |
+ }, [map, onEngine]); |
|
108 |
+ return null; |
|
109 |
+} |
|
110 |
+ |
|
111 |
+/** Mobile : la sélection d'un marqueur replie le sheet en mini. */ |
|
112 |
+function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) { |
|
113 |
+ const shell = useKaShell(); |
|
114 |
+ const shellRef = useRef(shell); |
|
115 |
+ shellRef.current = shell; |
|
116 |
+ useEffect(() => { |
|
117 |
+ const s = shellRef.current; |
|
118 |
+ if (selectedUid && s?.isMobile) s.setSheet("mini"); |
|
119 |
+ }, [selectedUid]); |
| 95 |
120 |
return null; |
| 96 |
121 |
} |
| 97 |
122 |
|
| 98 |
123 |
export interface MapViewProps { |
| 99 |
124 |
filters: ListingFilters; |
| 100 |
|
− selectedUid?: string | null; |
| 101 |
|
− hoveredUid?: string | null; |
| 102 |
|
− onSelect?: (uid: string | null) => void; |
|
125 |
+ /** Page de liste courante (12 annonces) — volet résultats du shell. */ |
|
126 |
+ listings: Listing[] | null; |
|
127 |
+ total: number; |
|
128 |
+ page: number; |
|
129 |
+ totalPages: number; |
|
130 |
+ onPage: (p: number) => void; |
|
131 |
+ sort: string; |
|
132 |
+ onSort: (s: string) => void; |
|
133 |
+ onExit?: () => void; |
|
134 |
+ onOpenFilters?: () => void; |
|
135 |
+ filtersCount?: number; |
| 103 |
136 |
} |
| 104 |
137 |
|
| 105 |
138 |
export default function MapView({ |
| 106 |
|
− filters, selectedUid = null, hoveredUid = null, onSelect, |
|
139 |
+ filters, listings, total, page, totalPages, onPage, sort, onSort, |
|
140 |
+ onExit, onOpenFilters, filtersCount = 0, |
| 107 |
141 |
}: MapViewProps) { |
| 108 |
142 |
// caméra initiale : URL partagée (?lat&lng&zoom) sinon Québec |
| 109 |
143 |
const initialCamera = useMemo(() => { |
| 111 |
145 |
return cam ?? { ...QUEBEC_CENTER, zoom: 11 }; |
| 112 |
146 |
}, []); |
| 113 |
147 |
|
|
148 |
+ const [selectedUid, setSelectedUid] = useState<string | null>(null); |
|
149 |
+ const [mapCount, setMapCount] = useState<number | null>(null); |
|
150 |
+ const [hasZone, setHasZone] = useState(false); |
|
151 |
+ const [isMobile, setIsMobile] = useState( |
|
152 |
+ () => window.matchMedia("(max-width: 780px)").matches); |
|
153 |
+ const engineRef = useRef<KaMap | null>(null); |
|
154 |
+ const listRef = useRef<HTMLDivElement | null>(null); |
|
155 |
+ |
|
156 |
+ useEffect(() => { |
|
157 |
+ const mq = window.matchMedia("(max-width: 780px)"); |
|
158 |
+ const update = () => setIsMobile(mq.matches); |
|
159 |
+ mq.addEventListener("change", update); |
|
160 |
+ return () => mq.removeEventListener("change", update); |
|
161 |
+ }, []); |
|
162 |
+ |
| 114 |
163 |
// caméra → URL (replaceState : pas de re-render du routeur) |
| 115 |
164 |
const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => { |
| 116 |
165 |
const url = new URL(window.location.href); |
| 123 |
172 |
[filters], |
| 124 |
173 |
); |
| 125 |
174 |
|
|
175 |
+ const handleEngine = useCallback((m: KaMap | null) => { |
|
176 |
+ engineRef.current = m; |
|
177 |
+ }, []); |
|
178 |
+ |
|
179 |
+ // sélection sur la carte : fiche contextuelle + carte de liste défilée |
|
180 |
+ const onSelect = useCallback((p: MapProperty | null) => { |
|
181 |
+ const uid = p?.id ?? null; |
|
182 |
+ setSelectedUid(uid); |
|
183 |
+ if (!uid) return; |
|
184 |
+ const card = listRef.current?.querySelector<HTMLElement>( |
|
185 |
+ `[data-uid="${CSS.escape(uid)}"]`); |
|
186 |
+ card?.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
|
187 |
+ }, []); |
|
188 |
+ |
|
189 |
+ // zone dessinée : découpe CÔTÉ CLIENT du jeu affiché (l'API Immo-Ka ne |
|
190 |
+ // filtre pas par polygone) — le compte du CTA vient de l'événement data. |
|
191 |
+ const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => { |
|
192 |
+ if (drawing) return; |
|
193 |
+ setHasZone(polygon !== null); |
|
194 |
+ engineRef.current?.setClipPolygon(polygon); |
|
195 |
+ }, []); |
|
196 |
+ |
|
197 |
+ const listHeader = ( |
|
198 |
+ <div className="ms2-head"> |
|
199 |
+ <div className="ms2-count" role="status" aria-live="polite"> |
|
200 |
+ <b>{total.toLocaleString("fr-CA")}</b> |
|
201 |
+ {" "}propriété{total > 1 ? "s" : ""} |
|
202 |
+ {mapCount != null && hasZone && ( |
|
203 |
+ <span className="ms2-zone">{mapCount.toLocaleString("fr-CA")} dans la zone</span> |
|
204 |
+ )} |
|
205 |
+ </div> |
|
206 |
+ <label className="ms2-sort"> |
|
207 |
+ <select |
|
208 |
+ value={sort} |
|
209 |
+ onChange={(e) => onSort(e.target.value)} |
|
210 |
+ aria-label="Trier les résultats" |
|
211 |
+ > |
|
212 |
+ <option value="recent">Plus récents</option> |
|
213 |
+ <option value="price_asc">Prix croissant</option> |
|
214 |
+ <option value="price_desc">Prix décroissant</option> |
|
215 |
+ </select> |
|
216 |
+ </label> |
|
217 |
+ </div> |
|
218 |
+ ); |
|
219 |
+ |
|
220 |
+ const listPane = ( |
|
221 |
+ <div className="ms2-list" aria-label="Résultats en liste" ref={listRef}> |
|
222 |
+ {(listings ?? []).map((l) => ( |
|
223 |
+ <div |
|
224 |
+ key={l.uid} |
|
225 |
+ data-uid={l.uid} |
|
226 |
+ className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`} |
|
227 |
+ onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} |
|
228 |
+ onMouseLeave={() => engineRef.current?.setHovered(null, "app")} |
|
229 |
+ > |
|
230 |
+ <ListingCard l={l} /> |
|
231 |
+ </div> |
|
232 |
+ ))} |
|
233 |
+ {listings !== null && listings.length === 0 && ( |
|
234 |
+ <div className="ms-empty" role="status"> |
|
235 |
+ <h3>Aucune propriété ne correspond</h3> |
|
236 |
+ <p>Essayez d'élargir vos critères.</p> |
|
237 |
+ </div> |
|
238 |
+ )} |
|
239 |
+ {totalPages > 1 && ( |
|
240 |
+ <nav className="pager ms2-pager" aria-label="Pagination"> |
|
241 |
+ <button className="pager-btn" onClick={() => { |
|
242 |
+ onPage(page - 1); |
|
243 |
+ listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" }); |
|
244 |
+ }} disabled={page <= 1}>‹ Préc.</button> |
|
245 |
+ <span className="pager-info">Page {page} / {totalPages}</span> |
|
246 |
+ <button className="pager-btn" onClick={() => { |
|
247 |
+ onPage(page + 1); |
|
248 |
+ listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" }); |
|
249 |
+ }} disabled={page >= totalPages}>Suiv. ›</button> |
|
250 |
+ </nav> |
|
251 |
+ )} |
|
252 |
+ </div> |
|
253 |
+ ); |
|
254 |
+ |
| 126 |
255 |
return ( |
| 127 |
|
− <div className="mapview" role="application" aria-label="Carte des propriétés"> |
|
256 |
+ <KaMapShell |
|
257 |
+ className="ms2" |
|
258 |
+ brand={<span className="ms2-brand"><b>Immo·Ka</b><span>Carte</span></span>} |
|
259 |
+ onExit={onExit} |
|
260 |
+ exitLabel="Liste" |
|
261 |
+ storageKey="immoka-map-split" |
|
262 |
+ listHeader={listHeader} |
|
263 |
+ list={listPane} |
|
264 |
+ topExtras={ |
|
265 |
+ onOpenFilters ? ( |
|
266 |
+ <button className="ka-top-btn" onClick={onOpenFilters}> |
|
267 |
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true"> |
|
268 |
+ <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" /> |
|
269 |
+ </svg> |
|
270 |
+ Filtres |
|
271 |
+ {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>} |
|
272 |
+ </button> |
|
273 |
+ ) : null |
|
274 |
+ } |
|
275 |
+ > |
| 128 |
276 |
<KaMapView |
| 129 |
277 |
theme={immoKaMapTheme} |
| 130 |
278 |
adapter={immoKaMapAdapter} |
| 138 |
286 |
basemap={{ theme: "default", showLandmarks: true }} |
| 139 |
287 |
// valueClamp : borne la contribution de chaque PRIX DE VENTE à la |
| 140 |
288 |
// bulle de cluster (un manoir à 20 M$ ne fausse pas la moyenne) |
| 141 |
|
− cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000] }} |
|
289 |
+ cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }} |
| 142 |
290 |
searchMode="manual" |
|
291 |
+ navControl={false} |
| 143 |
292 |
onMoveEnd={onMoveEnd} |
| 144 |
|
− onSelect={(p) => onSelect?.(p?.id ?? null)} |
|
293 |
+ onSelect={onSelect} |
|
294 |
+ onData={(count) => setMapCount(count)} |
|
295 |
+ onDraw={onDraw} |
| 145 |
296 |
> |
| 146 |
|
− <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} /> |
|
297 |
+ <EngineBridge onEngine={handleEngine} /> |
|
298 |
+ <SheetAutoCollapse selectedUid={selectedUid} /> |
| 147 |
299 |
<KaBrandBadge /> |
| 148 |
300 |
<MetroLignes /> |
| 149 |
|
− <Tilt3DControl /> |
|
301 |
+ |
|
302 |
+ {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */} |
|
303 |
+ <KaMapToolbar> |
|
304 |
+ {!isMobile && ( |
|
305 |
+ <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup> |
|
306 |
+ )} |
|
307 |
+ <KaToolbarGroup> |
|
308 |
+ <KaToolbar3D /> |
|
309 |
+ <KaToolbarDraw /> |
|
310 |
+ <KaToolbarLocate /> |
|
311 |
+ </KaToolbarGroup> |
|
312 |
+ </KaMapToolbar> |
|
313 |
+ |
|
314 |
+ {/* mode dessin : bandeau temporaire + CTA « N propriétés » */} |
|
315 |
+ <KaDrawAreaMode |
|
316 |
+ formatCount={(n) => `${n.toLocaleString("fr-CA")} propriété${n > 1 ? "s" : ""}`} |
|
317 |
+ onClear={() => engineRef.current?.setClipPolygon(null)} |
|
318 |
+ /> |
|
319 |
+ |
| 150 |
320 |
<SearchAreaControl /> |
| 151 |
321 |
<LoadingIndicator label="Mise à jour des propriétés…" /> |
| 152 |
|
− <ResultCount |
| 153 |
|
− emptyTitle="Aucune propriété trouvée dans cette zone." |
| 154 |
|
− emptyHint="Élargissez la carte ou modifiez vos filtres." |
| 155 |
|
− /> |
| 156 |
|
− <PropertyPreview render={(p) => <PreviewCard p={p} />} /> |
|
322 |
+ <KaPropertyPreview render={(p) => <PreviewCard p={p} />} /> |
| 157 |
323 |
</KaMapView> |
| 158 |
|
− </div> |
|
324 |
+ </KaMapShell> |
| 159 |
325 |
); |
| 160 |
326 |
} |