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%
7.6 KB · 255 lines tsx
Raw Blame History
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