SPB Git forge

spb/ka-maps

Public
6commits 1branches 0releases
448.0 KBsize
maindefault branch
29 days agolast push
TypeScript 87.7% CSS 12.3%
6.0 KB · 178 lines tsx
Raw Blame History
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * KaPropertyPreview — la fiche contextuelle v2. Tap sur une pastille :7 * pas de navigation, une carte élégante — photo, prix, adresse, traits8 * principaux, lien fiche (contenu rendu par l'app, render prop).9 *   · mobile  : bottom card flottante, glissement horizontal pour passer10 *     aux propriétés voisines (l'ordre de proximité est figé à l'ouverture) ;11 *   · desktop : carte flottante ancrée en bas à gauche de la carte ;12 *   · chevrons ‹ › des deux côtés, Échap/✕ pour fermer.13 */1415import {16  useEffect,17  useRef,18  useState,19  type ReactElement,20  type ReactNode,21} from "react";22import type { MapProperty } from "../types/index.js";23import { haversineMeters } from "../utils/geo.js";24import { useKaMap } from "./KaMapView.js";2526export interface KaPropertyPreviewProps {27  /** Rendu du CONTENU (photo, prix, traits, favori…) — app-owned. */28  render: (property: MapProperty, close: () => void) => ReactNode;29  /** Navigation ‹ › + swipe entre propriétés proches. Défaut : activée. */30  withNav?: boolean;31  mobileBreakpoint?: number;32  closeLabel?: string;33}3435export function KaPropertyPreview(props: KaPropertyPreviewProps): ReactElement | null {36  const map = useKaMap();37  const [property, setProperty] = useState<MapProperty | null>(null);38  const [mobile, setMobile] = useState(false);39  const [slide, setSlide] = useState<"left" | "right" | null>(null);40  const breakpoint = props.mobileBreakpoint ?? 780;4142  /** Ordre de navigation figé quand la fiche s'ouvre : propriétés triées43   *  par distance de l'élément sélectionné (les « voisines » d'abord). */44  const orderRef = useRef<string[]>([]);45  const touchRef = useRef<{ x: number; y: number } | null>(null);4647  useEffect(() => {48    if (!map) return;49    return map.events.on("select", ({ propertyId }) => {50      const p = propertyId ? map.getProperty(propertyId) ?? null : null;51      setProperty((previous) => {52        if (p && !previous) {53          // ouverture : figer l'ordre de proximité autour de p54          orderRef.current = map55            .getVisibleProperties()56            .map((item) => ({57              id: item.id,58              d: haversineMeters(p.latitude, p.longitude, item.latitude, item.longitude),59            }))60            .sort((a, b) => a.d - b.d)61            .map((item) => item.id);62        }63        if (!p) orderRef.current = [];64        return p;65      });66    });67  }, [map]);6869  useEffect(() => {70    const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);71    const update = () => setMobile(mq.matches);72    update();73    mq.addEventListener("change", update);74    return () => mq.removeEventListener("change", update);75  }, [breakpoint]);7677  useEffect(() => {78    if (!property || !map) return;79    const onKey = (e: KeyboardEvent) => {80      if (e.key === "Escape") map.select(null, "app");81      if (e.key === "ArrowRight") step(1);82      if (e.key === "ArrowLeft") step(-1);83    };84    window.addEventListener("keydown", onKey);85    return () => window.removeEventListener("keydown", onKey);86    // eslint-disable-next-line react-hooks/exhaustive-deps87  }, [property, map]);8889  if (!map || !property) return null;9091  const close = () => map.select(null, "app");9293  const order = orderRef.current;94  const index = order.indexOf(property.id);95  const canNav = props.withNav !== false && order.length > 1 && index !== -1;9697  const step = (dir: 1 | -1) => {98    if (!canNav) return;99    const next = order[(index + dir + order.length) % order.length];100    if (!next) return;101    setSlide(dir === 1 ? "left" : "right");102    map.select(next, "app");103  };104105  const onTouchStart = (e: React.TouchEvent) => {106    const t = e.touches[0];107    if (t) touchRef.current = { x: t.clientX, y: t.clientY };108  };109  const onTouchEnd = (e: React.TouchEvent) => {110    const start = touchRef.current;111    touchRef.current = null;112    const t = e.changedTouches[0];113    if (!start || !t) return;114    const dx = t.clientX - start.x;115    const dy = t.clientY - start.y;116    if (Math.abs(dx) > 52 && Math.abs(dx) > Math.abs(dy) * 1.4) {117      step(dx < 0 ? 1 : -1);118    }119  };120121  return (122    <div123      className={`ka-prev ${mobile ? "ka-prev-mobile" : "ka-prev-desktop"}`}124      role="dialog"125      aria-label={property.address ?? "Propriété sélectionnée"}126      onTouchStart={onTouchStart}127      onTouchEnd={onTouchEnd}128    >129      <button130        type="button"131        className="ka-prev-close"132        onClick={close}133        aria-label={props.closeLabel ?? "Fermer"}134      >135        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" aria-hidden="true">136          <path d="M18 6L6 18M6 6l12 12" />137        </svg>138      </button>139      {canNav ? (140        <>141          <button142            type="button"143            className="ka-prev-nav ka-prev-prev"144            onClick={() => step(-1)}145            aria-label="Propriété précédente"146          >147            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">148              <path d="M15 18l-6-6 6-6" />149            </svg>150          </button>151          <button152            type="button"153            className="ka-prev-nav ka-prev-next"154            onClick={() => step(1)}155            aria-label="Propriété suivante"156          >157            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">158              <path d="M9 6l6 6-6 6" />159            </svg>160          </button>161        </>162      ) : null}163      <div164        key={property.id}165        className={`ka-prev-card${slide ? ` ka-slide-${slide}` : ""}`}166        onAnimationEnd={() => setSlide(null)}167      >168        {props.render(property, close)}169      </div>170      {canNav ? (171        <div className="ka-prev-pos" aria-hidden="true">172          {index + 1} / {order.length}173        </div>174      ) : null}175    </div>176  );177}178