/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * React bindings. owns the KaMap lifecycle (StrictMode-safe), * exposes it through context, and renders app-provided overlays (previews, * legends, controls) as normal React children — no HTML-string popups. */ import { createContext, useContext, useEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode, } from "react"; import { KaMap, type KaMapOptions } from "../core/KaMap.js"; import type { KaMapMode } from "../theming/tokens.js"; import type { MapProperty } from "../types/index.js"; const KaMapContext = createContext(null); /** Access the KaMap engine from any child of . */ export function useKaMap(): KaMap | null { return useContext(KaMapContext); } export interface KaMapViewProps extends Omit { mode?: KaMapMode; className?: string; style?: CSSProperties; /** Static data path (no adapter): render these properties directly. */ properties?: MapProperty[]; /** App-owned filter payload forwarded to the adapter. */ filters?: Record; onSelect?: (property: MapProperty | null, origin: "map" | "app") => void; onHover?: (property: MapProperty | null) => void; onData?: (count: number, totalCount?: number) => void; onLoading?: (loading: boolean) => void; onSearchAreaDirty?: (dirty: boolean) => void; onMoveEnd?: ( center: { lat: number; lng: number }, zoom: number, byUser: boolean, ) => void; /** Outil de dessin : polygone posé/effacé, ou tracé en cours. */ onDraw?: (polygon: [number, number][] | null, drawing: boolean) => void; /** Survol d'un cluster (fourchette de prix) — null à la sortie. */ onClusterHover?: ( info: { count: number; min: number | null; max: number | null; x: number; y: number } | null, ) => void; children?: ReactNode; } export function KaMapView(props: KaMapViewProps): ReactElement { const containerRef = useRef(null); const [engine, setEngine] = useState(null); // Latest callbacks in refs so the engine effect never re-runs for them. const callbacks = useRef(props); callbacks.current = props; useEffect(() => { const container = containerRef.current; if (!container) return; const p = callbacks.current; const map = new KaMap({ container, theme: p.theme, mapboxToken: p.mapboxToken, mode: p.mode, adapter: p.adapter, basemap: p.basemap, center: p.center, zoom: p.zoom, pitch: p.pitch, minZoom: p.minZoom, maxZoom: p.maxZoom, searchMode: p.searchMode, query: p.query, cooperativeGestures: p.cooperativeGestures, navControl: p.navControl, cluster: p.cluster, }); const offs = [ map.events.on("select", ({ propertyId, origin }) => { callbacks.current.onSelect?.( propertyId ? map.getProperty(propertyId) ?? null : null, origin, ); }), map.events.on("hover", ({ propertyId }) => { callbacks.current.onHover?.( propertyId ? map.getProperty(propertyId) ?? null : null, ); }), map.events.on("data", ({ count, totalCount }) => callbacks.current.onData?.(count, totalCount), ), map.events.on("loading", ({ loading }) => callbacks.current.onLoading?.(loading), ), map.events.on("searchAreaDirty", ({ dirty }) => callbacks.current.onSearchAreaDirty?.(dirty), ), map.events.on("moveend", ({ center, zoom, byUser }) => callbacks.current.onMoveEnd?.(center, zoom, byUser), ), map.events.on("draw", ({ polygon, drawing }) => callbacks.current.onDraw?.(polygon, drawing), ), map.events.on("clusterHover", ({ info }) => callbacks.current.onClusterHover?.(info), ), ]; setEngine(map); return () => { for (const off of offs) off(); setEngine(null); map.destroy(); }; // The engine is created once per mount; theme/adapter swaps remount. // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.theme.id, props.adapter?.id]); // Static data path. useEffect(() => { if (engine && props.properties) engine.setProperties(props.properties); }, [engine, props.properties]); // Filters → adapter refetch. const filtersKey = props.filters ? JSON.stringify(props.filters) : ""; useEffect(() => { if (engine && props.adapter) engine.setFilters(props.filters); // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine, filtersKey]); // Light/dark swaps restyle in place. useEffect(() => { if (engine && props.mode) engine.setMode(props.mode); }, [engine, props.mode]); return (
{engine ? props.children : null}
); }