SPB Git

spb/ka-maps

Public
36.2 KB · 1,081 lines typescript
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?: { maxZoom?: number; radius?: number; valueClamp?: [number, number] };85}8687const EMPTY_FC: GeoJSON.FeatureCollection = {88  type: "FeatureCollection",89  features: [],90};9192/** Couches de l'outil « dessiner une zone » (Ka Draw). */93const DRAW_SOURCE_ID = "ka-draw";94const DRAW_LAYER_IDS = {95  fill: "ka-draw-fill",96  line: "ka-draw-line",97  vertex: "ka-draw-vertex",98} as const;99100export class KaMap {101  readonly events = new KaEventHub();102  readonly map: MapboxMap;103104  private theme: KaMapTheme;105  private mode: KaMapMode;106  private basemap: KaBasemapOptions | undefined;107  private scheduler: BoundsQueryScheduler | null = null;108  private searchMode: "auto" | "manual";109  private filters: Record<string, unknown> | undefined;110111  private data: GeoJSON.FeatureCollection = EMPTY_FC;112  private byId = new Map<string, { hash: number; property: MapProperty }>();113114  /** Jeu complet poussé par l'app/l'adaptateur, avant découpe éventuelle. */115  private rawProperties: MapProperty[] = [];116  /** Zone de découpe côté client (apps sans filtre polygone serveur). */117  private clipPolygon: [number, number][] | null = null;118  private lastTotalCount: number | undefined = undefined;119120  private selectedId: string | null = null;121  private hoveredId: string | null = null;122  private searchedBBox: BBox | null = null;123  private searchAreaDirty = false;124  private destroyed = false;125  private overlayInstalled = false;126  private overlayAttempts = 0;127  /** Id de source courant — tourne à chaque reconstruction : l'état worker128   *  d'un id « zombifié » par une recomposition Standard est irrécupérable. */129  private sourceId = PROPERTY_SOURCE_ID;130  private sourceGen = 0;131  private verifyTimer: ReturnType<typeof setTimeout> | null = null;132  private clusterOptions: {133    maxZoom: number;134    radius: number;135    valueClamp?: [number, number];136  };137138  /** Mouvement en cours déclenché par le code (fitBounds, easeTo interne) :139   *  consommé par handleMoveEnd pour distinguer le geste de l'utilisateur. */140  private programmaticMove = false;141142  /** Outil de dessin de zone : tracé en cours + polygone posé. */143  private drawing = false;144  private drawVertices: [number, number][] = [];145  private drawnPolygon: [number, number][] | null = null;146  private drawCursor: [number, number] | null = null;147  private drawKeyHandler: ((e: KeyboardEvent) => void) | null = null;148149  /** Annonces déjà consultées — pastilles atténuées (feature-state seen). */150  private seenIds: Set<string> = new Set();151152  /** Bâtiment mis en évidence (fiche) — featureset "buildings" du Standard. */153  private buildingFocus: { lng: number; lat: number } | null = null;154  private focusedBuildings: TargetFeature[] = [];155  private focusAttempts = 0;156  private focusRetryTimer: ReturnType<typeof setTimeout> | null = null;157158  constructor(options: KaMapOptions) {159    this.theme = options.theme;160    this.mode = options.mode ?? "light";161    this.basemap = options.basemap;162    this.searchMode = options.searchMode ?? "manual";163    this.clusterOptions = {164      // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` —165      // une valeur fractionnaire plante le worker (Invalid array length)166      maxZoom: Math.round(options.cluster?.maxZoom ?? 14),167      radius: options.cluster?.radius ?? 46,168      valueClamp: options.cluster?.valueClamp,169    };170171    this.map = new mapboxgl.Map({172      container: options.container,173      accessToken: options.mapboxToken,174      style: buildKaStyle(this.mode, this.theme),175      center: [options.center?.lng ?? -71.254, options.center?.lat ?? 46.813],176      zoom: options.zoom ?? 11,177      pitch: options.pitch ?? 50,178      minZoom: options.minZoom ?? 4,179      maxZoom: options.maxZoom ?? 18.5,180      attributionControl: false,181      cooperativeGestures: options.cooperativeGestures ?? false,182    });183    // Attribution repliée (ⓘ) — conforme, discrète ; logo Mapbox conservé.184    this.map.addControl(185      new mapboxgl.AttributionControl({ compact: true }),186      "bottom-right",187    );188    if (options.navControl !== false) {189      this.map.addControl(190        new mapboxgl.NavigationControl({ showCompass: false }),191        "top-right",192      );193    }194    // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise.195    this.map.touchZoomRotate.disableRotation();196    this.map.dragRotate.disable();197    this.map.keyboard.enable();198199    if (options.adapter) {200      this.scheduler = new BoundsQueryScheduler(options.adapter, options.query);201      this.scheduler.onResult((result) => {202        this.lastTotalCount = result.totalCount;203        this.setProperties(result.properties);204      });205      this.scheduler.onError((error) =>206        this.events.emit("error", { scope: "query", error }),207      );208      this.scheduler.onLoading((loading) =>209        this.events.emit("loading", { loading }),210      );211    }212213    // Poignée de débogage/tests E2E (comme l'ancien window._loukaMap).214    (globalThis as { __kaMap?: KaMap }).__kaMap = this;215216    // S'assurer que l'attribution démarre repliée (bouton ⓘ).217    this.map.once("load", () => {218      const attrib = options.container.querySelector(".mapboxgl-ctrl-attrib");219      attrib?.classList.remove("mapboxgl-compact-show");220      attrib?.removeAttribute("open");221    });222223    // Mapbox Standard est un style à imports : ses recompositions peuvent224    // « zombifier » une source ajoutée au mauvais moment (l'objet survit,225    // le worker ne traite plus rien). Stratégie : installer l'overlay au226    // chargement, appliquer la config du basemap APRÈS, puis VÉRIFIER que227    // des features sont réellement traitées — sinon on détruit et on228    // reconstruit la source et les couches (plafonné).229    this.map.once("load", () => {230      this.installOverlay();231      this.map.once("idle", () =>232        applyKaBasemapConfig(this.map, this.mode, this.basemap),233      );234    });235    this.map.on("styledata", () => {236      if (!this.overlayInstalled) return;237      if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) {238        this.map.once("idle", () => this.rebuildOverlay());239      }240      // Une recomposition du Standard peut perdre l'état du featureset241      // buildings : réappliquer le focus (idempotent, peu coûteux).242      if (this.buildingFocus) {243        this.focusedBuildings = [];244        this.scheduleBuildingFocus();245      }246    });247    // Tout geste direct annule le marquage « mouvement programmé » : si248    // l'utilisateur interrompt un fitBounds, le moveend redevient le sien.249    for (const gesture of ["dragstart", "wheel", "boxzoomstart", "dblclick"] as const) {250      this.map.on(gesture, () => {251        this.programmaticMove = false;252      });253    }254    this.map.on("touchmove", () => {255      this.programmaticMove = false;256    });257    this.map.on("moveend", () => this.handleMoveEnd());258    this.map.on("error", (e) => {259      // Garder la trace en console (MapLibre se tairait dès qu'un handler260      // existe — les couches invalides deviendraient indétectables).261      console.warn("[ka-maps]", e.error);262      this.events.emit("error", { scope: "tiles", error: e.error });263    });264265    this.map.on("click", (e) => this.handleBaseClick(e));266    this.map.on("mousemove", (e) => {267      if (this.drawing && this.drawVertices.length > 0) {268        this.drawCursor = [e.lngLat.lng, e.lngLat.lat];269        this.updateDrawSource();270      }271    });272    this.bindLayerInteractions();273  }274275  // ---------------------------------------------------------------- overlay276277  /** (Re)install property source + layers — self-healing after any style278   *  recomposition (Mapbox Standard imports). */279  private installOverlay(): void {280    if (this.destroyed) return;281    const map = this.map;282    registerPillImages(map, this.theme);283    this.overlayInstalled = true;284285    if (!map.getSource(this.sourceId)) {286      map.addSource(this.sourceId, {287        type: "geojson",288        data: this.data,289        cluster: true,290        clusterMaxZoom: this.clusterOptions.maxZoom,291        clusterRadius: this.clusterOptions.radius,292        clusterProperties: buildClusterProperties(293          this.clusterOptions.valueClamp,294        ) as never,295        // (pas de promoteId : les ids de features sont déjà des numériques296        // hachés par propertiesToGeoJSON)297      });298    }299    for (const layer of buildPropertyLayers(this.theme, this.sourceId)) {300      if (!map.getLayer(layer.id)) map.addLayer(layer);301    }302    this.installDrawLayers();303    this.applyFeatureStates();304    this.scheduleOverlayVerify();305  }306307  /** Détruit et réinstalle la source + les couches (source zombie). */308  private rebuildOverlay(): void {309    if (this.destroyed) return;310    const map = this.map;311    try {312      for (const id of Object.values(LAYER_IDS)) {313        if (map.getLayer(id)) map.removeLayer(id);314      }315      if (map.getSource(this.sourceId)) map.removeSource(this.sourceId);316    } catch {317      // style en transition : la prochaine vérification retentera318    }319    this.sourceGen++;320    this.sourceId = `${PROPERTY_SOURCE_ID}-r${this.sourceGen}`;321    this.installOverlay();322  }323324  /** Vérifie que le worker traite bien la source ; sinon, reconstruit. */325  private scheduleOverlayVerify(): void {326    if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);327    this.verifyTimer = setTimeout(() => {328      this.verifyTimer = null;329      if (this.destroyed || this.data.features.length === 0) return;330      let processed = 0;331      try {332        processed = this.map.querySourceFeatures(this.sourceId).length;333      } catch {334        return;335      }336      if (processed > 0) {337        this.overlayAttempts = 0;338        return;339      }340      if (this.overlayAttempts >= 6) return;341      this.overlayAttempts++;342      console.warn(343        `[ka-maps] source non traitée par le worker — reconstruction (${this.overlayAttempts}/6)`,344      );345      this.rebuildOverlay();346    }, 1200);347  }348349  private bindLayerInteractions(): void {350    const map = this.map;351352    map.on("click", LAYER_IDS.clusters, (e) => this.expandCluster(e));353    map.on("click", LAYER_IDS.pointPill, (e) => this.clickProperty(e));354    map.on("click", LAYER_IDS.pointDot, (e) => this.clickProperty(e));355356    for (const id of [LAYER_IDS.clusters, LAYER_IDS.pointPill, LAYER_IDS.pointDot]) {357      map.on("mouseenter", id, () => {358        map.getCanvas().style.cursor = "pointer";359      });360      map.on("mouseleave", id, () => {361        map.getCanvas().style.cursor = "";362      });363    }364365    map.on("mousemove", LAYER_IDS.pointPill, (e) => this.hoverFrom(e));366    map.on("mousemove", LAYER_IDS.pointDot, (e) => this.hoverFrom(e));367    map.on("mouseleave", LAYER_IDS.pointPill, () => this.setHovered(null, "map"));368    map.on("mouseleave", LAYER_IDS.pointDot, () => this.setHovered(null, "map"));369370    // Survol d'un cluster : fourchette de prix du contenu (tooltip app).371    map.on("mousemove", LAYER_IDS.clusters, (e) => {372      const f = e.features?.[0];373      if (!f) return;374      const props = f.properties ?? {};375      const count = (props["point_count"] as number | undefined) ?? 0;376      const rawMin = props["valueMin"] as number | undefined;377      const rawMax = props["valueMax"] as number | undefined;378      // 99999999 / 0 sont les sentinelles des agrégats pour les valeurs379      // nulles (voir buildClusterProperties) — jamais de vrais loyers.380      const min =381        typeof rawMin === "number" && Number.isFinite(rawMin) && rawMin < 99999999382          ? rawMin383          : null;384      const max =385        typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0386          ? rawMax387          : null;388      this.events.emit("clusterHover", {389        info: { count, min, max, x: e.point.x, y: e.point.y },390      });391    });392    map.on("mouseleave", LAYER_IDS.clusters, () =>393      this.events.emit("clusterHover", { info: null }),394    );395  }396397  private featureId(feature: LayerFeature): string | null {398    const raw = feature.properties?.["id"];399    return typeof raw === "string" ? raw : null;400  }401402  private expandCluster(e: MapLayerMouseEvent): void {403    if (this.drawing) return;404    const feature = e.features?.[0];405    if (!feature) return;406    const clusterId = feature.properties?.["cluster_id"] as number | undefined;407    const count = (feature.properties?.["point_count"] as number | undefined) ?? 0;408    const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;409    if (clusterId === undefined || !source) return;410    source.getClusterExpansionZoom(clusterId, (err, zoom) => {411      if (this.destroyed || err || zoom == null) return;412      const [lng, lat] = (feature.geometry as GeoJSON.Point).coordinates;413      // Le clic sur un cluster est un geste de l'utilisateur : le moveend414      // qui suit doit compter comme tel (liste ajustée en conséquence).415      this.programmaticMove = false;416      this.map.easeTo({417        center: [lng as number, lat as number],418        zoom: Math.min(zoom + 0.25, this.map.getMaxZoom()),419        duration: 480,420      });421      this.events.emit("clusterExpand", { count });422    });423  }424425  private clickProperty(e: MapLayerMouseEvent): void {426    if (this.drawing) return;427    const feature = e.features?.[0];428    if (!feature) return;429    const id = this.featureId(feature);430    if (id) this.select(id, "map");431  }432433  private handleBaseClick(e: MapMouseEvent): void {434    // Mode dessin : chaque clic pose un sommet ; un clic près du premier435    // sommet (≥ 3 posés) ferme le polygone.436    if (this.drawing) {437      this.addDrawVertex(e);438      return;439    }440    // Clicks that hit property layers are handled there; a bare map click441    // clears the selection.442    const hits = this.map.queryRenderedFeatures(e.point, {443      layers: [LAYER_IDS.pointPill, LAYER_IDS.pointDot, LAYER_IDS.clusters].filter(444        (l) => Boolean(this.map.getLayer(l)),445      ),446    });447    if (hits.length === 0 && this.selectedId) this.select(null, "map");448  }449450  private hoverFrom(e: MapLayerMouseEvent): void {451    const feature = e.features?.[0];452    const id = feature ? this.featureId(feature) : null;453    this.setHovered(id, "map");454  }455456  // ------------------------------------------------------------------ data457458  /** Replace the rendered property set (adapter results or app-pushed). */459  setProperties(properties: MapProperty[]): void {460    this.rawProperties = properties;461    this.renderProperties();462  }463464  /** Applique le jeu courant (après découpe polygone éventuelle). */465  private renderProperties(): void {466    const poly = this.clipPolygon;467    const properties =468      poly && poly.length >= 3469        ? this.rawProperties.filter((p) =>470            pointInPolygon(p.longitude, p.latitude, poly),471          )472        : this.rawProperties;473    this.byId.clear();474    for (const p of properties) {475      if (!isValidCoordinate(p.latitude, p.longitude)) continue;476      this.byId.set(p.id, { hash: hashId(p.id), property: p });477    }478    this.data = propertiesToGeoJSON(properties);479    const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;480    if (source) source.setData(this.data as never);481    // Keep selection if the item is still visible, otherwise drop it.482    if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app");483    this.applyFeatureStates();484    if (this.overlayInstalled) this.scheduleOverlayVerify();485    this.events.emit("data", {486      count: this.byId.size,487      totalCount: this.lastTotalCount,488    });489  }490491  /**492   * Découpe côté client : ne rendre que les items dans le polygone (apps493   * dont l'API ne filtre pas par polygone). Passer null pour tout rendre.494   * Indépendant du polygone AFFICHÉ (setDrawnPolygon) — l'app appelle495   * généralement les deux ensemble.496   */497  setClipPolygon(polygon: [number, number][] | null): void {498    this.clipPolygon = polygon && polygon.length >= 3 ? polygon : null;499    this.renderProperties();500  }501502  getProperty(id: string): MapProperty | undefined {503    return this.byId.get(id)?.property;504  }505506  /** Items actuellement rendus, dans l'ordre du jeu de données. */507  getVisibleProperties(): MapProperty[] {508    return [...this.byId.values()].map((e) => e.property);509  }510511  /** Current filters forwarded to the adapter on every query. */512  setFilters(filters: Record<string, unknown> | undefined): void {513    this.filters = filters;514    this.scheduler?.invalidate();515    this.refetch();516  }517518  /** Fetch data for the current viewport immediately (Search this area). */519  searchThisArea(): void {520    this.refetch();521  }522523  refetch(): void {524    if (!this.scheduler) return;525    const bbox = this.currentBBox();526    if (!bbox) return;527    this.searchedBBox = bbox;528    this.setDirty(false);529    this.scheduler.requestNow({ bbox, zoom: this.map.getZoom(), filters: this.filters });530  }531532  setSearchMode(mode: "auto" | "manual"): void {533    this.searchMode = mode;534    if (mode === "auto" && this.searchAreaDirty) this.refetch();535  }536537  getSearchMode(): "auto" | "manual" {538    return this.searchMode;539  }540541  private handleMoveEnd(): void {542    const center = this.map.getCenter();543    const byUser = !this.programmaticMove;544    this.programmaticMove = false;545    this.events.emit("moveend", {546      center: { lat: center.lat, lng: center.lng },547      zoom: this.map.getZoom(),548      byUser,549    });550    if (!this.scheduler) return;551552    const bbox = this.currentBBox();553    if (!bbox) return;554555    if (this.searchMode === "auto") {556      this.searchedBBox = bbox;557      this.setDirty(false);558      this.scheduler.request({ bbox, zoom: this.map.getZoom(), filters: this.filters });559      return;560    }561    // Manual mode: flag divergence, let the app show "Search this area".562    if (!this.searchedBBox) {563      this.refetch(); // first load564      return;565    }566    const tolerant = expandBBox(this.searchedBBox, 0.15);567    this.setDirty(!bboxContains(tolerant, bbox));568  }569570  private setDirty(dirty: boolean): void {571    if (this.searchAreaDirty === dirty) return;572    this.searchAreaDirty = dirty;573    this.events.emit("searchAreaDirty", { dirty });574  }575576  // ------------------------------------------------------------- selection577578  /** Select from map click or app (card click). Pass null to clear.579   *  Selecting the already-selected id is a no-op (prevents feedback loops580   *  when apps mirror the selection back declaratively). */581  select(id: string | null, origin: "map" | "app"): void {582    if (id === this.selectedId) return;583    const previous = this.selectedId;584    this.selectedId = id;585    if (previous) this.setFeatureState(previous, { selected: false });586    if (id) this.setFeatureState(id, { selected: true });587    this.refreshPillSelection();588    this.events.emit("select", { propertyId: id, origin });589590    // Card-driven selection: reveal the item without a jarring recenter.591    if (origin === "app" && id) {592      const entry = this.byId.get(id);593      if (entry) {594        const { latitude, longitude } = entry.property;595        const bounds = this.map.getBounds();596        if (bounds && !bounds.contains([longitude, latitude])) {597          this.programmaticMove = true;598          this.map.easeTo({ center: [longitude, latitude], duration: 420 });599        }600      }601    }602  }603604  getSelectedId(): string | null {605    return this.selectedId;606  }607608  setHovered(id: string | null, origin: "map" | "app"): void {609    if (id === this.hoveredId) return;610    if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: false });611    this.hoveredId = id;612    if (id) this.setFeatureState(id, { hovered: true });613    if (origin === "map") this.events.emit("hover", { propertyId: id });614  }615616  /** Dim everything except the given ids (Ka Lens, filter emphasis). */617  setDimmedExcept(ids: Set<string> | null): void {618    for (const [id, entry] of this.byId) {619      this.map.setFeatureState(620        { source: this.sourceId, id: entry.hash },621        { dimmed: ids !== null && !ids.has(id) },622      );623    }624  }625626  /** Annonces déjà consultées : pastilles atténuées (état « vu »). */627  setSeenIds(ids: Iterable<string>): void {628    this.seenIds = new Set(ids);629    this.applySeenStates();630  }631632  private applySeenStates(): void {633    if (this.seenIds.size === 0) return;634    for (const [id, entry] of this.byId) {635      if (!this.seenIds.has(id)) continue;636      try {637        this.map.setFeatureState(638          { source: this.sourceId, id: entry.hash },639          { seen: true },640        );641      } catch {642        // source en transition — réappliqué par applyFeatureStates643      }644    }645  }646647  private setFeatureState(id: string, state: Record<string, boolean>): void {648    const entry = this.byId.get(id);649    if (!entry) return;650    try {651      this.map.setFeatureState({ source: this.sourceId, id: entry.hash }, state);652    } catch {653      // Source may be mid-reload during a style swap; states reapply after.654    }655  }656657  private applyFeatureStates(): void {658    if (this.selectedId) this.setFeatureState(this.selectedId, { selected: true });659    if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: true });660    this.applySeenStates();661    this.refreshPillSelection();662  }663664  /** icon-image est une propriété layout (feature-state interdit) : la665   *  pastille sélectionnée est réinjectée dans l'expression au besoin. */666  private refreshPillSelection(): void {667    if (!this.map.getLayer(LAYER_IDS.pointPill)) return;668    try {669      this.map.setLayoutProperty(670        LAYER_IDS.pointPill,671        "icon-image",672        pillIconExpression(this.selectedId),673      );674    } catch {675      // style en cours de rechargement — réappliqué par installOverlay676    }677  }678679  // ----------------------------------------------------------------- dessin680681  /** Démarre le tracé d'une zone : chaque clic pose un sommet, un clic sur682   *  le premier sommet (≥ 3) ferme le polygone, Échap annule. */683  startDraw(): void {684    if (this.drawing) return;685    this.drawing = true;686    this.drawVertices = [];687    this.drawCursor = null;688    this.drawnPolygon = null;689    this.map.doubleClickZoom.disable();690    this.map.getCanvas().style.cursor = "crosshair";691    this.drawKeyHandler = (e: KeyboardEvent) => {692      if (e.key === "Escape") this.cancelDraw();693    };694    window.addEventListener("keydown", this.drawKeyHandler);695    this.updateDrawSource();696    this.events.emit("draw", { polygon: null, drawing: true });697  }698699  /** Annule le tracé en cours (Échap ou bouton). */700  cancelDraw(): void {701    if (!this.drawing) return;702    this.endDrawMode();703    this.drawVertices = [];704    this.drawCursor = null;705    this.updateDrawSource();706    this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });707  }708709  /** Efface le polygone posé (retrait de la puce « Zone dessinée »). */710  clearDrawnPolygon(): void {711    if (this.drawing) this.endDrawMode();712    this.drawing = false;713    this.drawVertices = [];714    this.drawCursor = null;715    this.drawnPolygon = null;716    this.updateDrawSource();717    this.events.emit("draw", { polygon: null, drawing: false });718  }719720  /** Restaure un polygone (URL partagée) sans passer par le tracé. */721  setDrawnPolygon(polygon: [number, number][] | null): void {722    this.drawnPolygon = polygon && polygon.length >= 3 ? polygon : null;723    this.updateDrawSource();724    this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });725  }726727  getDrawnPolygon(): [number, number][] | null {728    return this.drawnPolygon;729  }730731  isDrawing(): boolean {732    return this.drawing;733  }734735  private endDrawMode(): void {736    this.drawing = false;737    this.map.doubleClickZoom.enable();738    this.map.getCanvas().style.cursor = "";739    if (this.drawKeyHandler) {740      window.removeEventListener("keydown", this.drawKeyHandler);741      this.drawKeyHandler = null;742    }743  }744745  private addDrawVertex(e: MapMouseEvent): void {746    const first = this.drawVertices[0];747    if (first && this.drawVertices.length >= 3) {748      const firstPt = this.map.project(first);749      const dx = firstPt.x - e.point.x;750      const dy = firstPt.y - e.point.y;751      if (Math.hypot(dx, dy) < 14) {752        // Fermeture : clic sur le premier sommet.753        this.drawnPolygon = [...this.drawVertices];754        this.endDrawMode();755        this.drawCursor = null;756        this.updateDrawSource();757        this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });758        return;759      }760    }761    this.drawVertices.push([e.lngLat.lng, e.lngLat.lat]);762    this.updateDrawSource();763  }764765  private drawFeatureCollection(): GeoJSON.FeatureCollection {766    const features: GeoJSON.Feature[] = [];767    if (this.drawnPolygon) {768      const ring = [...this.drawnPolygon, this.drawnPolygon[0] as [number, number]];769      features.push({770        type: "Feature",771        properties: { role: "zone" },772        geometry: { type: "Polygon", coordinates: [ring] },773      });774    } else if (this.drawVertices.length > 0) {775      const line = this.drawCursor776        ? [...this.drawVertices, this.drawCursor]777        : [...this.drawVertices];778      if (line.length >= 2) {779        features.push({780          type: "Feature",781          properties: { role: "trace" },782          geometry: { type: "LineString", coordinates: line },783        });784      }785      for (const v of this.drawVertices) {786        features.push({787          type: "Feature",788          properties: { role: "sommet" },789          geometry: { type: "Point", coordinates: v },790        });791      }792    }793    return { type: "FeatureCollection", features };794  }795796  private updateDrawSource(): void {797    const source = this.map.getSource(DRAW_SOURCE_ID) as GeoJSONSource | undefined;798    if (source) source.setData(this.drawFeatureCollection() as never);799  }800801  /** Couches du dessin — réinstallées avec l'overlay (styledata). */802  private installDrawLayers(): void {803    const map = this.map;804    const accent = markerTokens(this.theme, "highlight").background;805    if (!map.getSource(DRAW_SOURCE_ID)) {806      map.addSource(DRAW_SOURCE_ID, {807        type: "geojson",808        data: this.drawFeatureCollection(),809      });810    }811    if (!map.getLayer(DRAW_LAYER_IDS.fill)) {812      map.addLayer({813        id: DRAW_LAYER_IDS.fill,814        slot: "top",815        type: "fill",816        source: DRAW_SOURCE_ID,817        filter: ["==", ["geometry-type"], "Polygon"],818        paint: { "fill-color": accent, "fill-opacity": 0.08 },819      });820    }821    if (!map.getLayer(DRAW_LAYER_IDS.line)) {822      map.addLayer({823        id: DRAW_LAYER_IDS.line,824        slot: "top",825        type: "line",826        source: DRAW_SOURCE_ID,827        filter: ["!=", ["geometry-type"], "Point"],828        paint: {829          "line-color": accent,830          "line-width": 2.25,831          "line-dasharray": [832            "case",833            ["==", ["get", "role"], "trace"],834            ["literal", [2, 1.6]],835            ["literal", [1, 0]],836          ] as never,837        },838      });839    }840    if (!map.getLayer(DRAW_LAYER_IDS.vertex)) {841      map.addLayer({842        id: DRAW_LAYER_IDS.vertex,843        slot: "top",844        type: "circle",845        source: DRAW_SOURCE_ID,846        filter: ["==", ["geometry-type"], "Point"],847        paint: {848          "circle-radius": 5,849          "circle-color": "#ffffff",850          "circle-stroke-color": accent,851          "circle-stroke-width": 2,852        },853      });854    }855  }856857  // ------------------------------------------------------ building spotlight858859  /**860   * Met en évidence le bâtiment situé aux coordonnées données (fiche d'une861   * propriété) : l'empreinte 3D du featureset « buildings » de Mapbox862   * Standard passe à l'état `select`, coloré via `colorBuildingSelect`863   * (option `basemap` ou `opts.color`). Passer `null` pour effacer.864   * Résilient : re-tenté tant que les tuiles ne sont pas rendues, et865   * réappliqué après chaque recomposition du style.866   */867  focusBuilding(868    at: { lng: number; lat: number } | null,869    opts?: { color?: string },870  ): void {871    for (const f of this.focusedBuildings) {872      try {873        this.map.setFeatureState(f, { select: false });874      } catch {875        // style en transition — l'état disparaît avec lui876      }877    }878    this.focusedBuildings = [];879    this.focusAttempts = 0;880    if (this.focusRetryTimer !== null) {881      clearTimeout(this.focusRetryTimer);882      this.focusRetryTimer = null;883    }884    this.buildingFocus = at;885    if (!at) return;886    if (opts?.color) {887      try {888        this.map.setConfigProperty("basemap", "colorBuildingSelect", opts.color);889      } catch {890        // style pas encore chargé : la couleur viendra de applyKaBasemapConfig891      }892    }893    this.scheduleBuildingFocus();894  }895896  private scheduleBuildingFocus(): void {897    if (!this.buildingFocus || this.destroyed) return;898    if (this.map.isStyleLoaded() && this.map.loaded()) {899      this.applyBuildingFocus();900    } else {901      this.map.once("idle", () => this.applyBuildingFocus());902    }903  }904905  private applyBuildingFocus(): void {906    const focus = this.buildingFocus;907    if (!focus || this.destroyed || this.focusedBuildings.length > 0) return;908909    const pt = this.map.project([focus.lng, focus.lat]);910    // Le point géocodé tombe parfois sur la rue devant l'immeuble : on911    // interroge une boîte serrée, puis on élargit progressivement.912    const pads = [4, 14, 34];913    let found: TargetFeature[] = [];914    for (const pad of pads) {915      try {916        found = this.map.queryRenderedFeatures(917          [918            [pt.x - pad, pt.y - pad],919            [pt.x + pad, pt.y + pad],920          ],921          { target: { featuresetId: "buildings", importId: "basemap" } },922        );923      } catch {924        found = [];925      }926      if (found.length > 0) break;927    }928929    if (found.length === 0) {930      // Tuiles vecteur pas encore prêtes, ou zone sans empreinte de931      // bâtiment : quelques re-tentatives espacées, puis on abandonne932      // proprement (le marqueur de prix reste le repère).933      if (this.focusAttempts++ < 8) {934        this.focusRetryTimer = setTimeout(() => {935          this.focusRetryTimer = null;936          this.scheduleBuildingFocus();937        }, 400);938      }939      return;940    }941942    // Un immeuble = souvent plusieurs morceaux d'empreinte : on sélectionne943    // toutes les parties partageant l'id de la plus proche du point.944    const primary = found[0];945    if (!primary) return;946    const primaryId = primary.id;947    const parts = found.filter((f) => f.id === primaryId);948    for (const f of parts) {949      try {950        this.map.setFeatureState(f, { select: true });951      } catch {952        // recomposition en cours : le handler styledata re-planifiera953      }954    }955    this.focusedBuildings = parts;956    this.events.emit("buildingFocus", { found: true });957  }958959  /** Recentre la caméra en douceur (fiche, spotlight, deep-link). */960  flyTo(961    center: { lng: number; lat: number },962    opts?: { zoom?: number; pitch?: number; bearing?: number; duration?: number },963  ): void {964    this.programmaticMove = true;965    this.map.easeTo({966      center: [center.lng, center.lat],967      zoom: opts?.zoom,968      pitch: opts?.pitch,969      bearing: opts?.bearing,970      duration: opts?.duration ?? 900,971    });972  }973974  /** Cadre l'emprise donnée avec une animation douce. Mouvement programmé :975   *  le moveend qui suit est émis avec `byUser: false` (la vue n'est pas976   *  « volée » à l'utilisateur au sens de la synchro liste↔carte). */977  fitBounds(978    bbox: BBox,979    opts?: { padding?: number; maxZoom?: number; duration?: number },980  ): void {981    this.programmaticMove = true;982    this.map.fitBounds(983      [984        [bbox.west, bbox.south],985        [bbox.east, bbox.north],986      ],987      {988        padding: opts?.padding ?? 56,989        maxZoom: opts?.maxZoom ?? 16,990        duration: opts?.duration ?? 850,991      },992    );993  }994995  /** Cadre l'ensemble des propriétés affichées (ou fournies). */996  fitToProperties(997    properties?: MapProperty[],998    opts?: { padding?: number; maxZoom?: number; duration?: number },999  ): void {1000    const list = properties ?? [...this.byId.values()].map((e) => e.property);1001    const bbox = bboxOfProperties(list);1002    if (bbox) this.fitBounds(bbox, opts);1003  }10041005  // ---------------------------------------------------------------- theming10061007  /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */1008  setTilt(on: boolean): void {1009    this.programmaticMove = true;1010    this.map.easeTo({ pitch: on ? 55 : 0, duration: 600 });1011  }10121013  isTilted(): boolean {1014    return this.map.getPitch() > 5;1015  }10161017  setMode(mode: KaMapMode): void {1018    if (mode === this.mode) return;1019    this.mode = mode;1020    // Jour/nuit via la config du style Standard — aucun rechargement.1021    applyKaBasemapConfig(this.map, this.mode, this.basemap);1022  }10231024  getMode(): KaMapMode {1025    return this.mode;1026  }10271028  getTheme(): KaMapTheme {1029    return this.theme;1030  }10311032  // ------------------------------------------------------------------ state10331034  currentBBox(): BBox | null {1035    const b = this.map.getBounds();1036    if (!b) return null;1037    return {1038      west: b.getWest(),1039      south: b.getSouth(),1040      east: b.getEast(),1041      north: b.getNorth(),1042    };1043  }10441045  getState(): KaMapState {1046    const center = this.map.getCenter();1047    return {1048      center: { lat: center.lat, lng: center.lng },1049      zoom: this.map.getZoom(),1050      bearing: this.map.getBearing(),1051      pitch: this.map.getPitch(),1052      bounds: this.currentBBox(),1053      selectedPropertyId: this.selectedId,1054      hoveredPropertyId: this.hoveredId,1055      activeLayers: Object.values(LAYER_IDS).filter((l) =>1056        Boolean(this.map.getLayer(l)),1057      ),1058      searchAreaDirty: this.searchAreaDirty,1059      drawnGeometry: this.drawnPolygon1060        ? {1061            type: "Polygon",1062            coordinates: [[...this.drawnPolygon, this.drawnPolygon[0] as [number, number]]],1063          }1064        : null,1065    };1066  }10671068  destroy(): void {1069    this.destroyed = true;1070    if (this.drawKeyHandler) {1071      window.removeEventListener("keydown", this.drawKeyHandler);1072      this.drawKeyHandler = null;1073    }1074    if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);1075    if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer);1076    this.scheduler?.destroy();1077    this.events.clear();1078    this.map.remove();1079  }1080}1081
Files · 30fa6e4cf899c7ac456a9801f72df3ea1395b625
Menu

Theme