TypeScript 87.7%
CSS 12.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * Framework-level React controls, themed by the app's KaMapTheme through7 * CSS custom properties (see ka-maps.css). Headless enough that each app8 * keeps its own visual identity via tokens, not forks.9 */1011import { useEffect, useState, type ReactElement, type ReactNode } from "react";12import { useKaMap } from "./KaMapView.js";1314/**15 * "Search this area" — appears when the viewport diverges from the last16 * searched area (manual search mode), with an optional auto-search toggle.17 */18export function SearchAreaControl(props: {19 label?: string;20 autoLabel?: string;21 showAutoToggle?: boolean;22}): ReactElement | null {23 const map = useKaMap();24 const [dirty, setDirty] = useState(false);25 const [loading, setLoading] = useState(false);26 const [auto, setAuto] = useState(map?.getSearchMode() === "auto");2728 useEffect(() => {29 if (!map) return;30 const offs = [31 map.events.on("searchAreaDirty", ({ dirty: d }) => setDirty(d)),32 map.events.on("loading", ({ loading: l }) => setLoading(l)),33 ];34 return () => offs.forEach((off) => off());35 }, [map]);3637 if (!map) return null;3839 const toggleAuto = () => {40 const next = !auto;41 setAuto(next);42 map.setSearchMode(next ? "auto" : "manual");43 };4445 return (46 <div className="ka-search-area" role="group" aria-label="Recherche géographique">47 {dirty && !auto ? (48 <button49 type="button"50 className="ka-search-area-btn"51 onClick={() => map.searchThisArea()}52 disabled={loading}53 >54 {loading ? "Recherche…" : props.label ?? "Rechercher dans cette zone"}55 </button>56 ) : null}57 {props.showAutoToggle !== false ? (58 <label className="ka-search-area-auto">59 <input type="checkbox" checked={auto} onChange={toggleAuto} />60 <span>{props.autoLabel ?? "Rechercher en déplaçant la carte"}</span>61 </label>62 ) : null}63 </div>64 );65}6667/** Subtle updating indicator — keeps previous results visible while loading. */68export function LoadingIndicator(props: { label?: string }): ReactElement | null {69 const map = useKaMap();70 const [loading, setLoading] = useState(false);7172 useEffect(() => {73 if (!map) return;74 return map.events.on("loading", ({ loading: l }) => setLoading(l));75 }, [map]);7677 if (!loading) return null;78 return (79 <div className="ka-loading" role="status" aria-live="polite">80 <span className="ka-loading-dot" aria-hidden="true" />81 {props.label ?? "Mise à jour…"}82 </div>83 );84}8586/** Count chip + empty state, French default. */87export function ResultCount(props: {88 emptyTitle?: string;89 emptyHint?: string;90}): ReactElement | null {91 const map = useKaMap();92 const [count, setCount] = useState<number | null>(null);93 const [total, setTotal] = useState<number | undefined>(undefined);9495 useEffect(() => {96 if (!map) return;97 return map.events.on("data", ({ count: c, totalCount }) => {98 setCount(c);99 setTotal(totalCount);100 });101 }, [map]);102103 if (count === null) return null;104 if (count === 0) {105 return (106 <div className="ka-empty" role="status">107 <strong>{props.emptyTitle ?? "Aucune propriété trouvée dans cette zone."}</strong>108 <span>{props.emptyHint ?? "Élargissez la carte ou modifiez vos filtres."}</span>109 </div>110 );111 }112 const hidden = total !== undefined && total > count ? total - count : 0;113 return (114 <div className="ka-count" role="status">115 {count.toLocaleString("fr-CA")} sur la carte116 {hidden > 0 ? ` · ${hidden.toLocaleString("fr-CA")} hors carte` : ""}117 </div>118 );119}120121/**122 * Groupe Ka brand badge — every Ka Maps instance carries the family mark:123 * the app's map product name over the "Ka Maps · Groupe Ka" signature.124 * Complements (never replaces) the legally required OSM attribution.125 */126export function KaBrandBadge(props: { subtitle?: string }): ReactElement | null {127 const map = useKaMap();128 if (!map) return null;129 const theme = map.getTheme();130 return (131 <div className="ka-brand" aria-hidden="true">132 <span className="ka-brand-name">{theme.productName}</span>133 <span className="ka-brand-sub">134 {props.subtitle ?? "Ka Maps · Groupe Ka"}135 </span>136 </div>137 );138}139140/** 3D tilt toggle — buildings gain their real extruded volumes at street141 * zoom; this control tilts the camera to reveal them. Never the default. */142export function Tilt3DControl(props: { label3d?: string; label2d?: string }): ReactElement | null {143 const map = useKaMap();144 const [tilted, setTilted] = useState(() => map?.isTilted() ?? false);145146 useEffect(() => {147 if (!map) return;148 return map.events.on("moveend", () => setTilted(map.isTilted()));149 }, [map]);150151 if (!map) return null;152 return (153 <button154 type="button"155 className={`ka-3d${tilted ? " on" : ""}`}156 onClick={() => map.setTilt(!tilted)}157 aria-pressed={tilted}158 aria-label="Basculer la vue 3D"159 >160 {tilted ? props.label2d ?? "2D" : props.label3d ?? "3D"}161 </button>162 );163}164165/** "Locate me" — geolocation strictly on user action, graceful denial. */166export function LocateControl(props: { label?: string }): ReactElement | null {167 const map = useKaMap();168 const [state, setState] = useState<"idle" | "busy" | "denied">("idle");169170 if (!map) return null;171172 const locate = () => {173 if (!("geolocation" in navigator)) {174 setState("denied");175 return;176 }177 setState("busy");178 navigator.geolocation.getCurrentPosition(179 (pos) => {180 setState("idle");181 map.map.easeTo({182 center: [pos.coords.longitude, pos.coords.latitude],183 zoom: Math.max(map.map.getZoom(), 14),184 duration: 600,185 });186 },187 () => setState("denied"),188 { enableHighAccuracy: true, timeout: 10_000 },189 );190 };191192 return (193 <button194 type="button"195 className="ka-locate"196 onClick={locate}197 disabled={state === "busy"}198 aria-label={props.label ?? "Me localiser"}199 title={state === "denied" ? "Géolocalisation indisponible" : props.label ?? "Me localiser"}200 >201 ◎ {props.label ?? "Me localiser"}202 </button>203 );204}205206/**207 * Outil « Dessiner une zone » — démarre/annule le tracé d'un polygone sur208 * la carte ; quand une zone est posée, le bouton devient « Effacer la zone ».209 * L'app écoute onDraw (KaMapView) pour transformer le polygone en filtre.210 */211export function DrawControl(props: {212 labelStart?: string;213 labelDrawing?: string;214 labelClear?: string;215}): ReactElement | null {216 const map = useKaMap();217 const [drawing, setDrawing] = useState(false);218 const [hasZone, setHasZone] = useState<boolean>(219 () => (map?.getDrawnPolygon() ?? null) !== null,220 );221222 useEffect(() => {223 if (!map) return;224 setDrawing(map.isDrawing());225 setHasZone(map.getDrawnPolygon() !== null);226 return map.events.on("draw", ({ polygon, drawing: d }) => {227 setDrawing(d);228 setHasZone(polygon !== null);229 });230 }, [map]);231232 if (!map) return null;233234 const onClick = () => {235 if (drawing) map.cancelDraw();236 else if (hasZone) map.clearDrawnPolygon();237 else map.startDraw();238 };239240 return (241 <button242 type="button"243 className={`ka-draw-btn${drawing ? " drawing" : ""}${hasZone ? " has-zone" : ""}`}244 onClick={onClick}245 aria-pressed={drawing || hasZone}246 >247 {drawing248 ? props.labelDrawing ?? "Cliquez pour tracer — Échap pour annuler"249 : hasZone250 ? props.labelClear ?? "Effacer la zone"251 : props.labelStart ?? "Dessiner une zone"}252 </button>253 );254}255