/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * KaMap — the framework engine. Owns the Mapbox GL instance, the Ka base * style, the property source/layers, feature states, selection, the * viewport→data pipeline and all map event listeners. Apps talk to this * class (directly or through the React bindings), never to Mapbox GL. */ import mapboxgl from "mapbox-gl"; import type { GeoJSONSource, Map as MapboxMap, MapLayerMouseEvent, MapMouseEvent, TargetFeature, } from "mapbox-gl"; /** Feature reçue par les gestionnaires de clic/survol des couches. */ type LayerFeature = NonNullable[number]; import type { BBox, KaDataAdapter, KaMapState, MapProperty, } from "../types/index.js"; import { KaEventHub } from "./events.js"; import { applyKaBasemapConfig, buildKaStyle, type KaBasemapOptions, } from "../styles/kaBaseStyle.js"; import { markerTokens, type KaMapMode, type KaMapTheme } from "../theming/tokens.js"; import { BoundsQueryScheduler, type BoundsQueryOptions, } from "../services/boundsQuery.js"; import { bboxContains, bboxOfProperties, expandBBox, hashId, isValidCoordinate, pointInPolygon, propertiesToGeoJSON, } from "../utils/geo.js"; import { buildClusterProperties, buildPropertyLayers, LAYER_IDS, pillIconExpression, PROPERTY_SOURCE_ID, registerPillImages, } from "../layers/propertyLayer.js"; export interface KaMapOptions { container: HTMLElement; theme: KaMapTheme; /** Jeton public Mapbox (pk.…) — conçu pour être exposé côté client. */ mapboxToken: string; mode?: KaMapMode; adapter?: KaDataAdapter; center?: { lat: number; lng: number }; zoom?: number; /** Inclinaison initiale — 3D par défaut (50°), bouton 2D pour revenir. */ pitch?: number; minZoom?: number; maxZoom?: number; /** Réglages du fond Mapbox Standard (réalisme, lumière, repères 3D). */ basemap?: KaBasemapOptions; /** "auto": refetch after every settled move. "manual": show Search-this-area. */ searchMode?: "auto" | "manual"; query?: BoundsQueryOptions; /** Cooperative gestures on embedded maps (two-finger pan hint). */ cooperativeGestures?: boolean; /** Boutons +/− Mapbox natifs. false quand l'app fournit sa propre * toolbar (KaMapToolbar) — le pincement/molette reste actif. */ navControl?: boolean; /** Clustering tuning. Lou-Ka style per-building geocoding wants * maxZoom 15 so stacked units stay grouped as long as possible. * `valueClamp` bounds each item's contribution to the bubble mean. */ cluster?: { maxZoom?: number; radius?: number; valueClamp?: [number, number]; /** N minimal d'items pour afficher la valeur sous une bulle. */ valueMinCount?: number; }; } const EMPTY_FC: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [], }; /** Couches de l'outil « dessiner une zone » (Ka Draw). */ const DRAW_SOURCE_ID = "ka-draw"; const DRAW_LAYER_IDS = { fill: "ka-draw-fill", line: "ka-draw-line", vertex: "ka-draw-vertex", } as const; export class KaMap { readonly events = new KaEventHub(); readonly map: MapboxMap; private theme: KaMapTheme; private mode: KaMapMode; private basemap: KaBasemapOptions | undefined; private scheduler: BoundsQueryScheduler | null = null; private searchMode: "auto" | "manual"; private filters: Record | undefined; private data: GeoJSON.FeatureCollection = EMPTY_FC; private byId = new Map(); /** Jeu complet poussé par l'app/l'adaptateur, avant découpe éventuelle. */ private rawProperties: MapProperty[] = []; /** Zone de découpe côté client (apps sans filtre polygone serveur). */ private clipPolygon: [number, number][] | null = null; private lastTotalCount: number | undefined = undefined; private selectedId: string | null = null; private hoveredId: string | null = null; private searchedBBox: BBox | null = null; private searchAreaDirty = false; private destroyed = false; private overlayInstalled = false; private overlayAttempts = 0; /** Id de source courant — tourne à chaque reconstruction : l'état worker * d'un id « zombifié » par une recomposition Standard est irrécupérable. */ private sourceId = PROPERTY_SOURCE_ID; private sourceGen = 0; private verifyTimer: ReturnType | null = null; private clusterOptions: { maxZoom: number; radius: number; valueClamp?: [number, number]; valueMinCount?: number; }; /** Mouvement en cours déclenché par le code (fitBounds, easeTo interne) : * consommé par handleMoveEnd pour distinguer le geste de l'utilisateur. */ private programmaticMove = false; /** Outil de dessin de zone : tracé en cours + polygone posé. */ private drawing = false; private drawVertices: [number, number][] = []; private drawnPolygon: [number, number][] | null = null; private drawCursor: [number, number] | null = null; private drawKeyHandler: ((e: KeyboardEvent) => void) | null = null; /** Annonces déjà consultées — pastilles atténuées (feature-state seen). */ private seenIds: Set = new Set(); /** Bâtiment mis en évidence (fiche) — featureset "buildings" du Standard. */ private buildingFocus: { lng: number; lat: number } | null = null; private focusedBuildings: TargetFeature[] = []; private focusAttempts = 0; private focusRetryTimer: ReturnType | null = null; constructor(options: KaMapOptions) { this.theme = options.theme; this.mode = options.mode ?? "light"; this.basemap = options.basemap; this.searchMode = options.searchMode ?? "manual"; this.clusterOptions = { // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` — // une valeur fractionnaire plante le worker (Invalid array length) maxZoom: Math.round(options.cluster?.maxZoom ?? 14), radius: options.cluster?.radius ?? 46, valueClamp: options.cluster?.valueClamp, valueMinCount: options.cluster?.valueMinCount, }; this.map = new mapboxgl.Map({ container: options.container, accessToken: options.mapboxToken, style: buildKaStyle(this.mode, this.theme), center: [options.center?.lng ?? -71.254, options.center?.lat ?? 46.813], zoom: options.zoom ?? 11, pitch: options.pitch ?? 50, minZoom: options.minZoom ?? 4, maxZoom: options.maxZoom ?? 18.5, attributionControl: false, cooperativeGestures: options.cooperativeGestures ?? false, }); // Attribution repliée (ⓘ) — conforme, discrète ; logo Mapbox conservé. this.map.addControl( new mapboxgl.AttributionControl({ compact: true }), "bottom-right", ); if (options.navControl !== false) { this.map.addControl( new mapboxgl.NavigationControl({ showCompass: false }), "top-right", ); } // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise. this.map.touchZoomRotate.disableRotation(); this.map.dragRotate.disable(); this.map.keyboard.enable(); if (options.adapter) { this.scheduler = new BoundsQueryScheduler(options.adapter, options.query); this.scheduler.onResult((result) => { this.lastTotalCount = result.totalCount; this.setProperties(result.properties); }); this.scheduler.onError((error) => this.events.emit("error", { scope: "query", error }), ); this.scheduler.onLoading((loading) => this.events.emit("loading", { loading }), ); } // Poignée de débogage/tests E2E (comme l'ancien window._loukaMap). (globalThis as { __kaMap?: KaMap }).__kaMap = this; // S'assurer que l'attribution démarre repliée (bouton ⓘ). this.map.once("load", () => { const attrib = options.container.querySelector(".mapboxgl-ctrl-attrib"); attrib?.classList.remove("mapboxgl-compact-show"); attrib?.removeAttribute("open"); }); // Mapbox Standard est un style à imports : ses recompositions peuvent // « zombifier » une source ajoutée au mauvais moment (l'objet survit, // le worker ne traite plus rien). Stratégie : installer l'overlay au // chargement, appliquer la config du basemap APRÈS, puis VÉRIFIER que // des features sont réellement traitées — sinon on détruit et on // reconstruit la source et les couches (plafonné). this.map.once("load", () => { this.installOverlay(); this.map.once("idle", () => applyKaBasemapConfig(this.map, this.mode, this.basemap), ); }); this.map.on("styledata", () => { if (!this.overlayInstalled) return; if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) { this.map.once("idle", () => this.rebuildOverlay()); } // Une recomposition du Standard peut perdre l'état du featureset // buildings : réappliquer le focus (idempotent, peu coûteux). if (this.buildingFocus) { this.focusedBuildings = []; this.scheduleBuildingFocus(); } }); // Tout geste direct annule le marquage « mouvement programmé » : si // l'utilisateur interrompt un fitBounds, le moveend redevient le sien. for (const gesture of ["dragstart", "wheel", "boxzoomstart", "dblclick"] as const) { this.map.on(gesture, () => { this.programmaticMove = false; }); } this.map.on("touchmove", () => { this.programmaticMove = false; }); this.map.on("moveend", () => this.handleMoveEnd()); this.map.on("error", (e) => { // Garder la trace en console (MapLibre se tairait dès qu'un handler // existe — les couches invalides deviendraient indétectables). console.warn("[ka-maps]", e.error); this.events.emit("error", { scope: "tiles", error: e.error }); }); this.map.on("click", (e) => this.handleBaseClick(e)); this.map.on("mousemove", (e) => { if (this.drawing && this.drawVertices.length > 0) { this.drawCursor = [e.lngLat.lng, e.lngLat.lat]; this.updateDrawSource(); } }); this.bindLayerInteractions(); } // ---------------------------------------------------------------- overlay /** (Re)install property source + layers — self-healing after any style * recomposition (Mapbox Standard imports). */ private installOverlay(): void { if (this.destroyed) return; const map = this.map; registerPillImages(map, this.theme); this.overlayInstalled = true; if (!map.getSource(this.sourceId)) { map.addSource(this.sourceId, { type: "geojson", data: this.data, cluster: true, clusterMaxZoom: this.clusterOptions.maxZoom, clusterRadius: this.clusterOptions.radius, clusterProperties: buildClusterProperties( this.clusterOptions.valueClamp, ) as never, // (pas de promoteId : les ids de features sont déjà des numériques // hachés par propertiesToGeoJSON) }); } for (const layer of buildPropertyLayers(this.theme, this.sourceId, { valueMinCount: this.clusterOptions.valueMinCount, })) { if (!map.getLayer(layer.id)) map.addLayer(layer); } this.installDrawLayers(); this.applyFeatureStates(); this.scheduleOverlayVerify(); } /** Détruit et réinstalle la source + les couches (source zombie). */ private rebuildOverlay(): void { if (this.destroyed) return; const map = this.map; try { for (const id of Object.values(LAYER_IDS)) { if (map.getLayer(id)) map.removeLayer(id); } if (map.getSource(this.sourceId)) map.removeSource(this.sourceId); } catch { // style en transition : la prochaine vérification retentera } this.sourceGen++; this.sourceId = `${PROPERTY_SOURCE_ID}-r${this.sourceGen}`; this.installOverlay(); } /** Vérifie que le worker traite bien la source ; sinon, reconstruit. */ private scheduleOverlayVerify(): void { if (this.verifyTimer !== null) clearTimeout(this.verifyTimer); this.verifyTimer = setTimeout(() => { this.verifyTimer = null; if (this.destroyed || this.data.features.length === 0) return; let processed = 0; try { processed = this.map.querySourceFeatures(this.sourceId).length; } catch { return; } if (processed > 0) { this.overlayAttempts = 0; return; } if (this.overlayAttempts >= 6) return; this.overlayAttempts++; console.warn( `[ka-maps] source non traitée par le worker — reconstruction (${this.overlayAttempts}/6)`, ); this.rebuildOverlay(); }, 1200); } private bindLayerInteractions(): void { const map = this.map; map.on("click", LAYER_IDS.clusters, (e) => this.expandCluster(e)); map.on("click", LAYER_IDS.pointPill, (e) => this.clickProperty(e)); map.on("click", LAYER_IDS.pointDot, (e) => this.clickProperty(e)); for (const id of [LAYER_IDS.clusters, LAYER_IDS.pointPill, LAYER_IDS.pointDot]) { map.on("mouseenter", id, () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", id, () => { map.getCanvas().style.cursor = ""; }); } map.on("mousemove", LAYER_IDS.pointPill, (e) => this.hoverFrom(e)); map.on("mousemove", LAYER_IDS.pointDot, (e) => this.hoverFrom(e)); map.on("mouseleave", LAYER_IDS.pointPill, () => this.setHovered(null, "map")); map.on("mouseleave", LAYER_IDS.pointDot, () => this.setHovered(null, "map")); // Survol d'un cluster : fourchette de prix du contenu (tooltip app). map.on("mousemove", LAYER_IDS.clusters, (e) => { const f = e.features?.[0]; if (!f) return; const props = f.properties ?? {}; const count = (props["point_count"] as number | undefined) ?? 0; const rawMin = props["valueMin"] as number | undefined; const rawMax = props["valueMax"] as number | undefined; // 99999999 / 0 sont les sentinelles des agrégats pour les valeurs // nulles (voir buildClusterProperties) — jamais de vrais loyers. const min = typeof rawMin === "number" && Number.isFinite(rawMin) && rawMin < 99999999 ? rawMin : null; const max = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0 ? rawMax : null; this.events.emit("clusterHover", { info: { count, min, max, x: e.point.x, y: e.point.y }, }); }); map.on("mouseleave", LAYER_IDS.clusters, () => this.events.emit("clusterHover", { info: null }), ); } private featureId(feature: LayerFeature): string | null { const raw = feature.properties?.["id"]; return typeof raw === "string" ? raw : null; } private expandCluster(e: MapLayerMouseEvent): void { if (this.drawing) return; const feature = e.features?.[0]; if (!feature) return; const clusterId = feature.properties?.["cluster_id"] as number | undefined; const count = (feature.properties?.["point_count"] as number | undefined) ?? 0; const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined; if (clusterId === undefined || !source) return; source.getClusterExpansionZoom(clusterId, (err, zoom) => { if (this.destroyed || err || zoom == null) return; const [lng, lat] = (feature.geometry as GeoJSON.Point).coordinates; // Le clic sur un cluster est un geste de l'utilisateur : le moveend // qui suit doit compter comme tel (liste ajustée en conséquence). this.programmaticMove = false; this.map.easeTo({ center: [lng as number, lat as number], zoom: Math.min(zoom + 0.25, this.map.getMaxZoom()), duration: 480, }); this.events.emit("clusterExpand", { count }); }); } private clickProperty(e: MapLayerMouseEvent): void { if (this.drawing) return; const feature = e.features?.[0]; if (!feature) return; const id = this.featureId(feature); if (id) this.select(id, "map"); } private handleBaseClick(e: MapMouseEvent): void { // Mode dessin : chaque clic pose un sommet ; un clic près du premier // sommet (≥ 3 posés) ferme le polygone. if (this.drawing) { this.addDrawVertex(e); return; } // Clicks that hit property layers are handled there; a bare map click // clears the selection. const hits = this.map.queryRenderedFeatures(e.point, { layers: [LAYER_IDS.pointPill, LAYER_IDS.pointDot, LAYER_IDS.clusters].filter( (l) => Boolean(this.map.getLayer(l)), ), }); if (hits.length === 0 && this.selectedId) this.select(null, "map"); } private hoverFrom(e: MapLayerMouseEvent): void { const feature = e.features?.[0]; const id = feature ? this.featureId(feature) : null; this.setHovered(id, "map"); } // ------------------------------------------------------------------ data /** Replace the rendered property set (adapter results or app-pushed). */ setProperties(properties: MapProperty[]): void { this.rawProperties = properties; this.renderProperties(); } /** Applique le jeu courant (après découpe polygone éventuelle). */ private renderProperties(): void { const poly = this.clipPolygon; const properties = poly && poly.length >= 3 ? this.rawProperties.filter((p) => pointInPolygon(p.longitude, p.latitude, poly), ) : this.rawProperties; this.byId.clear(); for (const p of properties) { if (!isValidCoordinate(p.latitude, p.longitude)) continue; this.byId.set(p.id, { hash: hashId(p.id), property: p }); } this.data = propertiesToGeoJSON(properties); const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined; if (source) source.setData(this.data as never); // Keep selection if the item is still visible, otherwise drop it. if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app"); this.applyFeatureStates(); if (this.overlayInstalled) this.scheduleOverlayVerify(); this.events.emit("data", { count: this.byId.size, totalCount: this.lastTotalCount, }); } /** * Découpe côté client : ne rendre que les items dans le polygone (apps * dont l'API ne filtre pas par polygone). Passer null pour tout rendre. * Indépendant du polygone AFFICHÉ (setDrawnPolygon) — l'app appelle * généralement les deux ensemble. */ setClipPolygon(polygon: [number, number][] | null): void { this.clipPolygon = polygon && polygon.length >= 3 ? polygon : null; this.renderProperties(); } getProperty(id: string): MapProperty | undefined { return this.byId.get(id)?.property; } /** Items actuellement rendus, dans l'ordre du jeu de données. */ getVisibleProperties(): MapProperty[] { return [...this.byId.values()].map((e) => e.property); } /** Current filters forwarded to the adapter on every query. */ setFilters(filters: Record | undefined): void { this.filters = filters; this.scheduler?.invalidate(); this.refetch(); } /** Fetch data for the current viewport immediately (Search this area). */ searchThisArea(): void { this.refetch(); } refetch(): void { if (!this.scheduler) return; const bbox = this.currentBBox(); if (!bbox) return; this.searchedBBox = bbox; this.setDirty(false); this.scheduler.requestNow({ bbox, zoom: this.map.getZoom(), filters: this.filters }); } setSearchMode(mode: "auto" | "manual"): void { this.searchMode = mode; if (mode === "auto" && this.searchAreaDirty) this.refetch(); } getSearchMode(): "auto" | "manual" { return this.searchMode; } private handleMoveEnd(): void { const center = this.map.getCenter(); const byUser = !this.programmaticMove; this.programmaticMove = false; this.events.emit("moveend", { center: { lat: center.lat, lng: center.lng }, zoom: this.map.getZoom(), byUser, }); if (!this.scheduler) return; const bbox = this.currentBBox(); if (!bbox) return; if (this.searchMode === "auto") { this.searchedBBox = bbox; this.setDirty(false); this.scheduler.request({ bbox, zoom: this.map.getZoom(), filters: this.filters }); return; } // Manual mode: flag divergence, let the app show "Search this area". if (!this.searchedBBox) { this.refetch(); // first load return; } const tolerant = expandBBox(this.searchedBBox, 0.15); this.setDirty(!bboxContains(tolerant, bbox)); } private setDirty(dirty: boolean): void { if (this.searchAreaDirty === dirty) return; this.searchAreaDirty = dirty; this.events.emit("searchAreaDirty", { dirty }); } // ------------------------------------------------------------- selection /** Select from map click or app (card click). Pass null to clear. * Selecting the already-selected id is a no-op (prevents feedback loops * when apps mirror the selection back declaratively). */ select(id: string | null, origin: "map" | "app"): void { if (id === this.selectedId) return; const previous = this.selectedId; this.selectedId = id; if (previous) this.setFeatureState(previous, { selected: false }); if (id) this.setFeatureState(id, { selected: true }); this.refreshPillSelection(); this.events.emit("select", { propertyId: id, origin }); // Card-driven selection: reveal the item without a jarring recenter. if (origin === "app" && id) { const entry = this.byId.get(id); if (entry) { const { latitude, longitude } = entry.property; const bounds = this.map.getBounds(); if (bounds && !bounds.contains([longitude, latitude])) { this.programmaticMove = true; this.map.easeTo({ center: [longitude, latitude], duration: 420 }); } } } } getSelectedId(): string | null { return this.selectedId; } setHovered(id: string | null, origin: "map" | "app"): void { if (id === this.hoveredId) return; if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: false }); this.hoveredId = id; if (id) this.setFeatureState(id, { hovered: true }); if (origin === "map") this.events.emit("hover", { propertyId: id }); } /** Dim everything except the given ids (Ka Lens, filter emphasis). */ setDimmedExcept(ids: Set | null): void { for (const [id, entry] of this.byId) { this.map.setFeatureState( { source: this.sourceId, id: entry.hash }, { dimmed: ids !== null && !ids.has(id) }, ); } } /** Annonces déjà consultées : pastilles atténuées (état « vu »). */ setSeenIds(ids: Iterable): void { this.seenIds = new Set(ids); this.applySeenStates(); } private applySeenStates(): void { if (this.seenIds.size === 0) return; for (const [id, entry] of this.byId) { if (!this.seenIds.has(id)) continue; try { this.map.setFeatureState( { source: this.sourceId, id: entry.hash }, { seen: true }, ); } catch { // source en transition — réappliqué par applyFeatureStates } } } private setFeatureState(id: string, state: Record): void { const entry = this.byId.get(id); if (!entry) return; try { this.map.setFeatureState({ source: this.sourceId, id: entry.hash }, state); } catch { // Source may be mid-reload during a style swap; states reapply after. } } private applyFeatureStates(): void { if (this.selectedId) this.setFeatureState(this.selectedId, { selected: true }); if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: true }); this.applySeenStates(); this.refreshPillSelection(); } /** icon-image est une propriété layout (feature-state interdit) : la * pastille sélectionnée est réinjectée dans l'expression au besoin. */ private refreshPillSelection(): void { if (!this.map.getLayer(LAYER_IDS.pointPill)) return; try { this.map.setLayoutProperty( LAYER_IDS.pointPill, "icon-image", pillIconExpression(this.selectedId), ); } catch { // style en cours de rechargement — réappliqué par installOverlay } } // ----------------------------------------------------------------- dessin /** Démarre le tracé d'une zone : chaque clic pose un sommet, un clic sur * le premier sommet (≥ 3) ferme le polygone, Échap annule. */ startDraw(): void { if (this.drawing) return; this.drawing = true; this.drawVertices = []; this.drawCursor = null; this.drawnPolygon = null; this.map.doubleClickZoom.disable(); this.map.getCanvas().style.cursor = "crosshair"; this.drawKeyHandler = (e: KeyboardEvent) => { if (e.key === "Escape") this.cancelDraw(); }; window.addEventListener("keydown", this.drawKeyHandler); this.updateDrawSource(); this.events.emit("draw", { polygon: null, drawing: true }); } /** Annule le tracé en cours (Échap ou bouton). */ cancelDraw(): void { if (!this.drawing) return; this.endDrawMode(); this.drawVertices = []; this.drawCursor = null; this.updateDrawSource(); this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false }); } /** Efface le polygone posé (retrait de la puce « Zone dessinée »). */ clearDrawnPolygon(): void { if (this.drawing) this.endDrawMode(); this.drawing = false; this.drawVertices = []; this.drawCursor = null; this.drawnPolygon = null; this.updateDrawSource(); this.events.emit("draw", { polygon: null, drawing: false }); } /** Restaure un polygone (URL partagée) sans passer par le tracé. */ setDrawnPolygon(polygon: [number, number][] | null): void { this.drawnPolygon = polygon && polygon.length >= 3 ? polygon : null; this.updateDrawSource(); this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false }); } getDrawnPolygon(): [number, number][] | null { return this.drawnPolygon; } isDrawing(): boolean { return this.drawing; } private endDrawMode(): void { this.drawing = false; this.map.doubleClickZoom.enable(); this.map.getCanvas().style.cursor = ""; if (this.drawKeyHandler) { window.removeEventListener("keydown", this.drawKeyHandler); this.drawKeyHandler = null; } } private addDrawVertex(e: MapMouseEvent): void { const first = this.drawVertices[0]; if (first && this.drawVertices.length >= 3) { const firstPt = this.map.project(first); const dx = firstPt.x - e.point.x; const dy = firstPt.y - e.point.y; if (Math.hypot(dx, dy) < 14) { // Fermeture : clic sur le premier sommet. this.drawnPolygon = [...this.drawVertices]; this.endDrawMode(); this.drawCursor = null; this.updateDrawSource(); this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false }); return; } } this.drawVertices.push([e.lngLat.lng, e.lngLat.lat]); this.updateDrawSource(); } private drawFeatureCollection(): GeoJSON.FeatureCollection { const features: GeoJSON.Feature[] = []; if (this.drawnPolygon) { const ring = [...this.drawnPolygon, this.drawnPolygon[0] as [number, number]]; features.push({ type: "Feature", properties: { role: "zone" }, geometry: { type: "Polygon", coordinates: [ring] }, }); } else if (this.drawVertices.length > 0) { const line = this.drawCursor ? [...this.drawVertices, this.drawCursor] : [...this.drawVertices]; if (line.length >= 2) { features.push({ type: "Feature", properties: { role: "trace" }, geometry: { type: "LineString", coordinates: line }, }); } for (const v of this.drawVertices) { features.push({ type: "Feature", properties: { role: "sommet" }, geometry: { type: "Point", coordinates: v }, }); } } return { type: "FeatureCollection", features }; } private updateDrawSource(): void { const source = this.map.getSource(DRAW_SOURCE_ID) as GeoJSONSource | undefined; if (source) source.setData(this.drawFeatureCollection() as never); } /** Couches du dessin — réinstallées avec l'overlay (styledata). */ private installDrawLayers(): void { const map = this.map; const accent = markerTokens(this.theme, "highlight").background; if (!map.getSource(DRAW_SOURCE_ID)) { map.addSource(DRAW_SOURCE_ID, { type: "geojson", data: this.drawFeatureCollection(), }); } if (!map.getLayer(DRAW_LAYER_IDS.fill)) { map.addLayer({ id: DRAW_LAYER_IDS.fill, slot: "top", type: "fill", source: DRAW_SOURCE_ID, filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": accent, "fill-opacity": 0.08 }, }); } if (!map.getLayer(DRAW_LAYER_IDS.line)) { map.addLayer({ id: DRAW_LAYER_IDS.line, slot: "top", type: "line", source: DRAW_SOURCE_ID, filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": accent, "line-width": 2.25, "line-dasharray": [ "case", ["==", ["get", "role"], "trace"], ["literal", [2, 1.6]], ["literal", [1, 0]], ] as never, }, }); } if (!map.getLayer(DRAW_LAYER_IDS.vertex)) { map.addLayer({ id: DRAW_LAYER_IDS.vertex, slot: "top", type: "circle", source: DRAW_SOURCE_ID, filter: ["==", ["geometry-type"], "Point"], paint: { "circle-radius": 5, "circle-color": "#ffffff", "circle-stroke-color": accent, "circle-stroke-width": 2, }, }); } } // ------------------------------------------------------ building spotlight /** * Met en évidence le bâtiment situé aux coordonnées données (fiche d'une * propriété) : l'empreinte 3D du featureset « buildings » de Mapbox * Standard passe à l'état `select`, coloré via `colorBuildingSelect` * (option `basemap` ou `opts.color`). Passer `null` pour effacer. * Résilient : re-tenté tant que les tuiles ne sont pas rendues, et * réappliqué après chaque recomposition du style. */ focusBuilding( at: { lng: number; lat: number } | null, opts?: { color?: string }, ): void { for (const f of this.focusedBuildings) { try { this.map.setFeatureState(f, { select: false }); } catch { // style en transition — l'état disparaît avec lui } } this.focusedBuildings = []; this.focusAttempts = 0; if (this.focusRetryTimer !== null) { clearTimeout(this.focusRetryTimer); this.focusRetryTimer = null; } this.buildingFocus = at; if (!at) return; if (opts?.color) { try { this.map.setConfigProperty("basemap", "colorBuildingSelect", opts.color); } catch { // style pas encore chargé : la couleur viendra de applyKaBasemapConfig } } this.scheduleBuildingFocus(); } private scheduleBuildingFocus(): void { if (!this.buildingFocus || this.destroyed) return; if (this.map.isStyleLoaded() && this.map.loaded()) { this.applyBuildingFocus(); } else { this.map.once("idle", () => this.applyBuildingFocus()); } } private applyBuildingFocus(): void { const focus = this.buildingFocus; if (!focus || this.destroyed || this.focusedBuildings.length > 0) return; const pt = this.map.project([focus.lng, focus.lat]); // Le point géocodé tombe parfois sur la rue devant l'immeuble : on // interroge une boîte serrée, puis on élargit progressivement. const pads = [4, 14, 34]; let found: TargetFeature[] = []; for (const pad of pads) { try { found = this.map.queryRenderedFeatures( [ [pt.x - pad, pt.y - pad], [pt.x + pad, pt.y + pad], ], { target: { featuresetId: "buildings", importId: "basemap" } }, ); } catch { found = []; } if (found.length > 0) break; } if (found.length === 0) { // Tuiles vecteur pas encore prêtes, ou zone sans empreinte de // bâtiment : quelques re-tentatives espacées, puis on abandonne // proprement (le marqueur de prix reste le repère). if (this.focusAttempts++ < 8) { this.focusRetryTimer = setTimeout(() => { this.focusRetryTimer = null; this.scheduleBuildingFocus(); }, 400); } return; } // Un immeuble = souvent plusieurs morceaux d'empreinte : on sélectionne // toutes les parties partageant l'id de la plus proche du point. const primary = found[0]; if (!primary) return; const primaryId = primary.id; const parts = found.filter((f) => f.id === primaryId); for (const f of parts) { try { this.map.setFeatureState(f, { select: true }); } catch { // recomposition en cours : le handler styledata re-planifiera } } this.focusedBuildings = parts; this.events.emit("buildingFocus", { found: true }); } /** Recentre la caméra en douceur (fiche, spotlight, deep-link). */ flyTo( center: { lng: number; lat: number }, opts?: { zoom?: number; pitch?: number; bearing?: number; duration?: number }, ): void { this.programmaticMove = true; this.map.easeTo({ center: [center.lng, center.lat], zoom: opts?.zoom, pitch: opts?.pitch, bearing: opts?.bearing, duration: opts?.duration ?? 900, }); } /** Cadre l'emprise donnée avec une animation douce. Mouvement programmé : * le moveend qui suit est émis avec `byUser: false` (la vue n'est pas * « volée » à l'utilisateur au sens de la synchro liste↔carte). */ fitBounds( bbox: BBox, opts?: { padding?: number; maxZoom?: number; duration?: number }, ): void { this.programmaticMove = true; this.map.fitBounds( [ [bbox.west, bbox.south], [bbox.east, bbox.north], ], { padding: opts?.padding ?? 56, maxZoom: opts?.maxZoom ?? 16, duration: opts?.duration ?? 850, }, ); } /** Cadre l'ensemble des propriétés affichées (ou fournies). */ fitToProperties( properties?: MapProperty[], opts?: { padding?: number; maxZoom?: number; duration?: number }, ): void { const list = properties ?? [...this.byId.values()].map((e) => e.property); const bbox = bboxOfProperties(list); if (bbox) this.fitBounds(bbox, opts); } // ---------------------------------------------------------------- theming /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */ setTilt(on: boolean): void { this.programmaticMove = true; this.map.easeTo({ pitch: on ? 55 : 0, duration: 600 }); } isTilted(): boolean { return this.map.getPitch() > 5; } setMode(mode: KaMapMode): void { if (mode === this.mode) return; this.mode = mode; // Jour/nuit via la config du style Standard — aucun rechargement. applyKaBasemapConfig(this.map, this.mode, this.basemap); } getMode(): KaMapMode { return this.mode; } getTheme(): KaMapTheme { return this.theme; } // ------------------------------------------------------------------ state currentBBox(): BBox | null { const b = this.map.getBounds(); if (!b) return null; return { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth(), }; } getState(): KaMapState { const center = this.map.getCenter(); return { center: { lat: center.lat, lng: center.lng }, zoom: this.map.getZoom(), bearing: this.map.getBearing(), pitch: this.map.getPitch(), bounds: this.currentBBox(), selectedPropertyId: this.selectedId, hoveredPropertyId: this.hoveredId, activeLayers: Object.values(LAYER_IDS).filter((l) => Boolean(this.map.getLayer(l)), ), searchAreaDirty: this.searchAreaDirty, drawnGeometry: this.drawnPolygon ? { type: "Polygon", coordinates: [[...this.drawnPolygon, this.drawnPolygon[0] as [number, number]]], } : null, }; } destroy(): void { this.destroyed = true; if (this.drawKeyHandler) { window.removeEventListener("keydown", this.drawKeyHandler); this.drawKeyHandler = null; } if (this.verifyTimer !== null) clearTimeout(this.verifyTimer); if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer); this.scheduler?.destroy(); this.events.clear(); this.map.remove(); } }