Python 49.6%
TypeScript 25.5%
CSS 24.1%
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Groupe Ka / Ka Maps (Home-Ka integration)5// components/MapView.tsx : the Home-Ka MAP MODE (Ka Map System v2) — same6// architecture as Lou-Ka Maps, themed green/off-white/ink.7// · viewport takeover (KaMapShell): mobile edge-to-edge + 3-notch results8// bottom sheet, desktop resizable list|map split;9// · viewport-driven data: /api/listings.geojson?bbox=… (Ka Maps adapter —10// cancellable requests, never a storm);11// · unified toolbar (zoom, 3D, draw, locate), contextual "Search this12// area", drawn zone clipped CLIENT-SIDE (setClipPolygon) with a13// "N homes in this area" CTA;14// · contextual preview card v2: photo, price, swipe/chevrons between15// neighboring homes.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 { homeKaMapTheme } from "../kamaps/theme";43import { homeKaMapAdapter } from "../kamaps/adapter";44import { MAPBOX_TOKEN } from "../kamaps/config";4546// Continental US — initial camera47const US_CENTER = { lat: 39.81, lng: -98.55 };48const US_ZOOM = 4;4950/** Preview card — Ka Map System ka-prevcard structure, Home-Ka content51 * (the adapter already carries photo/price/traits: no extra fetch). */52function PreviewCard({ p }: { p: MapProperty }) {53 const navigate = useNavigate();54 const extra = (p.extra ?? {}) as {55 title?: string | null; priceLabel?: string | null;56 source?: string; state?: string | null;57 };58 const price = p.price != null59 ? `$${p.price.toLocaleString("en-US", { maximumFractionDigits: 0 })}`60 : extra.priceLabel || "Price on request";61 const meta = [62 p.propertyType,63 p.bedrooms != null ? `${p.bedrooms} bd` : "",64 p.bathrooms != null ? `${p.bathrooms} ba` : "",65 [p.city, extra.state].filter(Boolean).join(", "),66 extra.source ? sourceName(extra.source) : "",67 ].filter(Boolean).join(" · ");68 const detail = p.originalUrl ?? "/";69 return (70 <>71 <div className="ka-prevcard-media">72 {p.thumbnailUrl ? (73 <img74 src={p.thumbnailUrl} alt="" loading="lazy"75 onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}76 />77 ) : (78 <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>79 )}80 </div>81 <div className="ka-prevcard-body">82 <div className="ka-prevcard-price">{price}</div>83 <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div>84 <div className="ka-prevcard-meta">{meta}</div>85 <div className="ka-prevcard-actions">86 <a87 className="ka-prevcard-cta"88 href={detail}89 onClick={(e) => { e.preventDefault(); navigate(detail); }}90 >91 View details →92 </a>93 </div>94 </div>95 </>96 );97}9899/** Bridge: exposes the KaMap engine to the parent component (off-canvas). */100function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {101 const map = useKaMap();102 useEffect(() => {103 onEngine(map);104 return () => onEngine(null);105 }, [map, onEngine]);106 return null;107}108109/** Mobile: selecting a marker collapses the sheet to mini. */110function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {111 const shell = useKaShell();112 const shellRef = useRef(shell);113 shellRef.current = shell;114 useEffect(() => {115 const s = shellRef.current;116 if (selectedUid && s?.isMobile) s.setSheet("mini");117 }, [selectedUid]);118 return null;119}120121export interface MapViewProps {122 filters: ListingFilters;123 /** Current list page (12 listings) — results panel of the shell. */124 listings: Listing[] | null;125 total: number;126 page: number;127 totalPages: number;128 onPage: (p: number) => void;129 sort: string;130 onSort: (s: string) => void;131 onExit?: () => void;132 onOpenFilters?: () => void;133 filtersCount?: number;134}135136export default function MapView({137 filters, listings, total, page, totalPages, onPage, sort, onSort,138 onExit, onOpenFilters, filtersCount = 0,139}: MapViewProps) {140 // initial camera: shared URL (?lat&lng&zoom), otherwise continental US141 const initialCamera = useMemo(() => {142 const cam = cameraFromParams(new URLSearchParams(window.location.search));143 return cam ?? { ...US_CENTER, zoom: US_ZOOM };144 }, []);145146 const [selectedUid, setSelectedUid] = useState<string | null>(null);147 const [mapCount, setMapCount] = useState<number | null>(null);148 const [hasZone, setHasZone] = useState(false);149 const [isMobile, setIsMobile] = useState(150 () => window.matchMedia("(max-width: 780px)").matches);151 const engineRef = useRef<KaMap | null>(null);152 const listRef = useRef<HTMLDivElement | null>(null);153154 useEffect(() => {155 const mq = window.matchMedia("(max-width: 780px)");156 const update = () => setIsMobile(mq.matches);157 mq.addEventListener("change", update);158 return () => mq.removeEventListener("change", update);159 }, []);160161 // camera → URL (replaceState: no router re-render)162 const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {163 const url = new URL(window.location.href);164 url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();165 window.history.replaceState(null, "", url);166 }, []);167168 const mapFilters = useMemo(169 () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),170 [filters],171 );172173 const handleEngine = useCallback((m: KaMap | null) => {174 engineRef.current = m;175 }, []);176177 // map selection: preview card + list card scrolled into view178 const onSelect = useCallback((p: MapProperty | null) => {179 const uid = p?.id ?? null;180 setSelectedUid(uid);181 if (!uid) return;182 const card = listRef.current?.querySelector<HTMLElement>(183 `[data-uid="${CSS.escape(uid)}"]`);184 card?.scrollIntoView({ behavior: "smooth", block: "nearest" });185 }, []);186187 // drawn zone: CLIENT-SIDE clip of the displayed set (the Home-Ka API does188 // not filter by polygon) — the CTA count comes from the data event.189 const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => {190 if (drawing) return;191 setHasZone(polygon !== null);192 engineRef.current?.setClipPolygon(polygon);193 }, []);194195 const listHeader = (196 <div className="ms2-head">197 <div className="ms2-count" role="status" aria-live="polite">198 <b>{total.toLocaleString("en-US")}</b>199 {" "}home{total !== 1 ? "s" : ""}200 {mapCount != null && hasZone && (201 <span className="ms2-zone">{mapCount.toLocaleString("en-US")} in this area</span>202 )}203 </div>204 <label className="ms2-sort">205 <select206 value={sort}207 onChange={(e) => onSort(e.target.value)}208 aria-label="Sort results"209 >210 <option value="recent">Newest</option>211 <option value="price_asc">Price: low to high</option>212 <option value="price_desc">Price: high to low</option>213 </select>214 </label>215 </div>216 );217218 const listPane = (219 <div className="ms2-list" aria-label="List results" ref={listRef}>220 {(listings ?? []).map((l) => (221 <div222 key={l.uid}223 data-uid={l.uid}224 className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}225 onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}226 onMouseLeave={() => engineRef.current?.setHovered(null, "app")}227 >228 <ListingCard l={l} />229 </div>230 ))}231 {listings !== null && listings.length === 0 && (232 <div className="ms-empty" role="status">233 <h3>No homes match your criteria</h3>234 <p>Try widening your search.</p>235 </div>236 )}237 {totalPages > 1 && (238 <nav className="pager ms2-pager" aria-label="Pagination">239 <button className="pager-btn" onClick={() => {240 onPage(page - 1);241 listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });242 }} disabled={page <= 1}>‹ Prev</button>243 <span className="pager-info">Page {page} / {totalPages}</span>244 <button className="pager-btn" onClick={() => {245 onPage(page + 1);246 listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });247 }} disabled={page >= totalPages}>Next ›</button>248 </nav>249 )}250 </div>251 );252253 return (254 <KaMapShell255 className="ms2"256 brand={<span className="ms2-brand"><b>Home·Ka</b><span>Map</span></span>}257 onExit={onExit}258 exitLabel="List"259 storageKey="homeka-map-split"260 listHeader={listHeader}261 list={listPane}262 topExtras={263 onOpenFilters ? (264 <button className="ka-top-btn" onClick={onOpenFilters}>265 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">266 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />267 </svg>268 Filters269 {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}270 </button>271 ) : null272 }273 >274 <KaMapView275 theme={homeKaMapTheme}276 adapter={homeKaMapAdapter}277 mapboxToken={MAPBOX_TOKEN}278 filters={mapFilters}279 center={initialCamera}280 zoom={initialCamera.zoom}281 pitch={50}282 // Realistic rendering: full-color Standard + 3D landmarks — same283 // settings as Lou-Ka Maps.284 basemap={{ theme: "default", showLandmarks: true }}285 // valueClamp: bounds each LIST PRICE's contribution to the cluster286 // bubble (a $20M mansion does not skew the average)287 cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }}288 searchMode="manual"289 navControl={false}290 onMoveEnd={onMoveEnd}291 onSelect={onSelect}292 onData={(count) => setMapCount(count)}293 onDraw={onDraw}294 >295 <EngineBridge onEngine={handleEngine} />296 <SheetAutoCollapse selectedUid={selectedUid} />297 <KaBrandBadge />298299 {/* THE control cluster — zoom (desktop), 3D, draw, locate */}300 <KaMapToolbar>301 {!isMobile && (302 <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>303 )}304 <KaToolbarGroup>305 <KaToolbar3D />306 <KaToolbarDraw />307 <KaToolbarLocate />308 </KaToolbarGroup>309 </KaMapToolbar>310311 {/* draw mode: temporary banner + "N homes" CTA */}312 <KaDrawAreaMode313 formatCount={(n) => `${n.toLocaleString("en-US")} home${n !== 1 ? "s" : ""}`}314 onClear={() => engineRef.current?.setClipPolygon(null)}315 />316317 <SearchAreaControl />318 <LoadingIndicator label="Updating homes…" />319 <KaPropertyPreview render={(p) => <PreviewCard p={p} />} />320 </KaMapView>321 </KaMapShell>322 );323}324