SPB Git

spb/ka-maps

Public

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 Browse files

7 changed files +307 −15

modified src/core/KaMap.ts +143 −3
@@ -15,6 +15,7 @@ import type {
15 15 Map as MapboxMap,
16 16 MapLayerMouseEvent,
17 17 MapMouseEvent,
18 + TargetFeature,
18 19 } from "mapbox-gl";
19 20
20 21 /** Feature reçue par les gestionnaires de clic/survol des couches. */
@@ -26,7 +27,11 @@ import type {
26 27 MapProperty,
27 28 } from "../types/index.js";
28 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 35 import type { KaMapMode, KaMapTheme } from "../theming/tokens.js";
31 36 import {
32 37 BoundsQueryScheduler,
@@ -61,6 +66,8 @@ export interface KaMapOptions {
61 66 pitch?: number;
62 67 minZoom?: number;
63 68 maxZoom?: number;
69 + /** Réglages du fond Mapbox Standard (réalisme, lumière, repères 3D). */
70 + basemap?: KaBasemapOptions;
64 71 /** "auto": refetch after every settled move. "manual": show Search-this-area. */
65 72 searchMode?: "auto" | "manual";
66 73 query?: BoundsQueryOptions;
@@ -83,6 +90,7 @@ export class KaMap {
83 90
84 91 private theme: KaMapTheme;
85 92 private mode: KaMapMode;
93 + private basemap: KaBasemapOptions | undefined;
86 94 private scheduler: BoundsQueryScheduler | null = null;
87 95 private searchMode: "auto" | "manual";
88 96 private filters: Record<string, unknown> | undefined;
@@ -108,9 +116,16 @@ export class KaMap {
108 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 125 constructor(options: KaMapOptions) {
112 126 this.theme = options.theme;
113 127 this.mode = options.mode ?? "light";
128 + this.basemap = options.basemap;
114 129 this.searchMode = options.searchMode ?? "manual";
115 130 this.clusterOptions = {
116 131 // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` —
@@ -181,13 +196,21 @@ export class KaMap {
181 196 // reconstruit la source et les couches (plafonné).
182 197 this.map.once("load", () => {
183 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 203 this.map.on("styledata", () => {
187 204 if (!this.overlayInstalled) return;
188 205 if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) {
189 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 215 this.map.on("moveend", () => this.handleMoveEnd());
193 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 650 // ---------------------------------------------------------------- theming
512 651
513 652 /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */
@@ -523,7 +662,7 @@ export class KaMap {
523 662 if (mode === this.mode) return;
524 663 this.mode = mode;
525 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 668 getMode(): KaMapMode {
@@ -568,6 +707,7 @@ export class KaMap {
568 707 destroy(): void {
569 708 this.destroyed = true;
570 709 if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);
710 + if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer);
571 711 this.scheduler?.destroy();
572 712 this.events.clear();
573 713 this.map.remove();
modified src/core/events.ts +2 −0
@@ -25,6 +25,8 @@ export type KaEventMap = {
25 25 loading: { loading: boolean };
26 26 /** Non-fatal error (tiles, query…) the app may surface. */
27 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 32 type Handler<T> = (payload: T) => void;
modified src/index.ts +1 −0
@@ -59,6 +59,7 @@ export {
59 59 buildKaStyle,
60 60 KA_ATTRIBUTION,
61 61 KA_STYLE_URL,
62 + type KaBasemapOptions,
62 63 } from "./styles/kaBaseStyle.js";
63 64 export {
64 65 buildClusterProperties,
modified src/react/KaMapView.tsx +1 −0
@@ -66,6 +66,7 @@ export function KaMapView(props: KaMapViewProps): ReactElement {
66 66 mapboxToken: p.mapboxToken,
67 67 mode: p.mode,
68 68 adapter: p.adapter,
69 + basemap: p.basemap,
69 70 center: p.center,
70 71 zoom: p.zoom,
71 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 9 export { KaMapView, useKaMap, type KaMapViewProps } from "./KaMapView.js";
10 +export { KaSpotlightMap, type KaSpotlightMapProps } from "./KaSpotlightMap.js";
10 11 export {
11 12 KaBrandBadge,
12 13 LoadingIndicator,
modified src/styles/kaBaseStyle.ts +43 −12
@@ -5,9 +5,10 @@
5 5 *
6 6 * Fond de carte Ka Maps : Mapbox Standard, configuré pour l'immobilier.
7 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
9 − * pastilles de prix restent l'élément dominant, et bascule jour/nuit via
10 − * lightPreset (Ka Light / Ka Dark) sans recharger le style.
8 + * et l'éclairage dynamique. Chaque produit choisit son niveau de réalisme
9 + * via KaBasemapOptions (thème pleine couleur ou « faded », préréglage de
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 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 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 43 * Retourne l'URL de style pour un mode/thème. (Signature conservée : le
23 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 à
32 − * chaque changement de mode :
33 − * · theme "faded" : couleurs atténuées, les prix ressortent ;
34 − * · lightPreset : day (Ka Light) / dusk (Ka Dark) ;
35 − * · POI/transit off: la carte montre des propriétés, pas des commerces.
52 + * Applique les réglages du fond Standard — à appeler après style.load et à
53 + * chaque changement de mode. Les propriétés absentes d'une future version
54 + * du style sont ignorées (non bloquant).
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 61 const set = (key: string, value: unknown) => {
39 62 try {
40 63 map.setConfigProperty("basemap", key, value);
@@ -42,10 +65,18 @@ export function applyKaBasemapConfig(map: MapboxMap, mode: KaMapMode): void {
42 65 // propriété absente sur une future version du style : non bloquant
43 66 }
44 67 };
45 − set("theme", "faded");
46 − set("lightPreset", mode === "dark" ? "dusk" : "day");
47 − set("showPointOfInterestLabels", false);
68 + set("theme", basemap?.theme ?? "faded");
69 + set(
70 + "lightPreset",
71 + mode === "dark" ? "dusk" : basemap?.lightPreset ?? "day",
72 + );
73 + set("showPointOfInterestLabels", basemap?.showPointOfInterestLabels ?? false);
48 74 set("showTransitLabels", false);
49 75 set("showPedestrianRoads", true);
50 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