SPB Git

Repository Public

Commit

Spotlight bâtiment + réglages réalisme du fond Standard : KaBasemapOptions (thème pleine couleur/faded, lightPreset, repères 3D, couleurs de sélection) ; KaMap.focusBuilding() — empreinte 3D du featureset buildings à l'état select (recherche progressive autour du point géocodé, re-tentatives tuiles, réapplication après recomposition) ; KaMap.flyTo() ; composant <KaSpotlightMap> (carte fiche : caméra serrée 3D, gestes coopératifs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 13, 2026 parent d1a4dbe
7 changed files +307 −15 Browse files
modified src/core/KaMap.ts +143 −3
@@ -15,6 +15,7 @@ import type {
15 Map as MapboxMap, 15 Map as MapboxMap,
16 MapLayerMouseEvent, 16 MapLayerMouseEvent,
17 MapMouseEvent, 17 MapMouseEvent,
18+ TargetFeature,
18 } from "mapbox-gl"; 19 } from "mapbox-gl";
19 20
20 /** Feature reçue par les gestionnaires de clic/survol des couches. */ 21 /** Feature reçue par les gestionnaires de clic/survol des couches. */
@@ -26,7 +27,11 @@ import type {
26 MapProperty, 27 MapProperty,
27 } from "../types/index.js"; 28 } from "../types/index.js";
28 import { KaEventHub } from "./events.js"; 29 import { KaEventHub } from "./events.js";
29−import { applyKaBasemapConfig, buildKaStyle } from "../styles/kaBaseStyle.js"; 30+import {
31+ applyKaBasemapConfig,
32+ buildKaStyle,
33+ type KaBasemapOptions,
34+} from "../styles/kaBaseStyle.js";
30 import type { KaMapMode, KaMapTheme } from "../theming/tokens.js"; 35 import type { KaMapMode, KaMapTheme } from "../theming/tokens.js";
31 import { 36 import {
32 BoundsQueryScheduler, 37 BoundsQueryScheduler,
@@ -61,6 +66,8 @@ export interface KaMapOptions {
61 pitch?: number; 66 pitch?: number;
62 minZoom?: number; 67 minZoom?: number;
63 maxZoom?: number; 68 maxZoom?: number;
69+ /** Réglages du fond Mapbox Standard (réalisme, lumière, repères 3D). */
70+ basemap?: KaBasemapOptions;
64 /** "auto": refetch after every settled move. "manual": show Search-this-area. */ 71 /** "auto": refetch after every settled move. "manual": show Search-this-area. */
65 searchMode?: "auto" | "manual"; 72 searchMode?: "auto" | "manual";
66 query?: BoundsQueryOptions; 73 query?: BoundsQueryOptions;
@@ -83,6 +90,7 @@ export class KaMap {
83 90
84 private theme: KaMapTheme; 91 private theme: KaMapTheme;
85 private mode: KaMapMode; 92 private mode: KaMapMode;
93+ private basemap: KaBasemapOptions | undefined;
86 private scheduler: BoundsQueryScheduler | null = null; 94 private scheduler: BoundsQueryScheduler | null = null;
87 private searchMode: "auto" | "manual"; 95 private searchMode: "auto" | "manual";
88 private filters: Record<string, unknown> | undefined; 96 private filters: Record<string, unknown> | undefined;
@@ -108,9 +116,16 @@ export class KaMap {
108 valueClamp?: [number, number]; 116 valueClamp?: [number, number];
109 }; 117 };
110 118
119+ /** Bâtiment mis en évidence (fiche) — featureset "buildings" du Standard. */
120+ private buildingFocus: { lng: number; lat: number } | null = null;
121+ private focusedBuildings: TargetFeature[] = [];
122+ private focusAttempts = 0;
123+ private focusRetryTimer: ReturnType<typeof setTimeout> | null = null;
124+
111 constructor(options: KaMapOptions) { 125 constructor(options: KaMapOptions) {
112 this.theme = options.theme; 126 this.theme = options.theme;
113 this.mode = options.mode ?? "light"; 127 this.mode = options.mode ?? "light";
128+ this.basemap = options.basemap;
114 this.searchMode = options.searchMode ?? "manual"; 129 this.searchMode = options.searchMode ?? "manual";
115 this.clusterOptions = { 130 this.clusterOptions = {
116 // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` — 131 // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` —
@@ -181,13 +196,21 @@ export class KaMap {
181 // reconstruit la source et les couches (plafonné). 196 // reconstruit la source et les couches (plafonné).
182 this.map.once("load", () => { 197 this.map.once("load", () => {
183 this.installOverlay(); 198 this.installOverlay();
184− this.map.once("idle", () => applyKaBasemapConfig(this.map, this.mode)); 199+ this.map.once("idle", () =>
200+ applyKaBasemapConfig(this.map, this.mode, this.basemap),
201+ );
185 }); 202 });
186 this.map.on("styledata", () => { 203 this.map.on("styledata", () => {
187 if (!this.overlayInstalled) return; 204 if (!this.overlayInstalled) return;
188 if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) { 205 if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) {
189 this.map.once("idle", () => this.rebuildOverlay()); 206 this.map.once("idle", () => this.rebuildOverlay());
190 } 207 }
208+ // Une recomposition du Standard peut perdre l'état du featureset
209+ // buildings : réappliquer le focus (idempotent, peu coûteux).
210+ if (this.buildingFocus) {
211+ this.focusedBuildings = [];
212+ this.scheduleBuildingFocus();
213+ }
191 }); 214 });
192 this.map.on("moveend", () => this.handleMoveEnd()); 215 this.map.on("moveend", () => this.handleMoveEnd());
193 this.map.on("error", (e) => { 216 this.map.on("error", (e) => {
@@ -508,6 +531,122 @@ export class KaMap {
508 } 531 }
509 } 532 }
510 533
534+ // ------------------------------------------------------ building spotlight
535+
536+ /**
537+ * Met en évidence le bâtiment situé aux coordonnées données (fiche d'une
538+ * propriété) : l'empreinte 3D du featureset « buildings » de Mapbox
539+ * Standard passe à l'état `select`, coloré via `colorBuildingSelect`
540+ * (option `basemap` ou `opts.color`). Passer `null` pour effacer.
541+ * Résilient : re-tenté tant que les tuiles ne sont pas rendues, et
542+ * réappliqué après chaque recomposition du style.
543+ */
544+ focusBuilding(
545+ at: { lng: number; lat: number } | null,
546+ opts?: { color?: string },
547+ ): void {
548+ for (const f of this.focusedBuildings) {
549+ try {
550+ this.map.setFeatureState(f, { select: false });
551+ } catch {
552+ // style en transition — l'état disparaît avec lui
553+ }
554+ }
555+ this.focusedBuildings = [];
556+ this.focusAttempts = 0;
557+ if (this.focusRetryTimer !== null) {
558+ clearTimeout(this.focusRetryTimer);
559+ this.focusRetryTimer = null;
560+ }
561+ this.buildingFocus = at;
562+ if (!at) return;
563+ if (opts?.color) {
564+ try {
565+ this.map.setConfigProperty("basemap", "colorBuildingSelect", opts.color);
566+ } catch {
567+ // style pas encore chargé : la couleur viendra de applyKaBasemapConfig
568+ }
569+ }
570+ this.scheduleBuildingFocus();
571+ }
572+
573+ private scheduleBuildingFocus(): void {
574+ if (!this.buildingFocus || this.destroyed) return;
575+ if (this.map.isStyleLoaded() && this.map.loaded()) {
576+ this.applyBuildingFocus();
577+ } else {
578+ this.map.once("idle", () => this.applyBuildingFocus());
579+ }
580+ }
581+
582+ private applyBuildingFocus(): void {
583+ const focus = this.buildingFocus;
584+ if (!focus || this.destroyed || this.focusedBuildings.length > 0) return;
585+
586+ const pt = this.map.project([focus.lng, focus.lat]);
587+ // Le point géocodé tombe parfois sur la rue devant l'immeuble : on
588+ // interroge une boîte serrée, puis on élargit progressivement.
589+ const pads = [4, 14, 34];
590+ let found: TargetFeature[] = [];
591+ for (const pad of pads) {
592+ try {
593+ found = this.map.queryRenderedFeatures(
594+ [
595+ [pt.x - pad, pt.y - pad],
596+ [pt.x + pad, pt.y + pad],
597+ ],
598+ { target: { featuresetId: "buildings", importId: "basemap" } },
599+ );
600+ } catch {
601+ found = [];
602+ }
603+ if (found.length > 0) break;
604+ }
605+
606+ if (found.length === 0) {
607+ // Tuiles vecteur pas encore prêtes, ou zone sans empreinte de
608+ // bâtiment : quelques re-tentatives espacées, puis on abandonne
609+ // proprement (le marqueur de prix reste le repère).
610+ if (this.focusAttempts++ < 8) {
611+ this.focusRetryTimer = setTimeout(() => {
612+ this.focusRetryTimer = null;
613+ this.scheduleBuildingFocus();
614+ }, 400);
615+ }
616+ return;
617+ }
618+
619+ // Un immeuble = souvent plusieurs morceaux d'empreinte : on sélectionne
620+ // toutes les parties partageant l'id de la plus proche du point.
621+ const primary = found[0];
622+ if (!primary) return;
623+ const primaryId = primary.id;
624+ const parts = found.filter((f) => f.id === primaryId);
625+ for (const f of parts) {
626+ try {
627+ this.map.setFeatureState(f, { select: true });
628+ } catch {
629+ // recomposition en cours : le handler styledata re-planifiera
630+ }
631+ }
632+ this.focusedBuildings = parts;
633+ this.events.emit("buildingFocus", { found: true });
634+ }
635+
636+ /** Recentre la caméra en douceur (fiche, spotlight, deep-link). */
637+ flyTo(
638+ center: { lng: number; lat: number },
639+ opts?: { zoom?: number; pitch?: number; bearing?: number; duration?: number },
640+ ): void {
641+ this.map.easeTo({
642+ center: [center.lng, center.lat],
643+ zoom: opts?.zoom,
644+ pitch: opts?.pitch,
645+ bearing: opts?.bearing,
646+ duration: opts?.duration ?? 900,
647+ });
648+ }
649+
511 // ---------------------------------------------------------------- theming 650 // ---------------------------------------------------------------- theming
512 651
513 /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */ 652 /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */
@@ -523,7 +662,7 @@ export class KaMap {
523 if (mode === this.mode) return; 662 if (mode === this.mode) return;
524 this.mode = mode; 663 this.mode = mode;
525 // Jour/nuit via la config du style Standard — aucun rechargement. 664 // Jour/nuit via la config du style Standard — aucun rechargement.
526− applyKaBasemapConfig(this.map, this.mode); 665+ applyKaBasemapConfig(this.map, this.mode, this.basemap);
527 } 666 }
528 667
529 getMode(): KaMapMode { 668 getMode(): KaMapMode {
@@ -568,6 +707,7 @@ export class KaMap {
568 destroy(): void { 707 destroy(): void {
569 this.destroyed = true; 708 this.destroyed = true;
570 if (this.verifyTimer !== null) clearTimeout(this.verifyTimer); 709 if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);
710+ if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer);
571 this.scheduler?.destroy(); 711 this.scheduler?.destroy();
572 this.events.clear(); 712 this.events.clear();
573 this.map.remove(); 713 this.map.remove();
modified src/core/events.ts +2 −0
@@ -25,6 +25,8 @@ export type KaEventMap = {
25 loading: { loading: boolean }; 25 loading: { loading: boolean };
26 /** Non-fatal error (tiles, query…) the app may surface. */ 26 /** Non-fatal error (tiles, query…) the app may surface. */
27 error: { scope: "tiles" | "query" | "style"; error: unknown }; 27 error: { scope: "tiles" | "query" | "style"; error: unknown };
28+ /** Le spotlight bâtiment (focusBuilding) a trouvé son empreinte 3D. */
29+ buildingFocus: { found: boolean };
28 }; 30 };
29 31
30 type Handler<T> = (payload: T) => void; 32 type Handler<T> = (payload: T) => void;
modified src/index.ts +1 −0
@@ -59,6 +59,7 @@ export {
59 buildKaStyle, 59 buildKaStyle,
60 KA_ATTRIBUTION, 60 KA_ATTRIBUTION,
61 KA_STYLE_URL, 61 KA_STYLE_URL,
62+ type KaBasemapOptions,
62 } from "./styles/kaBaseStyle.js"; 63 } from "./styles/kaBaseStyle.js";
63 export { 64 export {
64 buildClusterProperties, 65 buildClusterProperties,
modified src/react/KaMapView.tsx +1 −0
@@ -66,6 +66,7 @@ export function KaMapView(props: KaMapViewProps): ReactElement {
66 mapboxToken: p.mapboxToken, 66 mapboxToken: p.mapboxToken,
67 mode: p.mode, 67 mode: p.mode,
68 adapter: p.adapter, 68 adapter: p.adapter,
69+ basemap: p.basemap,
69 center: p.center, 70 center: p.center,
70 zoom: p.zoom, 71 zoom: p.zoom,
71 pitch: p.pitch, 72 pitch: p.pitch,
added src/react/KaSpotlightMap.tsx +116 −0
@@ -0,0 +1,116 @@
1+/**
2+ * Author: Simon-Pierre Boucher
3+ * Contact: contact@spboucher.ai
4+ * Project: Groupe Ka / Ka Maps
5+ *
6+ * <KaSpotlightMap> — la carte « fiche » : une seule propriété, caméra 3D
7+ * serrée sur l'adresse, et le bâtiment concerné mis en évidence (empreinte
8+ * 3D du featureset « buildings » de Mapbox Standard, coloré via
9+ * `buildingColor`). Fond réaliste par défaut (pleine couleur, repères 3D).
10+ * Gestes coopératifs : la fiche reste défilable au-dessus de la carte.
11+ */
12+
13+import { useEffect, useRef, type CSSProperties, type ReactElement, type ReactNode } from "react";
14+import type { KaBasemapOptions } from "../styles/kaBaseStyle.js";
15+import type { KaMapMode, KaMapTheme } from "../theming/tokens.js";
16+import type { MapProperty } from "../types/index.js";
17+import { KaMapView, useKaMap } from "./KaMapView.js";
18+
19+/** Rouge signal — l'immeuble de la fiche ressort du premier coup d'œil. */
20+const DEFAULT_BUILDING_COLOR = "#e03131";
21+
22+const DEFAULT_ZOOM = 17.1;
23+const DEFAULT_PITCH = 62;
24+
25+function SpotlightBridge({
26+ property,
27+ color,
28+ zoom,
29+ pitch,
30+}: {
31+ property: MapProperty;
32+ color: string;
33+ zoom: number;
34+ pitch: number;
35+}) {
36+ const map = useKaMap();
37+ const firstRun = useRef(true);
38+
39+ useEffect(() => {
40+ if (!map) return;
41+ const at = { lng: property.longitude, lat: property.latitude };
42+ // Premier rendu : la caméra est déjà posée par le constructeur ;
43+ // navigations suivantes (fiche → fiche) : glissement doux.
44+ if (firstRun.current) firstRun.current = false;
45+ else map.flyTo(at, { zoom, pitch });
46+ map.focusBuilding(at, { color });
47+ return () => map.focusBuilding(null);
48+ // eslint-disable-next-line react-hooks/exhaustive-deps
49+ }, [map, property.id, property.longitude, property.latitude, color]);
50+
51+ return null;
52+}
53+
54+export interface KaSpotlightMapProps {
55+ theme: KaMapTheme;
56+ mapboxToken: string;
57+ /** La propriété de la fiche — pastille de prix + bâtiment en évidence. */
58+ property: MapProperty;
59+ mode?: KaMapMode;
60+ className?: string;
61+ style?: CSSProperties;
62+ /** Couleur de l'empreinte 3D du bâtiment. Défaut : rouge signal. */
63+ buildingColor?: string;
64+ zoom?: number;
65+ pitch?: number;
66+ /** Surcharge du fond (défaut spotlight : pleine couleur + repères 3D). */
67+ basemap?: KaBasemapOptions;
68+ children?: ReactNode;
69+}
70+
71+export function KaSpotlightMap({
72+ theme,
73+ mapboxToken,
74+ property,
75+ mode,
76+ className,
77+ style,
78+ buildingColor = DEFAULT_BUILDING_COLOR,
79+ zoom = DEFAULT_ZOOM,
80+ pitch = DEFAULT_PITCH,
81+ basemap,
82+ children,
83+}: KaSpotlightMapProps): ReactElement {
84+ const properties = useRef<MapProperty[]>([]);
85+ if (properties.current[0]?.id !== property.id) properties.current = [property];
86+
87+ return (
88+ <KaMapView
89+ theme={theme}
90+ mapboxToken={mapboxToken}
91+ mode={mode}
92+ className={className}
93+ style={style}
94+ properties={properties.current}
95+ center={{ lat: property.latitude, lng: property.longitude }}
96+ zoom={zoom}
97+ pitch={pitch}
98+ minZoom={11}
99+ cooperativeGestures
100+ basemap={{
101+ theme: "default",
102+ showLandmarks: true,
103+ colorBuildingSelect: buildingColor,
104+ ...basemap,
105+ }}
106+ >
107+ <SpotlightBridge
108+ property={property}
109+ color={buildingColor}
110+ zoom={zoom}
111+ pitch={pitch}
112+ />
113+ {children}
114+ </KaMapView>
115+ );
116+}
modified src/react/index.ts +1 −0
@@ -7,6 +7,7 @@
7 */ 7 */
8 8
9 export { KaMapView, useKaMap, type KaMapViewProps } from "./KaMapView.js"; 9 export { KaMapView, useKaMap, type KaMapViewProps } from "./KaMapView.js";
10+export { KaSpotlightMap, type KaSpotlightMapProps } from "./KaSpotlightMap.js";
10 export { 11 export {
11 KaBrandBadge, 12 KaBrandBadge,
12 LoadingIndicator, 13 LoadingIndicator,
modified src/styles/kaBaseStyle.ts +43 −12
@@ -5,9 +5,10 @@
5 * 5 *
6 * Fond de carte Ka Maps : Mapbox Standard, configuré pour l'immobilier. 6 * Fond de carte Ka Maps : Mapbox Standard, configuré pour l'immobilier.
7 * Le style Standard apporte les bâtiments 3D photoréalistes, les repères 7 * Le style Standard apporte les bâtiments 3D photoréalistes, les repères
8− * et l'éclairage dynamique ; Ka Maps le règle en « faded » pour que les 8+ * et l'éclairage dynamique. Chaque produit choisit son niveau de réalisme
9− * pastilles de prix restent l'élément dominant, et bascule jour/nuit via 9+ * via KaBasemapOptions (thème pleine couleur ou « faded », préréglage de
10− * lightPreset (Ka Light / Ka Dark) sans recharger le style. 10+ * lumière, repères 3D) — le jour/nuit bascule via lightPreset (Ka Light /
11+ * Ka Dark) sans recharger le style.
11 */ 12 */
12 13
13 import type { Map as MapboxMap } from "mapbox-gl"; 14 import type { Map as MapboxMap } from "mapbox-gl";
@@ -18,6 +19,26 @@ export const KA_STYLE_URL = "mapbox://styles/mapbox/standard";
18 19
19 export const KA_ATTRIBUTION = "© Mapbox © OpenStreetMap"; 20 export const KA_ATTRIBUTION = "© Mapbox © OpenStreetMap";
20 21
22+/** Réglages produit du fond Mapbox Standard. */
23+export interface KaBasemapOptions {
24+ /** "default" = pleine couleur réaliste ; "faded" = atténué (les prix
25+ * dominent) ; "monochrome" = neutre. Défaut : "faded". */
26+ theme?: "default" | "faded" | "monochrome";
27+ /** Préréglage lumière du mode clair (le mode sombre force "dusk").
28+ * "day" à midi, "dawn" pour une lumière rasante plus sculpturale. */
29+ lightPreset?: "dawn" | "day" | "dusk" | "night";
30+ /** Repères 3D emblématiques (stades, ponts, églises…). Défaut : true. */
31+ showLandmarks?: boolean;
32+ /** Étiquettes de commerces/POI. Défaut : false (la carte montre des
33+ * propriétés, pas des commerces). */
34+ showPointOfInterestLabels?: boolean;
35+ /** Couleur du bâtiment « sélectionné » (état select du featureset
36+ * buildings) — utilisée par KaMap.focusBuilding. */
37+ colorBuildingSelect?: string;
38+ /** Couleur du bâtiment « survolé » (état highlight). */
39+ colorBuildingHighlight?: string;
40+}
41+
21 /** 42 /**
22 * Retourne l'URL de style pour un mode/thème. (Signature conservée : le 43 * Retourne l'URL de style pour un mode/thème. (Signature conservée : le
23 * jour/nuit et les réglages passent par applyKaBasemapConfig, pas par un 44 * jour/nuit et les réglages passent par applyKaBasemapConfig, pas par un
@@ -28,13 +49,15 @@ export function buildKaStyle(_mode: KaMapMode, _theme: KaMapTheme): string {
28 } 49 }
29 50
30 /** 51 /**
31− * Réglages immobiliers du fond Standard — à appeler après style.load et à 52+ * Applique les réglages du fond Standard — à appeler après style.load et à
32− * chaque changement de mode : 53+ * chaque changement de mode. Les propriétés absentes d'une future version
33− * · theme "faded" : couleurs atténuées, les prix ressortent ; 54+ * du style sont ignorées (non bloquant).
34− * · lightPreset : day (Ka Light) / dusk (Ka Dark) ;
35− * · POI/transit off: la carte montre des propriétés, pas des commerces.
36 */ 55 */
37−export function applyKaBasemapConfig(map: MapboxMap, mode: KaMapMode): void { 56+export function applyKaBasemapConfig(
57+ map: MapboxMap,
58+ mode: KaMapMode,
59+ basemap?: KaBasemapOptions,
60+): void {
38 const set = (key: string, value: unknown) => { 61 const set = (key: string, value: unknown) => {
39 try { 62 try {
40 map.setConfigProperty("basemap", key, value); 63 map.setConfigProperty("basemap", key, value);
@@ -42,10 +65,18 @@ export function applyKaBasemapConfig(map: MapboxMap, mode: KaMapMode): void {
42 // propriété absente sur une future version du style : non bloquant 65 // propriété absente sur une future version du style : non bloquant
43 } 66 }
44 }; 67 };
45− set("theme", "faded"); 68+ set("theme", basemap?.theme ?? "faded");
46− set("lightPreset", mode === "dark" ? "dusk" : "day"); 69+ set(
47− set("showPointOfInterestLabels", false); 70+ "lightPreset",
71+ mode === "dark" ? "dusk" : basemap?.lightPreset ?? "day",
72+ );
73+ set("showPointOfInterestLabels", basemap?.showPointOfInterestLabels ?? false);
48 set("showTransitLabels", false); 74 set("showTransitLabels", false);
49 set("showPedestrianRoads", true); 75 set("showPedestrianRoads", true);
50 set("show3dObjects", true); 76 set("show3dObjects", true);
77+ set("showLandmarkIcons", basemap?.showLandmarks ?? true);
78+ if (basemap?.colorBuildingSelect)
79+ set("colorBuildingSelect", basemap.colorBuildingSelect);
80+ if (basemap?.colorBuildingHighlight)
81+ set("colorBuildingHighlight", basemap.colorBuildingHighlight);
51 } 82 }
52 83
Menu

Theme