/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * Framework-level React controls, themed by the app's KaMapTheme through * CSS custom properties (see ka-maps.css). Headless enough that each app * keeps its own visual identity via tokens, not forks. */ import { useEffect, useState, type ReactElement, type ReactNode } from "react"; import { useKaMap } from "./KaMapView.js"; /** * "Search this area" — appears when the viewport diverges from the last * searched area (manual search mode), with an optional auto-search toggle. */ export function SearchAreaControl(props: { label?: string; autoLabel?: string; showAutoToggle?: boolean; }): ReactElement | null { const map = useKaMap(); const [dirty, setDirty] = useState(false); const [loading, setLoading] = useState(false); const [auto, setAuto] = useState(map?.getSearchMode() === "auto"); useEffect(() => { if (!map) return; const offs = [ map.events.on("searchAreaDirty", ({ dirty: d }) => setDirty(d)), map.events.on("loading", ({ loading: l }) => setLoading(l)), ]; return () => offs.forEach((off) => off()); }, [map]); if (!map) return null; const toggleAuto = () => { const next = !auto; setAuto(next); map.setSearchMode(next ? "auto" : "manual"); }; return (
{dirty && !auto ? ( ) : null} {props.showAutoToggle !== false ? ( ) : null}
); } /** Subtle updating indicator — keeps previous results visible while loading. */ export function LoadingIndicator(props: { label?: string }): ReactElement | null { const map = useKaMap(); const [loading, setLoading] = useState(false); useEffect(() => { if (!map) return; return map.events.on("loading", ({ loading: l }) => setLoading(l)); }, [map]); if (!loading) return null; return (
); } /** Count chip + empty state, French default. */ export function ResultCount(props: { emptyTitle?: string; emptyHint?: string; }): ReactElement | null { const map = useKaMap(); const [count, setCount] = useState(null); const [total, setTotal] = useState(undefined); useEffect(() => { if (!map) return; return map.events.on("data", ({ count: c, totalCount }) => { setCount(c); setTotal(totalCount); }); }, [map]); if (count === null) return null; if (count === 0) { return (
{props.emptyTitle ?? "Aucune propriété trouvée dans cette zone."} {props.emptyHint ?? "Élargissez la carte ou modifiez vos filtres."}
); } const hidden = total !== undefined && total > count ? total - count : 0; return (
{count.toLocaleString("fr-CA")} sur la carte {hidden > 0 ? ` · ${hidden.toLocaleString("fr-CA")} hors carte` : ""}
); } /** * Groupe Ka brand badge — every Ka Maps instance carries the family mark: * the app's map product name over the "Ka Maps · Groupe Ka" signature. * Complements (never replaces) the legally required OSM attribution. */ export function KaBrandBadge(props: { subtitle?: string }): ReactElement | null { const map = useKaMap(); if (!map) return null; const theme = map.getTheme(); return ( ); } /** 3D tilt toggle — buildings gain their real extruded volumes at street * zoom; this control tilts the camera to reveal them. Never the default. */ export function Tilt3DControl(props: { label3d?: string; label2d?: string }): ReactElement | null { const map = useKaMap(); const [tilted, setTilted] = useState(() => map?.isTilted() ?? false); useEffect(() => { if (!map) return; return map.events.on("moveend", () => setTilted(map.isTilted())); }, [map]); if (!map) return null; return ( ); } /** "Locate me" — geolocation strictly on user action, graceful denial. */ export function LocateControl(props: { label?: string }): ReactElement | null { const map = useKaMap(); const [state, setState] = useState<"idle" | "busy" | "denied">("idle"); if (!map) return null; const locate = () => { if (!("geolocation" in navigator)) { setState("denied"); return; } setState("busy"); navigator.geolocation.getCurrentPosition( (pos) => { setState("idle"); map.map.easeTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: Math.max(map.map.getZoom(), 14), duration: 600, }); }, () => setState("denied"), { enableHighAccuracy: true, timeout: 10_000 }, ); }; return ( ); } /** * Outil « Dessiner une zone » — démarre/annule le tracé d'un polygone sur * la carte ; quand une zone est posée, le bouton devient « Effacer la zone ». * L'app écoute onDraw (KaMapView) pour transformer le polygone en filtre. */ export function DrawControl(props: { labelStart?: string; labelDrawing?: string; labelClear?: string; }): ReactElement | null { const map = useKaMap(); const [drawing, setDrawing] = useState(false); const [hasZone, setHasZone] = useState( () => (map?.getDrawnPolygon() ?? null) !== null, ); useEffect(() => { if (!map) return; setDrawing(map.isDrawing()); setHasZone(map.getDrawnPolygon() !== null); return map.events.on("draw", ({ polygon, drawing: d }) => { setDrawing(d); setHasZone(polygon !== null); }); }, [map]); if (!map) return null; const onClick = () => { if (drawing) map.cancelDraw(); else if (hasZone) map.clearDrawnPolygon(); else map.startDraw(); }; return ( ); }