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%
36.4 KB · 1,091 lines typescript
Raw Blame History
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * KaMap — the framework engine. Owns the Mapbox GL instance, the Ka base7 * style, the property source/layers, feature states, selection, the8 * viewport→data pipeline and all map event listeners. Apps talk to this9 * class (directly or through the React bindings), never to Mapbox GL.10 */1112import mapboxgl from "mapbox-gl";13import type {14  GeoJSONSource,15  Map as MapboxMap,16  MapLayerMouseEvent,17  MapMouseEvent,18  TargetFeature,19} from "mapbox-gl";2021/** Feature reçue par les gestionnaires de clic/survol des couches. */22type LayerFeature = NonNullable<MapLayerMouseEvent["features"]>[number];23import type {24  BBox,25  KaDataAdapter,26  KaMapState,27  MapProperty,28} from "../types/index.js";29import { KaEventHub } from "./events.js";30import {31  applyKaBasemapConfig,32  buildKaStyle,33  type KaBasemapOptions,34} from "../styles/kaBaseStyle.js";35import { markerTokens, type KaMapMode, type KaMapTheme } from "../theming/tokens.js";36import {37  BoundsQueryScheduler,38  type BoundsQueryOptions,39} from "../services/boundsQuery.js";40import {41  bboxContains,42  bboxOfProperties,43  expandBBox,44  hashId,45  isValidCoordinate,46  pointInPolygon,47  propertiesToGeoJSON,48} from "../utils/geo.js";49import {50  buildClusterProperties,51  buildPropertyLayers,52  LAYER_IDS,53  pillIconExpression,54  PROPERTY_SOURCE_ID,55  registerPillImages,56} from "../layers/propertyLayer.js";5758export interface KaMapOptions {59  container: HTMLElement;60  theme: KaMapTheme;61  /** Jeton public Mapbox (pk.…) — conçu pour être exposé côté client. */62  mapboxToken: string;63  mode?: KaMapMode;64  adapter?: KaDataAdapter;65  center?: { lat: number; lng: number };66  zoom?: number;67  /** Inclinaison initiale — 3D par défaut (50°), bouton 2D pour revenir. */68  pitch?: number;69  minZoom?: number;70  maxZoom?: number;71  /** Réglages du fond Mapbox Standard (réalisme, lumière, repères 3D). */72  basemap?: KaBasemapOptions;73  /** "auto": refetch after every settled move. "manual": show Search-this-area. */74  searchMode?: "auto" | "manual";75  query?: BoundsQueryOptions;76  /** Cooperative gestures on embedded maps (two-finger pan hint). */77  cooperativeGestures?: boolean;78  /** Boutons +/− Mapbox natifs. false quand l'app fournit sa propre79   *  toolbar (KaMapToolbar) — le pincement/molette reste actif. */80  navControl?: boolean;81  /** Clustering tuning. Lou-Ka style per-building geocoding wants82   *  maxZoom 15 so stacked units stay grouped as long as possible.83   *  `valueClamp` bounds each item's contribution to the bubble mean. */84  cluster?: {85    maxZoom?: number;86    radius?: number;87    valueClamp?: [number, number];88    /** N minimal d'items pour afficher la valeur sous une bulle. */89    valueMinCount?: number;90  };91}9293const EMPTY_FC: GeoJSON.FeatureCollection = {94  type: "FeatureCollection",95  features: [],96};9798/** Couches de l'outil « dessiner une zone » (Ka Draw). */99const DRAW_SOURCE_ID = "ka-draw";100const DRAW_LAYER_IDS = {101  fill: "ka-draw-fill",102  line: "ka-draw-line",103  vertex: "ka-draw-vertex",104} as const;105106export class KaMap {107  readonly events = new KaEventHub();108  readonly map: MapboxMap;109110  private theme: KaMapTheme;111  private mode: KaMapMode;112  private basemap: KaBasemapOptions | undefined;113  private scheduler: BoundsQueryScheduler | null = null;114  private searchMode: "auto" | "manual";115  private filters: Record<string, unknown> | undefined;116117  private data: GeoJSON.FeatureCollection = EMPTY_FC;118  private byId = new Map<string, { hash: number; property: MapProperty }>();119120  /** Jeu complet poussé par l'app/l'adaptateur, avant découpe éventuelle. */121  private rawProperties: MapProperty[] = [];122  /** Zone de découpe côté client (apps sans filtre polygone serveur). */123  private clipPolygon: [number, number][] | null = null;124  private lastTotalCount: number | undefined = undefined;125126  private selectedId: string | null = null;127  private hoveredId: string | null = null;128  private searchedBBox: BBox | null = null;129  private searchAreaDirty = false;130  private destroyed = false;131  private overlayInstalled = false;132  private overlayAttempts = 0;133  /** Id de source courant — tourne à chaque reconstruction : l'état worker134   *  d'un id « zombifié » par une recomposition Standard est irrécupérable. */135  private sourceId = PROPERTY_SOURCE_ID;136  private sourceGen = 0;137  private verifyTimer: ReturnType<typeof setTimeout> | null = null;138  private clusterOptions: {139    maxZoom: number;140    radius: number;141    valueClamp?: [number, number];142    valueMinCount?: number;143  };144145  /** Mouvement en cours déclenché par le code (fitBounds, easeTo interne) :146   *  consommé par handleMoveEnd pour distinguer le geste de l'utilisateur. */147  private programmaticMove = false;148149  /** Outil de dessin de zone : tracé en cours + polygone posé. */150  private drawing = false;151  private drawVertices: [number, number][] = [];152  private drawnPolygon: [number, number][] | null = null;153  private drawCursor: [number, number] | null = null;154  private drawKeyHandler: ((e: KeyboardEvent) => void) | null = null;155156  /** Annonces déjà consultées — pastilles atténuées (feature-state seen). */157  private seenIds: Set<string> = new Set();158159  /** Bâtiment mis en évidence (fiche) — featureset "buildings" du Standard. */160  private buildingFocus: { lng: number; lat: number } | null = null;161  private focusedBuildings: TargetFeature[] = [];162  private focusAttempts = 0;163  private focusRetryTimer: ReturnType<typeof setTimeout> | null = null;164165  constructor(options: KaMapOptions) {166    this.theme = options.theme;167    this.mode = options.mode ?? "light";168    this.basemap = options.basemap;169    this.searchMode = options.searchMode ?? "manual";170    this.clusterOptions = {171      // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` —172      // une valeur fractionnaire plante le worker (Invalid array length)173      maxZoom: Math.round(options.cluster?.maxZoom ?? 14),174      radius: options.cluster?.radius ?? 46,175      valueClamp: options.cluster?.valueClamp,176      valueMinCount: options.cluster?.valueMinCount,177    };178179    this.map = new mapboxgl.Map({180      container: options.container,181      accessToken: options.mapboxToken,182      style: buildKaStyle(this.mode, this.theme),183      center: [options.center?.lng ?? -71.254, options.center?.lat ?? 46.813],184      zoom: options.zoom ?? 11,185      pitch: options.pitch ?? 50,186      minZoom: options.minZoom ?? 4,187      maxZoom: options.maxZoom ?? 18.5,188      attributionControl: false,189      cooperativeGestures: options.cooperativeGestures ?? false,190    });191    // Attribution repliée (ⓘ) — conforme, discrète ; logo Mapbox conservé.192    this.map.addControl(193      new mapboxgl.AttributionControl({ compact: true }),194      "bottom-right",195    );196    if (options.navControl !== false) {197      this.map.addControl(198        new mapboxgl.NavigationControl({ showCompass: false }),199        "top-right",200      );201    }202    // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise.203    this.map.touchZoomRotate.disableRotation();204    this.map.dragRotate.disable();205    this.map.keyboard.enable();206207    if (options.adapter) {208      this.scheduler = new BoundsQueryScheduler(options.adapter, options.query);209      this.scheduler.onResult((result) => {210        this.lastTotalCount = result.totalCount;211        this.setProperties(result.properties);212      });213      this.scheduler.onError((error) =>214        this.events.emit("error", { scope: "query", error }),215      );216      this.scheduler.onLoading((loading) =>217        this.events.emit("loading", { loading }),218      );219    }220221    // Poignée de débogage/tests E2E (comme l'ancien window._loukaMap).222    (globalThis as { __kaMap?: KaMap }).__kaMap = this;223224    // S'assurer que l'attribution démarre repliée (bouton ⓘ).225    this.map.once("load", () => {226      const attrib = options.container.querySelector(".mapboxgl-ctrl-attrib");227      attrib?.classList.remove("mapboxgl-compact-show");228      attrib?.removeAttribute("open");229    });230231    // Mapbox Standard est un style à imports : ses recompositions peuvent232    // « zombifier » une source ajoutée au mauvais moment (l'objet survit,233    // le worker ne traite plus rien). Stratégie : installer l'overlay au234    // chargement, appliquer la config du basemap APRÈS, puis VÉRIFIER que235    // des features sont réellement traitées — sinon on détruit et on236    // reconstruit la source et les couches (plafonné).237    this.map.once("load", () => {238      this.installOverlay();239      this.map.once("idle", () =>240        applyKaBasemapConfig(this.map, this.mode, this.basemap),241      );242    });243    this.map.on("styledata", () => {244      if (!this.overlayInstalled) return;245      if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) {246        this.map.once("idle", () => this.rebuildOverlay());247      }248      // Une recomposition du Standard peut perdre l'état du featureset249      // buildings : réappliquer le focus (idempotent, peu coûteux).250      if (this.buildingFocus) {251        this.focusedBuildings = [];252        this.scheduleBuildingFocus();253      }254    });255    // Tout geste direct annule le marquage « mouvement programmé » : si256    // l'utilisateur interrompt un fitBounds, le moveend redevient le sien.257    for (const gesture of ["dragstart", "wheel", "boxzoomstart", "dblclick"] as const) {258      this.map.on(gesture, () => {259        this.programmaticMove = false;260      });261    }262    this.map.on("touchmove", () => {263      this.programmaticMove = false;264    });265    this.map.on("moveend", () => this.handleMoveEnd());266    this.map.on("error", (e) => {267      // Garder la trace en console (MapLibre se tairait dès qu'un handler268      // existe — les couches invalides deviendraient indétectables).269      console.warn("[ka-maps]", e.error);270      this.events.emit("error", { scope: "tiles", error: e.error });271    });272273    this.map.on("click", (e) => this.handleBaseClick(e));274    this.map.on("mousemove", (e) => {275      if (this.drawing && this.drawVertices.length > 0) {276        this.drawCursor = [e.lngLat.lng, e.lngLat.lat];277        this.updateDrawSource();278      }279    });280    this.bindLayerInteractions();281  }282283  // ---------------------------------------------------------------- overlay284285  /** (Re)install property source + layers — self-healing after any style286   *  recomposition (Mapbox Standard imports). */287  private installOverlay(): void {288    if (this.destroyed) return;289    const map = this.map;290    registerPillImages(map, this.theme);291    this.overlayInstalled = true;292293    if (!map.getSource(this.sourceId)) {294      map.addSource(this.sourceId, {295        type: "geojson",296        data: this.data,297        cluster: true,298        clusterMaxZoom: this.clusterOptions.maxZoom,299        clusterRadius: this.clusterOptions.radius,300        clusterProperties: buildClusterProperties(301          this.clusterOptions.valueClamp,302        ) as never,303        // (pas de promoteId : les ids de features sont déjà des numériques304        // hachés par propertiesToGeoJSON)305      });306    }307    for (const layer of buildPropertyLayers(this.theme, this.sourceId, {308      valueMinCount: this.clusterOptions.valueMinCount,309    })) {310      if (!map.getLayer(layer.id)) map.addLayer(layer);311    }312    this.installDrawLayers();313    this.applyFeatureStates();314    this.scheduleOverlayVerify();315  }316317  /** Détruit et réinstalle la source + les couches (source zombie). */318  private rebuildOverlay(): void {319    if (this.destroyed) return;320    const map = this.map;321    try {322      for (const id of Object.values(LAYER_IDS)) {323        if (map.getLayer(id)) map.removeLayer(id);324      }325      if (map.getSource(this.sourceId)) map.removeSource(this.sourceId);326    } catch {327      // style en transition : la prochaine vérification retentera328    }329    this.sourceGen++;330    this.sourceId = `${PROPERTY_SOURCE_ID}-r${this.sourceGen}`;331    this.installOverlay();332  }333334  /** Vérifie que le worker traite bien la source ; sinon, reconstruit. */335  private scheduleOverlayVerify(): void {336    if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);337    this.verifyTimer = setTimeout(() => {338      this.verifyTimer = null;339      if (this.destroyed || this.data.features.length === 0) return;340      let processed = 0;341      try {342        processed = this.map.querySourceFeatures(this.sourceId).length;343      } catch {344        return;345      }346      if (processed > 0) {347        this.overlayAttempts = 0;348        return;349      }350      if (this.overlayAttempts >= 6) return;351      this.overlayAttempts++;352      console.warn(353        `[ka-maps] source non traitée par le worker — reconstruction (${this.overlayAttempts}/6)`,354      );355      this.rebuildOverlay();356    }, 1200);357  }358359  private bindLayerInteractions(): void {360    const map = this.map;361362    map.on("click", LAYER_IDS.clusters, (e) => this.expandCluster(e));363    map.on("click", LAYER_IDS.pointPill, (e) => this.clickProperty(e));364    map.on("click", LAYER_IDS.pointDot, (e) => this.clickProperty(e));365366    for (const id of [LAYER_IDS.clusters, LAYER_IDS.pointPill, LAYER_IDS.pointDot]) {367      map.on("mouseenter", id, () => {368        map.getCanvas().style.cursor = "pointer";369      });370      map.on("mouseleave", id, () => {371        map.getCanvas().style.cursor = "";372      });373    }374375    map.on("mousemove", LAYER_IDS.pointPill, (e) => this.hoverFrom(e));376    map.on("mousemove", LAYER_IDS.pointDot, (e) => this.hoverFrom(e));377    map.on("mouseleave", LAYER_IDS.pointPill, () => this.setHovered(null, "map"));378    map.on("mouseleave", LAYER_IDS.pointDot, () => this.setHovered(null, "map"));379380    // Survol d'un cluster : fourchette de prix du contenu (tooltip app).381    map.on("mousemove", LAYER_IDS.clusters, (e) => {382      const f = e.features?.[0];383      if (!f) return;384      const props = f.properties ?? {};385      const count = (props["point_count"] as number | undefined) ?? 0;386      const rawMin = props["valueMin"] as number | undefined;387      const rawMax = props["valueMax"] as number | undefined;388      // 99999999 / 0 sont les sentinelles des agrégats pour les valeurs389      // nulles (voir buildClusterProperties) — jamais de vrais loyers.390      const min =391        typeof rawMin === "number" && Number.isFinite(rawMin) && rawMin < 99999999392          ? rawMin393          : null;394      const max =395        typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0396          ? rawMax397          : null;398      this.events.emit("clusterHover", {399        info: { count, min, max, x: e.point.x, y: e.point.y },400      });401    });402    map.on("mouseleave", LAYER_IDS.clusters, () =>403      this.events.emit("clusterHover", { info: null }),404    );405  }406407  private featureId(feature: LayerFeature): string | null {408    const raw = feature.properties?.["id"];409    return typeof raw === "string" ? raw : null;410  }411412  private expandCluster(e: MapLayerMouseEvent): void {413    if (this.drawing) return;414    const feature = e.features?.[0];415    if (!feature) return;416    const clusterId = feature.properties?.["cluster_id"] as number | undefined;417    const count = (feature.properties?.["point_count"] as number | undefined) ?? 0;418    const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;419    if (clusterId === undefined || !source) return;420    source.getClusterExpansionZoom(clusterId, (err, zoom) => {421      if (this.destroyed || err || zoom == null) return;422      const [lng, lat] = (feature.geometry as GeoJSON.Point).coordinates;423      // Le clic sur un cluster est un geste de l'utilisateur : le moveend424      // qui suit doit compter comme tel (liste ajustée en conséquence).425      this.programmaticMove = false;426      this.map.easeTo({427        center: [lng as number, lat as number],428        zoom: Math.min(zoom + 0.25, this.map.getMaxZoom()),429        duration: 480,430      });431      this.events.emit("clusterExpand", { count });432    });433  }434435  private clickProperty(e: MapLayerMouseEvent): void {436    if (this.drawing) return;437    const feature = e.features?.[0];438    if (!feature) return;439    const id = this.featureId(feature);440    if (id) this.select(id, "map");441  }442443  private handleBaseClick(e: MapMouseEvent): void {444    // Mode dessin : chaque clic pose un sommet ; un clic près du premier445    // sommet (≥ 3 posés) ferme le polygone.446    if (this.drawing) {447      this.addDrawVertex(e);448      return;449    }450    // Clicks that hit property layers are handled there; a bare map click451    // clears the selection.452    const hits = this.map.queryRenderedFeatures(e.point, {453      layers: [LAYER_IDS.pointPill, LAYER_IDS.pointDot, LAYER_IDS.clusters].filter(454        (l) => Boolean(this.map.getLayer(l)),455      ),456    });457    if (hits.length === 0 && this.selectedId) this.select(null, "map");458  }459460  private hoverFrom(e: MapLayerMouseEvent): void {461    const feature = e.features?.[0];462    const id = feature ? this.featureId(feature) : null;463    this.setHovered(id, "map");464  }465466  // ------------------------------------------------------------------ data467468  /** Replace the rendered property set (adapter results or app-pushed). */469  setProperties(properties: MapProperty[]): void {470    this.rawProperties = properties;471    this.renderProperties();472  }473474  /** Applique le jeu courant (après découpe polygone éventuelle). */475  private renderProperties(): void {476    const poly = this.clipPolygon;477    const properties =478      poly && poly.length >= 3479        ? this.rawProperties.filter((p) =>480            pointInPolygon(p.longitude, p.latitude, poly),481          )482        : this.rawProperties;483    this.byId.clear();484    for (const p of properties) {485      if (!isValidCoordinate(p.latitude, p.longitude)) continue;486      this.byId.set(p.id, { hash: hashId(p.id), property: p });487    }488    this.data = propertiesToGeoJSON(properties);489    const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;490    if (source) source.setData(this.data as never);491    // Keep selection if the item is still visible, otherwise drop it.492    if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app");493    this.applyFeatureStates();494    if (this.overlayInstalled) this.scheduleOverlayVerify();495    this.events.emit("data", {496      count: this.byId.size,497      totalCount: this.lastTotalCount,498    });499  }500501  /**502   * Découpe côté client : ne rendre que les items dans le polygone (apps503   * dont l'API ne filtre pas par polygone). Passer null pour tout rendre.504   * Indépendant du polygone AFFICHÉ (setDrawnPolygon) — l'app appelle505   * généralement les deux ensemble.506   */507  setClipPolygon(polygon: [number, number][] | null): void {508    this.clipPolygon = polygon && polygon.length >= 3 ? polygon : null;509    this.renderProperties();510  }511512  getProperty(id: string): MapProperty | undefined {513    return this.byId.get(id)?.property;514  }515516  /** Items actuellement rendus, dans l'ordre du jeu de données. */517  getVisibleProperties(): MapProperty[] {518    return [...this.byId.values()].map((e) => e.property);519  }520521  /** Current filters forwarded to the adapter on every query. */522  setFilters(filters: Record<string, unknown> | undefined): void {523    this.filters = filters;524    this.scheduler?.invalidate();525    this.refetch();526  }527528  /** Fetch data for the current viewport immediately (Search this area). */529  searchThisArea(): void {530    this.refetch();531  }532533  refetch(): void {534    if (!this.scheduler) return;535    const bbox = this.currentBBox();536    if (!bbox) return;537    this.searchedBBox = bbox;538    this.setDirty(false);539    this.scheduler.requestNow({ bbox, zoom: this.map.getZoom(), filters: this.filters });540  }541542  setSearchMode(mode: "auto" | "manual"): void {543    this.searchMode = mode;544    if (mode === "auto" && this.searchAreaDirty) this.refetch();545  }546547  getSearchMode(): "auto" | "manual" {548    return this.searchMode;549  }550551  private handleMoveEnd(): void {552    const center = this.map.getCenter();553    const byUser = !this.programmaticMove;554    this.programmaticMove = false;555    this.events.emit("moveend", {556      center: { lat: center.lat, lng: center.lng },557      zoom: this.map.getZoom(),558      byUser,559    });560    if (!this.scheduler) return;561562    const bbox = this.currentBBox();563    if (!bbox) return;564565    if (this.searchMode === "auto") {566      this.searchedBBox = bbox;567      this.setDirty(false);568      this.scheduler.request({ bbox, zoom: this.map.getZoom(), filters: this.filters });569      return;570    }571    // Manual mode: flag divergence, let the app show "Search this area".572    if (!this.searchedBBox) {573      this.refetch(); // first load574      return;575    }576    const tolerant = expandBBox(this.searchedBBox, 0.15);577    this.setDirty(!bboxContains(tolerant, bbox));578  }579580  private setDirty(dirty: boolean): void {581    if (this.searchAreaDirty === dirty) return;582    this.searchAreaDirty = dirty;583    this.events.emit("searchAreaDirty", { dirty });584  }585586  // ------------------------------------------------------------- selection587588  /** Select from map click or app (card click). Pass null to clear.589   *  Selecting the already-selected id is a no-op (prevents feedback loops590   *  when apps mirror the selection back declaratively). */591  select(id: string | null, origin: "map" | "app"): void {592    if (id === this.selectedId) return;593    const previous = this.selectedId;594    this.selectedId = id;595    if (previous) this.setFeatureState(previous, { selected: false });596    if (id) this.setFeatureState(id, { selected: true });597    this.refreshPillSelection();598    this.events.emit("select", { propertyId: id, origin });599600    // Card-driven selection: reveal the item without a jarring recenter.601    if (origin === "app" && id) {602      const entry = this.byId.get(id);603      if (entry) {604        const { latitude, longitude } = entry.property;605        const bounds = this.map.getBounds();606        if (bounds && !bounds.contains([longitude, latitude])) {607          this.programmaticMove = true;608          this.map.easeTo({ center: [longitude, latitude], duration: 420 });609        }610      }611    }612  }613614  getSelectedId(): string | null {615    return this.selectedId;616  }617618  setHovered(id: string | null, origin: "map" | "app"): void {619    if (id === this.hoveredId) return;620    if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: false });621    this.hoveredId = id;622    if (id) this.setFeatureState(id, { hovered: true });623    if (origin === "map") this.events.emit("hover", { propertyId: id });624  }625626  /** Dim everything except the given ids (Ka Lens, filter emphasis). */627  setDimmedExcept(ids: Set<string> | null): void {628    for (const [id, entry] of this.byId) {629      this.map.setFeatureState(630        { source: this.sourceId, id: entry.hash },631        { dimmed: ids !== null && !ids.has(id) },632      );633    }634  }635636  /** Annonces déjà consultées : pastilles atténuées (état « vu »). */637  setSeenIds(ids: Iterable<string>): void {638    this.seenIds = new Set(ids);639    this.applySeenStates();640  }641642  private applySeenStates(): void {643    if (this.seenIds.size === 0) return;644    for (const [id, entry] of this.byId) {645      if (!this.seenIds.has(id)) continue;646      try {647        this.map.setFeatureState(648          { source: this.sourceId, id: entry.hash },649          { seen: true },650        );651      } catch {652        // source en transition — réappliqué par applyFeatureStates653      }654    }655  }656657  private setFeatureState(id: string, state: Record<string, boolean>): void {658    const entry = this.byId.get(id);659    if (!entry) return;660    try {661      this.map.setFeatureState({ source: this.sourceId, id: entry.hash }, state);662    } catch {663      // Source may be mid-reload during a style swap; states reapply after.664    }665  }666667  private applyFeatureStates(): void {668    if (this.selectedId) this.setFeatureState(this.selectedId, { selected: true });669    if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: true });670    this.applySeenStates();671    this.refreshPillSelection();672  }673674  /** icon-image est une propriété layout (feature-state interdit) : la675   *  pastille sélectionnée est réinjectée dans l'expression au besoin. */676  private refreshPillSelection(): void {677    if (!this.map.getLayer(LAYER_IDS.pointPill)) return;678    try {679      this.map.setLayoutProperty(680        LAYER_IDS.pointPill,681        "icon-image",682        pillIconExpression(this.selectedId),683      );684    } catch {685      // style en cours de rechargement — réappliqué par installOverlay686    }687  }688689  // ----------------------------------------------------------------- dessin690691  /** Démarre le tracé d'une zone : chaque clic pose un sommet, un clic sur692   *  le premier sommet (≥ 3) ferme le polygone, Échap annule. */693  startDraw(): void {694    if (this.drawing) return;695    this.drawing = true;696    this.drawVertices = [];697    this.drawCursor = null;698    this.drawnPolygon = null;699    this.map.doubleClickZoom.disable();700    this.map.getCanvas().style.cursor = "crosshair";701    this.drawKeyHandler = (e: KeyboardEvent) => {702      if (e.key === "Escape") this.cancelDraw();703    };704    window.addEventListener("keydown", this.drawKeyHandler);705    this.updateDrawSource();706    this.events.emit("draw", { polygon: null, drawing: true });707  }708709  /** Annule le tracé en cours (Échap ou bouton). */710  cancelDraw(): void {711    if (!this.drawing) return;712    this.endDrawMode();713    this.drawVertices = [];714    this.drawCursor = null;715    this.updateDrawSource();716    this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });717  }718719  /** Efface le polygone posé (retrait de la puce « Zone dessinée »). */720  clearDrawnPolygon(): void {721    if (this.drawing) this.endDrawMode();722    this.drawing = false;723    this.drawVertices = [];724    this.drawCursor = null;725    this.drawnPolygon = null;726    this.updateDrawSource();727    this.events.emit("draw", { polygon: null, drawing: false });728  }729730  /** Restaure un polygone (URL partagée) sans passer par le tracé. */731  setDrawnPolygon(polygon: [number, number][] | null): void {732    this.drawnPolygon = polygon && polygon.length >= 3 ? polygon : null;733    this.updateDrawSource();734    this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });735  }736737  getDrawnPolygon(): [number, number][] | null {738    return this.drawnPolygon;739  }740741  isDrawing(): boolean {742    return this.drawing;743  }744745  private endDrawMode(): void {746    this.drawing = false;747    this.map.doubleClickZoom.enable();748    this.map.getCanvas().style.cursor = "";749    if (this.drawKeyHandler) {750      window.removeEventListener("keydown", this.drawKeyHandler);751      this.drawKeyHandler = null;752    }753  }754755  private addDrawVertex(e: MapMouseEvent): void {756    const first = this.drawVertices[0];757    if (first && this.drawVertices.length >= 3) {758      const firstPt = this.map.project(first);759      const dx = firstPt.x - e.point.x;760      const dy = firstPt.y - e.point.y;761      if (Math.hypot(dx, dy) < 14) {762        // Fermeture : clic sur le premier sommet.763        this.drawnPolygon = [...this.drawVertices];764        this.endDrawMode();765        this.drawCursor = null;766        this.updateDrawSource();767        this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });768        return;769      }770    }771    this.drawVertices.push([e.lngLat.lng, e.lngLat.lat]);772    this.updateDrawSource();773  }774775  private drawFeatureCollection(): GeoJSON.FeatureCollection {776    const features: GeoJSON.Feature[] = [];777    if (this.drawnPolygon) {778      const ring = [...this.drawnPolygon, this.drawnPolygon[0] as [number, number]];779      features.push({780        type: "Feature",781        properties: { role: "zone" },782        geometry: { type: "Polygon", coordinates: [ring] },783      });784    } else if (this.drawVertices.length > 0) {785      const line = this.drawCursor786        ? [...this.drawVertices, this.drawCursor]787        : [...this.drawVertices];788      if (line.length >= 2) {789        features.push({790          type: "Feature",791          properties: { role: "trace" },792          geometry: { type: "LineString", coordinates: line },793        });794      }795      for (const v of this.drawVertices) {796        features.push({797          type: "Feature",798          properties: { role: "sommet" },799          geometry: { type: "Point", coordinates: v },800        });801      }802    }803    return { type: "FeatureCollection", features };804  }805806  private updateDrawSource(): void {807    const source = this.map.getSource(DRAW_SOURCE_ID) as GeoJSONSource | undefined;808    if (source) source.setData(this.drawFeatureCollection() as never);809  }810811  /** Couches du dessin — réinstallées avec l'overlay (styledata). */812  private installDrawLayers(): void {813    const map = this.map;814    const accent = markerTokens(this.theme, "highlight").background;815    if (!map.getSource(DRAW_SOURCE_ID)) {816      map.addSource(DRAW_SOURCE_ID, {817        type: "geojson",818        data: this.drawFeatureCollection(),819      });820    }821    if (!map.getLayer(DRAW_LAYER_IDS.fill)) {822      map.addLayer({823        id: DRAW_LAYER_IDS.fill,824        slot: "top",825        type: "fill",826        source: DRAW_SOURCE_ID,827        filter: ["==", ["geometry-type"], "Polygon"],828        paint: { "fill-color": accent, "fill-opacity": 0.08 },829      });830    }831    if (!map.getLayer(DRAW_LAYER_IDS.line)) {832      map.addLayer({833        id: DRAW_LAYER_IDS.line,834        slot: "top",835        type: "line",836        source: DRAW_SOURCE_ID,837        filter: ["!=", ["geometry-type"], "Point"],838        paint: {839          "line-color": accent,840          "line-width": 2.25,841          "line-dasharray": [842            "case",843            ["==", ["get", "role"], "trace"],844            ["literal", [2, 1.6]],845            ["literal", [1, 0]],846          ] as never,847        },848      });849    }850    if (!map.getLayer(DRAW_LAYER_IDS.vertex)) {851      map.addLayer({852        id: DRAW_LAYER_IDS.vertex,853        slot: "top",854        type: "circle",855        source: DRAW_SOURCE_ID,856        filter: ["==", ["geometry-type"], "Point"],857        paint: {858          "circle-radius": 5,859          "circle-color": "#ffffff",860          "circle-stroke-color": accent,861          "circle-stroke-width": 2,862        },863      });864    }865  }866867  // ------------------------------------------------------ building spotlight868869  /**870   * Met en évidence le bâtiment situé aux coordonnées données (fiche d'une871   * propriété) : l'empreinte 3D du featureset « buildings » de Mapbox872   * Standard passe à l'état `select`, coloré via `colorBuildingSelect`873   * (option `basemap` ou `opts.color`). Passer `null` pour effacer.874   * Résilient : re-tenté tant que les tuiles ne sont pas rendues, et875   * réappliqué après chaque recomposition du style.876   */877  focusBuilding(878    at: { lng: number; lat: number } | null,879    opts?: { color?: string },880  ): void {881    for (const f of this.focusedBuildings) {882      try {883        this.map.setFeatureState(f, { select: false });884      } catch {885        // style en transition — l'état disparaît avec lui886      }887    }888    this.focusedBuildings = [];889    this.focusAttempts = 0;890    if (this.focusRetryTimer !== null) {891      clearTimeout(this.focusRetryTimer);892      this.focusRetryTimer = null;893    }894    this.buildingFocus = at;895    if (!at) return;896    if (opts?.color) {897      try {898        this.map.setConfigProperty("basemap", "colorBuildingSelect", opts.color);899      } catch {900        // style pas encore chargé : la couleur viendra de applyKaBasemapConfig901      }902    }903    this.scheduleBuildingFocus();904  }905906  private scheduleBuildingFocus(): void {907    if (!this.buildingFocus || this.destroyed) return;908    if (this.map.isStyleLoaded() && this.map.loaded()) {909      this.applyBuildingFocus();910    } else {911      this.map.once("idle", () => this.applyBuildingFocus());912    }913  }914915  private applyBuildingFocus(): void {916    const focus = this.buildingFocus;917    if (!focus || this.destroyed || this.focusedBuildings.length > 0) return;918919    const pt = this.map.project([focus.lng, focus.lat]);920    // Le point géocodé tombe parfois sur la rue devant l'immeuble : on921    // interroge une boîte serrée, puis on élargit progressivement.922    const pads = [4, 14, 34];923    let found: TargetFeature[] = [];924    for (const pad of pads) {925      try {926        found = this.map.queryRenderedFeatures(927          [928            [pt.x - pad, pt.y - pad],929            [pt.x + pad, pt.y + pad],930          ],931          { target: { featuresetId: "buildings", importId: "basemap" } },932        );933      } catch {934        found = [];935      }936      if (found.length > 0) break;937    }938939    if (found.length === 0) {940      // Tuiles vecteur pas encore prêtes, ou zone sans empreinte de941      // bâtiment : quelques re-tentatives espacées, puis on abandonne942      // proprement (le marqueur de prix reste le repère).943      if (this.focusAttempts++ < 8) {944        this.focusRetryTimer = setTimeout(() => {945          this.focusRetryTimer = null;946          this.scheduleBuildingFocus();947        }, 400);948      }949      return;950    }951952    // Un immeuble = souvent plusieurs morceaux d'empreinte : on sélectionne953    // toutes les parties partageant l'id de la plus proche du point.954    const primary = found[0];955    if (!primary) return;956    const primaryId = primary.id;957    const parts = found.filter((f) => f.id === primaryId);958    for (const f of parts) {959      try {960        this.map.setFeatureState(f, { select: true });961      } catch {962        // recomposition en cours : le handler styledata re-planifiera963      }964    }965    this.focusedBuildings = parts;966    this.events.emit("buildingFocus", { found: true });967  }968969  /** Recentre la caméra en douceur (fiche, spotlight, deep-link). */970  flyTo(971    center: { lng: number; lat: number },972    opts?: { zoom?: number; pitch?: number; bearing?: number; duration?: number },973  ): void {974    this.programmaticMove = true;975    this.map.easeTo({976      center: [center.lng, center.lat],977      zoom: opts?.zoom,978      pitch: opts?.pitch,979      bearing: opts?.bearing,980      duration: opts?.duration ?? 900,981    });982  }983984  /** Cadre l'emprise donnée avec une animation douce. Mouvement programmé :985   *  le moveend qui suit est émis avec `byUser: false` (la vue n'est pas986   *  « volée » à l'utilisateur au sens de la synchro liste↔carte). */987  fitBounds(988    bbox: BBox,989    opts?: { padding?: number; maxZoom?: number; duration?: number },990  ): void {991    this.programmaticMove = true;992    this.map.fitBounds(993      [994        [bbox.west, bbox.south],995        [bbox.east, bbox.north],996      ],997      {998        padding: opts?.padding ?? 56,999        maxZoom: opts?.maxZoom ?? 16,1000        duration: opts?.duration ?? 850,1001      },1002    );1003  }10041005  /** Cadre l'ensemble des propriétés affichées (ou fournies). */1006  fitToProperties(1007    properties?: MapProperty[],1008    opts?: { padding?: number; maxZoom?: number; duration?: number },1009  ): void {1010    const list = properties ?? [...this.byId.values()].map((e) => e.property);1011    const bbox = bboxOfProperties(list);1012    if (bbox) this.fitBounds(bbox, opts);1013  }10141015  // ---------------------------------------------------------------- theming10161017  /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */1018  setTilt(on: boolean): void {1019    this.programmaticMove = true;1020    this.map.easeTo({ pitch: on ? 55 : 0, duration: 600 });1021  }10221023  isTilted(): boolean {1024    return this.map.getPitch() > 5;1025  }10261027  setMode(mode: KaMapMode): void {1028    if (mode === this.mode) return;1029    this.mode = mode;1030    // Jour/nuit via la config du style Standard — aucun rechargement.1031    applyKaBasemapConfig(this.map, this.mode, this.basemap);1032  }10331034  getMode(): KaMapMode {1035    return this.mode;1036  }10371038  getTheme(): KaMapTheme {1039    return this.theme;1040  }10411042  // ------------------------------------------------------------------ state10431044  currentBBox(): BBox | null {1045    const b = this.map.getBounds();1046    if (!b) return null;1047    return {1048      west: b.getWest(),1049      south: b.getSouth(),1050      east: b.getEast(),1051      north: b.getNorth(),1052    };1053  }10541055  getState(): KaMapState {1056    const center = this.map.getCenter();1057    return {1058      center: { lat: center.lat, lng: center.lng },1059      zoom: this.map.getZoom(),1060      bearing: this.map.getBearing(),1061      pitch: this.map.getPitch(),1062      bounds: this.currentBBox(),1063      selectedPropertyId: this.selectedId,1064      hoveredPropertyId: this.hoveredId,1065      activeLayers: Object.values(LAYER_IDS).filter((l) =>1066        Boolean(this.map.getLayer(l)),1067      ),1068      searchAreaDirty: this.searchAreaDirty,1069      drawnGeometry: this.drawnPolygon1070        ? {1071            type: "Polygon",1072            coordinates: [[...this.drawnPolygon, this.drawnPolygon[0] as [number, number]]],1073          }1074        : null,1075    };1076  }10771078  destroy(): void {1079    this.destroyed = true;1080    if (this.drawKeyHandler) {1081      window.removeEventListener("keydown", this.drawKeyHandler);1082      this.drawKeyHandler = null;1083    }1084    if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);1085    if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer);1086    this.scheduler?.destroy();1087    this.events.clear();1088    this.map.remove();1089  }1090}1091