SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
11.5 KB · 322 lines tsx
Raw Blame History
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