Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Groupe Ka / Ka Maps (House-Ka integration)5// components/MapView.tsx : the House-Ka MAP MODE (Ka Map System v2) — same6// architecture as Lou-Ka/Immo-Ka Maps, themed pine/cream/ink.7// · viewport takeover (KaMapShell): mobile edge-to-edge + 3-notch results8// bottom sheet, desktop resizable list|map split with near-fullscreen map;9// · viewport-driven data: /api/listings.geojson?bbox=…10// (Ka Maps adapter — cancellable requests, never a request storm);11// · unified toolbar (zoom, 3D, draw, locate me), contextual "Search this12// area", drawn area clipped CLIENT-SIDE (setClipPolygon) with the13// "N homes in this area" CTA;14// · contextual preview card v2: photo, price, swipe/chevrons between15// neighbouring properties.16// -----------------------------------------------------------------------------17import { useCallback, useEffect, useMemo, useRef, useState } from "react";18import { useNavigate } from "react-router-dom";19import "mapbox-gl/dist/mapbox-gl.css";20import "@groupe-ka/ka-maps/styles.css";21import type { KaMap, MapProperty } from "@groupe-ka/ka-maps";22import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";23import {24 KaBrandBadge,25 KaDrawAreaMode,26 KaMapShell,27 KaMapToolbar,28 KaMapView,29 KaPropertyPreview,30 KaToolbar3D,31 KaToolbarDraw,32 KaToolbarGroup,33 KaToolbarLocate,34 KaToolbarZoom,35 LoadingIndicator,36 SearchAreaControl,37 useKaMap,38 useKaShell,39} from "@groupe-ka/ka-maps/react";40import { Listing, ListingFilters, sourceName } from "../api";41import ListingCard from "./ListingCard";42import { houseKaMapTheme } from "../kamaps/theme";43import { houseKaMapAdapter } from "../kamaps/adapter";44import { MAPBOX_TOKEN } from "../kamaps/config";4546// Ontario first: open on Toronto (shared URLs override via ?lat&lng&zoom)47const DEFAULT_CENTER = { lat: 43.68, lng: -79.4 };4849/** Preview card — Ka Map System ka-prevcard structure, House-Ka content50 * (the adapter already carries photo/price/traits: no extra fetch). */51function PreviewCard({ p }: { p: MapProperty }) {52 const navigate = useNavigate();53 const extra = (p.extra ?? {}) as {54 title?: string | null; priceLabel?: string | null;55 source?: string;56 };57 const price = p.price != null58 ? "$" + p.price.toLocaleString("en-CA", { maximumFractionDigits: 0 })59 : extra.priceLabel || "Price on request";60 const meta = [61 p.propertyType,62 p.bedrooms != null ? `${p.bedrooms} bed` : "",63 p.bathrooms != null ? `${p.bathrooms} bath` : "",64 extra.source ? sourceName(extra.source) : "",65 ].filter(Boolean).join(" · ");66 const fiche = p.originalUrl ?? "/";67 return (68 <>69 <div className="ka-prevcard-media">70 {p.thumbnailUrl ? (71 <img72 src={p.thumbnailUrl} alt="" loading="lazy"73 onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}74 />75 ) : (76 <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>77 )}78 </div>79 <div className="ka-prevcard-body">80 <div className="ka-prevcard-price">{price}</div>81 <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div>82 <div className="ka-prevcard-meta">{meta}</div>83 <div className="ka-prevcard-actions">84 <a85 className="ka-prevcard-cta"86 href={fiche}87 onClick={(e) => { e.preventDefault(); navigate(fiche); }}88 >89 See the listing →90 </a>91 </div>92 </div>93 </>94 );95}9697/** Bridge: exposes the KaMap engine to the parent component (outside canvas). */98function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {99 const map = useKaMap();100 useEffect(() => {101 onEngine(map);102 return () => onEngine(null);103 }, [map, onEngine]);104 return null;105}106107/** Mobile: selecting a marker collapses the sheet to mini. */108function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {109 const shell = useKaShell();110 const shellRef = useRef(shell);111 shellRef.current = shell;112 useEffect(() => {113 const s = shellRef.current;114 if (selectedUid && s?.isMobile) s.setSheet("mini");115 }, [selectedUid]);116 return null;117}118119export interface MapViewProps {120 filters: ListingFilters;121 /** Current list page (12 listings) — the shell's results pane. */122 listings: Listing[] | null;123 total: number;124 page: number;125 totalPages: number;126 onPage: (p: number) => void;127 sort: string;128 onSort: (s: string) => void;129 onExit?: () => void;130 onOpenFilters?: () => void;131 filtersCount?: number;132}133134export default function MapView({135 filters, listings, total, page, totalPages, onPage, sort, onSort,136 onExit, onOpenFilters, filtersCount = 0,137}: MapViewProps) {138 // initial camera: shared URL (?lat&lng&zoom) otherwise Toronto139 const initialCamera = useMemo(() => {140 const cam = cameraFromParams(new URLSearchParams(window.location.search));141 return cam ?? { ...DEFAULT_CENTER, zoom: 10 };142 }, []);143144 const [selectedUid, setSelectedUid] = useState<string | null>(null);145 const [mapCount, setMapCount] = useState<number | null>(null);146 const [hasZone, setHasZone] = useState(false);147 const [isMobile, setIsMobile] = useState(148 () => window.matchMedia("(max-width: 780px)").matches);149 const engineRef = useRef<KaMap | null>(null);150 const listRef = useRef<HTMLDivElement | null>(null);151152 useEffect(() => {153 const mq = window.matchMedia("(max-width: 780px)");154 const update = () => setIsMobile(mq.matches);155 mq.addEventListener("change", update);156 return () => mq.removeEventListener("change", update);157 }, []);158159 // camera → URL (replaceState: no router re-render)160 const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {161 const url = new URL(window.location.href);162 url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();163 window.history.replaceState(null, "", url);164 }, []);165166 const mapFilters = useMemo(167 () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),168 [filters],169 );170171 const handleEngine = useCallback((m: KaMap | null) => {172 engineRef.current = m;173 }, []);174175 // map selection: preview card + list card scrolled into view176 const onSelect = useCallback((p: MapProperty | null) => {177 const uid = p?.id ?? null;178 setSelectedUid(uid);179 if (!uid) return;180 const card = listRef.current?.querySelector<HTMLElement>(181 `[data-uid="${CSS.escape(uid)}"]`);182 card?.scrollIntoView({ behavior: "smooth", block: "nearest" });183 }, []);184185 // drawn area: CLIENT-SIDE clip of the displayed set (the House-Ka API does186 // not filter by polygon) — the CTA count comes from the data event.187 const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => {188 if (drawing) return;189 setHasZone(polygon !== null);190 engineRef.current?.setClipPolygon(polygon);191 }, []);192193 const listHeader = (194 <div className="ms2-head">195 <div className="ms2-count" role="status" aria-live="polite">196 <b>{total.toLocaleString("en-CA")}</b>197 {" "}home{total > 1 ? "s" : ""}198 {mapCount != null && hasZone && (199 <span className="ms2-zone">{mapCount.toLocaleString("en-CA")} in the area</span>200 )}201 </div>202 <label className="ms2-sort">203 <select204 value={sort}205 onChange={(e) => onSort(e.target.value)}206 aria-label="Sort the results"207 >208 <option value="recent">Newest</option>209 <option value="price_asc">Price: low to high</option>210 <option value="price_desc">Price: high to low</option>211 </select>212 </label>213 </div>214 );215216 const listPane = (217 <div className="ms2-list" aria-label="List results" ref={listRef}>218 {(listings ?? []).map((l) => (219 <div220 key={l.uid}221 data-uid={l.uid}222 className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}223 onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}224 onMouseLeave={() => engineRef.current?.setHovered(null, "app")}225 >226 <ListingCard l={l} />227 </div>228 ))}229 {listings !== null && listings.length === 0 && (230 <div className="ms-empty" role="status">231 <h3>No home matches</h3>232 <p>Try widening your criteria.</p>233 </div>234 )}235 {totalPages > 1 && (236 <nav className="pager ms2-pager" aria-label="Pagination">237 <button className="pager-btn" onClick={() => {238 onPage(page - 1);239 listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });240 }} disabled={page <= 1}>‹ Prev</button>241 <span className="pager-info">Page {page} / {totalPages}</span>242 <button className="pager-btn" onClick={() => {243 onPage(page + 1);244 listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });245 }} disabled={page >= totalPages}>Next ›</button>246 </nav>247 )}248 </div>249 );250251 return (252 <KaMapShell253 className="ms2"254 brand={<span className="ms2-brand"><b>House·Ka</b><span>Map</span></span>}255 onExit={onExit}256 exitLabel="List"257 storageKey="houseka-map-split"258 listHeader={listHeader}259 list={listPane}260 topExtras={261 onOpenFilters ? (262 <button className="ka-top-btn" onClick={onOpenFilters}>263 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">264 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />265 </svg>266 Filters267 {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}268 </button>269 ) : null270 }271 >272 <KaMapView273 theme={houseKaMapTheme}274 adapter={houseKaMapAdapter}275 mapboxToken={MAPBOX_TOKEN}276 filters={mapFilters}277 center={initialCamera}278 zoom={initialCamera.zoom}279 pitch={50}280 // Realistic rendering: full-colour Standard + 3D landmarks — same281 // settings as Lou-Ka Maps.282 basemap={{ theme: "default", showLandmarks: true }}283 // valueClamp: caps each ASKING PRICE's contribution to the cluster284 // bubble (a $20M mansion doesn't skew the average)285 cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }}286 searchMode="manual"287 navControl={false}288 onMoveEnd={onMoveEnd}289 onSelect={onSelect}290 onData={(count) => setMapCount(count)}291 onDraw={onDraw}292 >293 <EngineBridge onEngine={handleEngine} />294 <SheetAutoCollapse selectedUid={selectedUid} />295 <KaBrandBadge />296297 {/* THE control cluster — zoom (desktop), 3D, draw, locate */}298 <KaMapToolbar>299 {!isMobile && (300 <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>301 )}302 <KaToolbarGroup>303 <KaToolbar3D />304 <KaToolbarDraw />305 <KaToolbarLocate />306 </KaToolbarGroup>307 </KaMapToolbar>308309 {/* draw mode: temporary banner + "N homes" CTA */}310 <KaDrawAreaMode311 formatCount={(n) => `${n.toLocaleString("en-CA")} home${n > 1 ? "s" : ""}`}312 onClear={() => engineRef.current?.setClipPolygon(null)}313 />314315 <SearchAreaControl />316 <LoadingIndicator label="Updating the homes…" />317 <KaPropertyPreview render={(p) => <PreviewCard p={p} />} />318 </KaMapView>319 </KaMapShell>320 );321}322