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