/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * KaPropertyPreview — la fiche contextuelle v2. Tap sur une pastille : * pas de navigation, une carte élégante — photo, prix, adresse, traits * principaux, lien fiche (contenu rendu par l'app, render prop). * · mobile : bottom card flottante, glissement horizontal pour passer * aux propriétés voisines (l'ordre de proximité est figé à l'ouverture) ; * · desktop : carte flottante ancrée en bas à gauche de la carte ; * · chevrons ‹ › des deux côtés, Échap/✕ pour fermer. */ import { useEffect, useRef, useState, type ReactElement, type ReactNode, } from "react"; import type { MapProperty } from "../types/index.js"; import { haversineMeters } from "../utils/geo.js"; import { useKaMap } from "./KaMapView.js"; export interface KaPropertyPreviewProps { /** Rendu du CONTENU (photo, prix, traits, favori…) — app-owned. */ render: (property: MapProperty, close: () => void) => ReactNode; /** Navigation ‹ › + swipe entre propriétés proches. Défaut : activée. */ withNav?: boolean; mobileBreakpoint?: number; closeLabel?: string; } export function KaPropertyPreview(props: KaPropertyPreviewProps): ReactElement | null { const map = useKaMap(); const [property, setProperty] = useState(null); const [mobile, setMobile] = useState(false); const [slide, setSlide] = useState<"left" | "right" | null>(null); const breakpoint = props.mobileBreakpoint ?? 780; /** Ordre de navigation figé quand la fiche s'ouvre : propriétés triées * par distance de l'élément sélectionné (les « voisines » d'abord). */ const orderRef = useRef([]); const touchRef = useRef<{ x: number; y: number } | null>(null); useEffect(() => { if (!map) return; return map.events.on("select", ({ propertyId }) => { const p = propertyId ? map.getProperty(propertyId) ?? null : null; setProperty((previous) => { if (p && !previous) { // ouverture : figer l'ordre de proximité autour de p orderRef.current = map .getVisibleProperties() .map((item) => ({ id: item.id, d: haversineMeters(p.latitude, p.longitude, item.latitude, item.longitude), })) .sort((a, b) => a.d - b.d) .map((item) => item.id); } if (!p) orderRef.current = []; return p; }); }); }, [map]); useEffect(() => { const mq = window.matchMedia(`(max-width: ${breakpoint}px)`); const update = () => setMobile(mq.matches); update(); mq.addEventListener("change", update); return () => mq.removeEventListener("change", update); }, [breakpoint]); useEffect(() => { if (!property || !map) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") map.select(null, "app"); if (e.key === "ArrowRight") step(1); if (e.key === "ArrowLeft") step(-1); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [property, map]); if (!map || !property) return null; const close = () => map.select(null, "app"); const order = orderRef.current; const index = order.indexOf(property.id); const canNav = props.withNav !== false && order.length > 1 && index !== -1; const step = (dir: 1 | -1) => { if (!canNav) return; const next = order[(index + dir + order.length) % order.length]; if (!next) return; setSlide(dir === 1 ? "left" : "right"); map.select(next, "app"); }; const onTouchStart = (e: React.TouchEvent) => { const t = e.touches[0]; if (t) touchRef.current = { x: t.clientX, y: t.clientY }; }; const onTouchEnd = (e: React.TouchEvent) => { const start = touchRef.current; touchRef.current = null; const t = e.changedTouches[0]; if (!start || !t) return; const dx = t.clientX - start.x; const dy = t.clientY - start.y; if (Math.abs(dx) > 52 && Math.abs(dx) > Math.abs(dy) * 1.4) { step(dx < 0 ? 1 : -1); } }; return (
{canNav ? ( <> ) : null}
setSlide(null)} > {props.render(property, close)}
{canNav ? ( ) : null}
); }