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