SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
11.5 KB · 324 lines tsx
Raw Blame History
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