SPB Git forge

spb/ka-maps

Public
6commits 1branches 0releases
448.0 KBsize
maindefault branch
29 days agolast push
TypeScript 87.7% CSS 12.3%

Ka Map System v2 — mode carte immersif (shell, bottom sheet, split redimensionnable, toolbar unifiée, mode dessin contextuel, fiche v2, markers/clusters premium)

- KaMapShell : prise de contrôle du viewport, mobile edge-to-edge + desktop split
- KaMapBottomSheet : résultats mobiles à 3 crans (mini/half/full), drag + snap
- KaMapDesktopSplit : split liste|carte redimensionnable, carte quasi plein écran
- KaMapToolbar : une seule grappe iconographique (zoom, 3D, dessin, localisation)
- KaDrawAreaMode : UI temporaire du dessin + CTA « Voir N … dans cette zone »
- KaPropertyPreview : fiche contextuelle, swipe/chevrons entre voisines
- KaMap : navControl optionnel, setClipPolygon (découpe client), getVisibleProperties,
  événement data unifié après rendu
- Markers v2 : pilules fines hairline + ombre diffuse ; clusters légers avec anneau,
  valeur indicative sous la bulle (tokens cluster.valueText/valueHalo)
- CSS v2 : surfaces floutées, hairlines, chrome minimal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 29 days ago (Aug 26, 2026) parent 5cd05c0

14 changed files +1,626 −42

modified package-lock.json +2 −2
@@ -1,12 +1,12 @@
1 1 {
2 2 "name": "@groupe-ka/ka-maps",
3 − "version": "0.1.0",
3 + "version": "0.2.0",
4 4 "lockfileVersion": 3,
5 5 "requires": true,
6 6 "packages": {
7 7 "": {
8 8 "name": "@groupe-ka/ka-maps",
9 − "version": "0.1.0",
9 + "version": "0.2.0",
10 10 "license": "UNLICENSED",
11 11 "dependencies": {
12 12 "@types/geojson": "^7946.0.16",
modified package.json +1 −1
@@ -1,6 +1,6 @@
1 1 {
2 2 "name": "@groupe-ka/ka-maps",
3 − "version": "0.1.0",
3 + "version": "0.2.0",
4 4 "description": "Ka Maps — real-estate geographic intelligence framework by Groupe Ka. Powers Lou-Ka, Immo-Ka and Vrai-Prix maps.",
5 5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 6 "license": "UNLICENSED",
modified src/core/KaMap.ts +50 −8
@@ -43,6 +43,7 @@ import {
43 43 expandBBox,
44 44 hashId,
45 45 isValidCoordinate,
46 + pointInPolygon,
46 47 propertiesToGeoJSON,
47 48 } from "../utils/geo.js";
48 49 import {
@@ -74,6 +75,9 @@ export interface KaMapOptions {
74 75 query?: BoundsQueryOptions;
75 76 /** Cooperative gestures on embedded maps (two-finger pan hint). */
76 77 cooperativeGestures?: boolean;
78 + /** Boutons +/− Mapbox natifs. false quand l'app fournit sa propre
79 + * toolbar (KaMapToolbar) — le pincement/molette reste actif. */
80 + navControl?: boolean;
77 81 /** Clustering tuning. Lou-Ka style per-building geocoding wants
78 82 * maxZoom 15 so stacked units stay grouped as long as possible.
79 83 * `valueClamp` bounds each item's contribution to the bubble mean. */
@@ -107,6 +111,12 @@ export class KaMap {
107 111 private data: GeoJSON.FeatureCollection = EMPTY_FC;
108 112 private byId = new Map<string, { hash: number; property: MapProperty }>();
109 113
114 + /** 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;
119 +
110 120 private selectedId: string | null = null;
111 121 private hoveredId: string | null = null;
112 122 private searchedBBox: BBox | null = null;
@@ -175,10 +185,12 @@ export class KaMap {
175 185 new mapboxgl.AttributionControl({ compact: true }),
176 186 "bottom-right",
177 187 );
178 − this.map.addControl(
179 − new mapboxgl.NavigationControl({ showCompass: false }),
180 − "top-right",
181 − );
188 + if (options.navControl !== false) {
189 + this.map.addControl(
190 + new mapboxgl.NavigationControl({ showCompass: false }),
191 + "top-right",
192 + );
193 + }
182 194 // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise.
183 195 this.map.touchZoomRotate.disableRotation();
184 196 this.map.dragRotate.disable();
@@ -187,11 +199,8 @@ export class KaMap {
187 199 if (options.adapter) {
188 200 this.scheduler = new BoundsQueryScheduler(options.adapter, options.query);
189 201 this.scheduler.onResult((result) => {
202 + this.lastTotalCount = result.totalCount;
190 203 this.setProperties(result.properties);
191 − this.events.emit("data", {
192 − count: result.properties.length,
193 − totalCount: result.totalCount,
194 − });
195 204 });
196 205 this.scheduler.onError((error) =>
197 206 this.events.emit("error", { scope: "query", error }),
@@ -448,6 +457,19 @@ export class KaMap {
448 457
449 458 /** Replace the rendered property set (adapter results or app-pushed). */
450 459 setProperties(properties: MapProperty[]): void {
460 + this.rawProperties = properties;
461 + this.renderProperties();
462 + }
463 +
464 + /** 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 >= 3
469 + ? this.rawProperties.filter((p) =>
470 + pointInPolygon(p.longitude, p.latitude, poly),
471 + )
472 + : this.rawProperties;
451 473 this.byId.clear();
452 474 for (const p of properties) {
453 475 if (!isValidCoordinate(p.latitude, p.longitude)) continue;
@@ -460,12 +482,32 @@ export class KaMap {
460 482 if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app");
461 483 this.applyFeatureStates();
462 484 if (this.overlayInstalled) this.scheduleOverlayVerify();
485 + this.events.emit("data", {
486 + count: this.byId.size,
487 + totalCount: this.lastTotalCount,
488 + });
489 + }
490 +
491 + /**
492 + * Découpe côté client : ne rendre que les items dans le polygone (apps
493 + * dont l'API ne filtre pas par polygone). Passer null pour tout rendre.
494 + * Indépendant du polygone AFFICHÉ (setDrawnPolygon) — l'app appelle
495 + * 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();
463 500 }
464 501
465 502 getProperty(id: string): MapProperty | undefined {
466 503 return this.byId.get(id)?.property;
467 504 }
468 505
506 + /** Items actuellement rendus, dans l'ordre du jeu de données. */
507 + getVisibleProperties(): MapProperty[] {
508 + return [...this.byId.values()].map((e) => e.property);
509 + }
510 +
469 511 /** Current filters forwarded to the adapter on every query. */
470 512 setFilters(filters: Record<string, unknown> | undefined): void {
471 513 this.filters = filters;
modified src/layers/propertyLayer.ts +59 −31
@@ -20,6 +20,7 @@ import { markerTokens } from "../theming/tokens.js";
20 20 export const PROPERTY_SOURCE_ID = "ka-properties";
21 21
22 22 export const LAYER_IDS = {
23 + clusterRing: "ka-cluster-ring",
23 24 clusters: "ka-clusters",
24 25 clusterCount: "ka-cluster-count",
25 26 clusterValue: "ka-cluster-value",
@@ -72,20 +73,22 @@ export function pillImageId(family: PillFamily, selected = false): string {
72 73 export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {
73 74 const DPR = 2;
74 75 const W = 64;
75 − const H = 30;
76 − const R = 13;
77 − const TAIL = 6;
76 + const H = 26;
77 + const R = 12.5;
78 + const TAIL = 5;
78 79
80 + // v2 « premium » : pilule plus fine, ombre diffuse légère, liseré hairline
81 + // (fini l'effet gros bouton opaque posé sur la carte).
79 82 const draw = (background: string, border: string): ImageData => {
80 83 const canvas = document.createElement("canvas");
81 84 canvas.width = W * DPR;
82 − canvas.height = (H + TAIL + 3) * DPR;
85 + canvas.height = (H + TAIL + 4) * DPR;
83 86 const ctx = canvas.getContext("2d");
84 87 if (!ctx) throw new Error("Canvas 2D indisponible");
85 88 ctx.scale(DPR, DPR);
86 − ctx.shadowColor = "rgba(10, 12, 10, 0.28)";
87 − ctx.shadowBlur = 4;
88 − ctx.shadowOffsetY = 1.5;
89 + ctx.shadowColor = "rgba(12, 14, 18, 0.20)";
90 + ctx.shadowBlur = 7;
91 + ctx.shadowOffsetY = 2;
89 92 // corps de la pastille
90 93 ctx.beginPath();
91 94 ctx.roundRect(1, 1, W - 2, H - 2, R);
@@ -94,16 +97,16 @@ export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {
94 97 // pointe vers la coordonnée
95 98 ctx.shadowColor = "transparent";
96 99 ctx.beginPath();
97 − ctx.moveTo(W / 2 - 5.5, H - 1.5);
100 + ctx.moveTo(W / 2 - 4.5, H - 1.5);
98 101 ctx.lineTo(W / 2, H + TAIL - 1);
99 − ctx.lineTo(W / 2 + 5.5, H - 1.5);
102 + ctx.lineTo(W / 2 + 4.5, H - 1.5);
100 103 ctx.closePath();
101 104 ctx.fillStyle = background;
102 105 ctx.fill();
103 106 ctx.beginPath();
104 107 ctx.roundRect(1, 1, W - 2, H - 2, R);
105 108 ctx.strokeStyle = border;
106 − ctx.lineWidth = 1.4;
109 + ctx.lineWidth = 1.1;
107 110 ctx.stroke();
108 111 return ctx.getImageData(0, 0, canvas.width, canvas.height);
109 112 };
@@ -236,6 +239,34 @@ export function buildPropertyLayers(
236 239 1,
237 240 ] as ExpressionSpecification;
238 241
242 + // v2 : bulles compactes et légères — halo diffus + pastille — au lieu des
243 + // gros disques opaques. La valeur indicative s'affiche SOUS la bulle.
244 + const clusterRadius: ExpressionSpecification = [
245 + "step",
246 + ["get", "point_count"],
247 + 12,
248 + 10,
249 + 14.5,
250 + 50,
251 + 17,
252 + 200,
253 + 20,
254 + ];
255 +
256 + const clusterRing: CircleLayerSpecification = {
257 + id: LAYER_IDS.clusterRing,
258 + slot: "top",
259 + type: "circle",
260 + source: sourceId,
261 + filter: ["has", "point_count"],
262 + paint: {
263 + "circle-color": theme.cluster.border,
264 + "circle-radius": ["+", clusterRadius, 5] as ExpressionSpecification,
265 + "circle-opacity": 0.22,
266 + "circle-blur": 0.45,
267 + },
268 + };
269 +
239 270 const clusterCircle: CircleLayerSpecification = {
240 271 id: LAYER_IDS.clusters,
241 272 slot: "top",
@@ -245,19 +276,9 @@ export function buildPropertyLayers(
245 276 paint: {
246 277 "circle-color": theme.cluster.background,
247 278 "circle-stroke-color": theme.cluster.border,
248 − "circle-stroke-width": 2.5,
249 − "circle-radius": [
250 − "step",
251 − ["get", "point_count"],
252 − 16,
253 − 10,
254 − 20,
255 − 50,
256 − 25,
257 − 200,
258 − 31,
259 − ],
260 − "circle-opacity": 0.95,
279 + "circle-stroke-width": 1.5,
280 + "circle-radius": clusterRadius,
281 + "circle-opacity": 0.96,
261 282 },
262 283 };
263 284
@@ -270,20 +291,21 @@ export function buildPropertyLayers(
270 291 layout: {
271 292 "text-field": ["get", "point_count_abbreviated"],
272 293 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
273 − "text-size": 13,
274 − "text-offset": [0, -0.32],
294 + "text-size": ["step", ["get", "point_count"], 11.5, 50, 12.5],
275 295 "text-allow-overlap": true,
276 296 },
277 297 paint: { "text-color": theme.cluster.text },
278 298 };
279 299
280 − // Second line inside the cluster bubble: indicative (mean) value.
300 + // Valeur indicative (moyenne bornée) sous la bulle — texte halo, pas de
301 + // deuxième boîte : la carte respire.
281 302 const clusterValue: SymbolLayerSpecification = {
282 303 id: LAYER_IDS.clusterValue,
283 304 slot: "top",
284 305 type: "symbol",
285 306 source: sourceId,
286 307 filter: ["all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0]],
308 + minzoom: 9,
287 309 layout: {
288 310 "text-field": [
289 311 "concat",
@@ -296,10 +318,16 @@ export function buildPropertyLayers(
296 318 ],
297 319 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],
298 320 "text-size": 10,
299 − "text-offset": [0, 0.75],
321 + "text-anchor": "top",
322 + "text-offset": [0, 1.55],
300 323 "text-allow-overlap": true,
301 324 },
302 − paint: { "text-color": theme.cluster.text, "text-opacity": 0.85 },
325 + paint: {
326 + "text-color": theme.cluster.valueText ?? theme.cluster.border,
327 + "text-halo-color": theme.cluster.valueHalo ?? "rgba(255,255,255,0.85)",
328 + "text-halo-width": 1.1,
329 + "text-opacity": 0.95,
330 + },
303 331 };
304 332
305 333 const pointDot: CircleLayerSpecification = {
@@ -336,13 +364,13 @@ export function buildPropertyLayers(
336 364 layout: {
337 365 "icon-image": pillIconExpression(null),
338 366 "icon-text-fit": "both",
339 − "icon-text-fit-padding": [3, 9, 8, 9],
367 + "icon-text-fit-padding": [2.5, 8.5, 7.5, 8.5],
340 368 "icon-anchor": "bottom",
341 369 "icon-allow-overlap": false,
342 370 "icon-optional": false,
343 371 "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification),
344 372 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
345 − "text-size": ["interpolate", ["linear"], ["zoom"], 13, 11, 17, 13],
373 + "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10.5, 17, 12.5],
346 374 "text-anchor": "bottom",
347 375 "text-offset": [0, -0.9],
348 376 "text-allow-overlap": false,
@@ -362,7 +390,7 @@ export function buildPropertyLayers(
362 390 },
363 391 };
364 392
365 − return [clusterCircle, clusterCount, clusterValue, pointDot, pointPill];
393 + return [clusterRing, clusterCircle, clusterCount, clusterValue, pointDot, pointPill];
366 394 }
367 395
368 396 /**
added src/react/KaDrawAreaMode.tsx +121 −0
@@ -0,0 +1,121 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaDrawAreaMode — l'interface TEMPORAIRE du dessin de zone (v2). Plus de
7 + * grosse boîte permanente : pendant le tracé, un bandeau d'instructions
8 + * discret ; une fois la zone posée, un CTA contextuel « Voir N propriétés
9 + * dans cette zone » + retrait en un geste. Tout disparaît hors du mode.
10 + */
11 +
12 +import {
13 + useEffect,
14 + useState,
15 + type ReactElement,
16 +} from "react";
17 +import { useKaMap } from "./KaMapView.js";
18 +
19 +export interface KaDrawAreaModeProps {
20 + /** « 184 propriétés » — libellé du compte (le compte vient de l'événement
21 + * data du moteur ; l'app peut le surcharger avec son total serveur). */
22 + formatCount?: (n: number) => string;
23 + /** Total maîtrisé par l'app (prime sur le compte du moteur). */
24 + count?: number | null;
25 + /** Clic sur le CTA (défaut : cadrer la zone). */
26 + onSee?: () => void;
27 + /** Zone retirée. */
28 + onClear?: () => void;
29 +}
30 +
31 +export function KaDrawAreaMode(props: KaDrawAreaModeProps): ReactElement | null {
32 + const map = useKaMap();
33 + const [drawing, setDrawing] = useState(false);
34 + const [hasZone, setHasZone] = useState(false);
35 + const [engineCount, setEngineCount] = useState<number | null>(null);
36 +
37 + useEffect(() => {
38 + if (!map) return;
39 + setDrawing(map.isDrawing());
40 + setHasZone(map.getDrawnPolygon() !== null);
41 + const offs = [
42 + map.events.on("draw", ({ polygon, drawing: d }) => {
43 + setDrawing(d);
44 + setHasZone(polygon !== null);
45 + }),
46 + map.events.on("data", ({ count }) => setEngineCount(count)),
47 + ];
48 + return () => offs.forEach((off) => off());
49 + }, [map]);
50 +
51 + if (!map) return null;
52 +
53 + if (drawing) {
54 + return (
55 + <div className="ka-drawmode" role="status">
56 + <span className="ka-drawmode-hint">
57 + Touchez la carte pour poser des points — refermez sur le premier
58 + </span>
59 + <button
60 + type="button"
61 + className="ka-drawmode-cancel"
62 + onClick={() => map.cancelDraw()}
63 + >
64 + Annuler
65 + </button>
66 + </div>
67 + );
68 + }
69 +
70 + if (!hasZone) return null;
71 +
72 + const n = props.count ?? engineCount;
73 + const label =
74 + n == null
75 + ? "Zone dessinée"
76 + : props.formatCount
77 + ? props.formatCount(n)
78 + : `${n.toLocaleString("fr-CA")} résultat${n > 1 ? "s" : ""}`;
79 +
80 + return (
81 + <div className="ka-drawzone" role="group" aria-label="Zone dessinée">
82 + <button
83 + type="button"
84 + className="ka-drawzone-see"
85 + onClick={() => {
86 + if (props.onSee) {
87 + props.onSee();
88 + return;
89 + }
90 + const poly = map.getDrawnPolygon();
91 + if (poly) {
92 + const lngs = poly.map((p) => p[0]);
93 + const lats = poly.map((p) => p[1]);
94 + map.fitBounds(
95 + {
96 + west: Math.min(...lngs),
97 + south: Math.min(...lats),
98 + east: Math.max(...lngs),
99 + north: Math.max(...lats),
100 + },
101 + { padding: 64 },
102 + );
103 + }
104 + }}
105 + >
106 + {label} dans cette zone
107 + </button>
108 + <button
109 + type="button"
110 + className="ka-drawzone-clear"
111 + onClick={() => {
112 + map.clearDrawnPolygon();
113 + props.onClear?.();
114 + }}
115 + aria-label="Retirer la zone dessinée"
116 + >
117 + ✕
118 + </button>
119 + </div>
120 + );
121 +}
added src/react/KaMapBottomSheet.tsx +141 −0
@@ -0,0 +1,141 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaMapBottomSheet — le volet de résultats mobile du Ka Map System v2.
7 + * Trois crans : mini (poignée + en-tête), half (mi-écran), full (quasi
8 + * plein écran). Glissement au doigt sur la poignée/l'en-tête, snap au cran
9 + * le plus proche à la relâche, tap sur la poignée pour passer au cran
10 + * suivant. Le contenu ne défile qu'en position full — sinon le geste
11 + * vertical appartient au sheet.
12 + */
13 +
14 +import {
15 + useCallback,
16 + useEffect,
17 + useRef,
18 + useState,
19 + type ReactElement,
20 + type ReactNode,
21 +} from "react";
22 +
23 +export type KaSheetPosition = "mini" | "half" | "full";
24 +
25 +const POSITIONS: KaSheetPosition[] = ["mini", "half", "full"];
26 +
27 +/** Hauteur cible (px) d'un cran pour un viewport donné. */
28 +function snapHeight(pos: KaSheetPosition, viewport: number): number {
29 + switch (pos) {
30 + case "mini":
31 + return 96;
32 + case "half":
33 + return Math.round(viewport * 0.44);
34 + case "full":
35 + return Math.round(viewport - 108);
36 + }
37 +}
38 +
39 +export interface KaMapBottomSheetProps {
40 + position: KaSheetPosition;
41 + onPosition: (p: KaSheetPosition) => void;
42 + /** En-tête toujours visible (compteur, tri) — zone de glissement. */
43 + header?: ReactNode;
44 + children: ReactNode;
45 +}
46 +
47 +export function KaMapBottomSheet(props: KaMapBottomSheetProps): ReactElement {
48 + const { position, onPosition } = props;
49 + const [dragHeight, setDragHeight] = useState<number | null>(null);
50 + const dragRef = useRef<{ startY: number; startH: number; moved: boolean } | null>(null);
51 + const sheetRef = useRef<HTMLDivElement | null>(null);
52 +
53 + const viewport = () => window.innerHeight;
54 +
55 + const onPointerDown = useCallback((e: React.PointerEvent) => {
56 + // Ne pas capturer les gestes commencés sur un élément interactif.
57 + const target = e.target as HTMLElement;
58 + if (target.closest("button, a, select, input, label")) return;
59 + const h = sheetRef.current?.getBoundingClientRect().height ?? 0;
60 + dragRef.current = { startY: e.clientY, startH: h, moved: false };
61 + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
62 + }, []);
63 +
64 + const onPointerMove = useCallback((e: React.PointerEvent) => {
65 + const drag = dragRef.current;
66 + if (!drag) return;
67 + const delta = drag.startY - e.clientY;
68 + if (Math.abs(delta) > 4) drag.moved = true;
69 + const max = snapHeight("full", viewport());
70 + const next = Math.min(max, Math.max(64, drag.startH + delta));
71 + setDragHeight(next);
72 + }, []);
73 +
74 + const settle = useCallback(
75 + (e: React.PointerEvent) => {
76 + const drag = dragRef.current;
77 + dragRef.current = null;
78 + if (!drag) return;
79 + try {
80 + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
81 + } catch {
82 + // capture déjà relâchée
83 + }
84 + setDragHeight(null);
85 + if (!drag.moved) {
86 + // Tap : cran suivant (mini → half → full → mini).
87 + const i = POSITIONS.indexOf(position);
88 + onPosition(POSITIONS[(i + 1) % POSITIONS.length] as KaSheetPosition);
89 + return;
90 + }
91 + const delta = drag.startY - e.clientY;
92 + const h = drag.startH + delta;
93 + const vp = viewport();
94 + let best: KaSheetPosition = "mini";
95 + let bestD = Infinity;
96 + for (const p of POSITIONS) {
97 + const d = Math.abs(snapHeight(p, vp) - h);
98 + if (d < bestD) {
99 + bestD = d;
100 + best = p;
101 + }
102 + }
103 + onPosition(best);
104 + },
105 + [position, onPosition],
106 + );
107 +
108 + // Recalage à l'orientation/redimensionnement (hauteur en px inline).
109 + const [, forceRender] = useState(0);
110 + useEffect(() => {
111 + const onResize = () => forceRender((x) => x + 1);
112 + window.addEventListener("resize", onResize);
113 + return () => window.removeEventListener("resize", onResize);
114 + }, []);
115 +
116 + const height = dragHeight ?? snapHeight(position, viewport());
117 +
118 + return (
119 + <div
120 + ref={sheetRef}
121 + className={`ka-sheet ka-sheet-${position}${dragHeight !== null ? " dragging" : ""}`}
122 + style={{ height }}
123 + role="region"
124 + aria-label="Résultats"
125 + >
126 + <div
127 + className="ka-sheet-grip"
128 + onPointerDown={onPointerDown}
129 + onPointerMove={onPointerMove}
130 + onPointerUp={settle}
131 + onPointerCancel={settle}
132 + >
133 + <span className="ka-sheet-handle" aria-hidden="true" />
134 + {props.header ? <div className="ka-sheet-head">{props.header}</div> : null}
135 + </div>
136 + <div className="ka-sheet-body" aria-hidden={position === "mini"}>
137 + {props.children}
138 + </div>
139 + </div>
140 + );
141 +}
added src/react/KaMapDesktopSplit.tsx +149 −0
@@ -0,0 +1,149 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaMapDesktopSplit — l'agencement grand écran du Ka Map System v2 :
7 + * résultats à gauche, carte à droite, séparés par une poignée glissable
8 + * (24 % à 60 % pour la liste, ratio persisté par app). Un bouton sur la
9 + * poignée bascule en mode « carte quasi plein écran » (liste repliée) et
10 + * inversement. Double-clic sur la poignée : ratio par défaut.
11 + */
12 +
13 +import {
14 + useCallback,
15 + useEffect,
16 + useRef,
17 + useState,
18 + type ReactElement,
19 + type ReactNode,
20 +} from "react";
21 +
22 +export type KaSplitLayout = "split" | "map";
23 +
24 +const DEFAULT_RATIO = 0.40;
25 +const MIN_RATIO = 0.24;
26 +const MAX_RATIO = 0.60;
27 +
28 +export interface KaMapDesktopSplitProps {
29 + layout: KaSplitLayout;
30 + onLayout: (l: KaSplitLayout) => void;
31 + /** Clé localStorage du ratio (ex. "ka-split-lou-ka"). */
32 + storageKey?: string;
33 + list: ReactNode;
34 + /** La carte. */
35 + children: ReactNode;
36 +}
37 +
38 +export function KaMapDesktopSplit(props: KaMapDesktopSplitProps): ReactElement {
39 + const { layout, onLayout } = props;
40 + const rootRef = useRef<HTMLDivElement | null>(null);
41 + const [ratio, setRatio] = useState<number>(() => {
42 + if (props.storageKey) {
43 + const saved = Number(localStorage.getItem(props.storageKey));
44 + if (Number.isFinite(saved) && saved >= MIN_RATIO && saved <= MAX_RATIO) {
45 + return saved;
46 + }
47 + }
48 + return DEFAULT_RATIO;
49 + });
50 + const [dragging, setDragging] = useState(false);
51 +
52 + const persist = useCallback(
53 + (r: number) => {
54 + if (props.storageKey) {
55 + try {
56 + localStorage.setItem(props.storageKey, String(r));
57 + } catch {
58 + // stockage plein/privé : le ratio reste pour la session
59 + }
60 + }
61 + },
62 + [props.storageKey],
63 + );
64 +
65 + const onPointerDown = useCallback(
66 + (e: React.PointerEvent) => {
67 + if (layout !== "split") return;
68 + e.preventDefault();
69 + setDragging(true);
70 + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
71 + },
72 + [layout],
73 + );
74 +
75 + const onPointerMove = useCallback(
76 + (e: React.PointerEvent) => {
77 + if (!dragging || !rootRef.current) return;
78 + const rect = rootRef.current.getBoundingClientRect();
79 + const r = (e.clientX - rect.left) / rect.width;
80 + setRatio(Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)));
81 + },
82 + [dragging],
83 + );
84 +
85 + const onPointerUp = useCallback(
86 + (e: React.PointerEvent) => {
87 + if (!dragging) return;
88 + setDragging(false);
89 + try {
90 + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
91 + } catch {
92 + // capture déjà relâchée
93 + }
94 + setRatio((r) => {
95 + persist(r);
96 + return r;
97 + });
98 + },
99 + [dragging, persist],
100 + );
101 +
102 + // La carte doit se recaler quand la colonne change de largeur.
103 + useEffect(() => {
104 + const t = setTimeout(() => window.dispatchEvent(new Event("resize")), 220);
105 + return () => clearTimeout(t);
106 + }, [layout, ratio]);
107 +
108 + const mapFull = layout === "map";
109 +
110 + return (
111 + <div
112 + ref={rootRef}
113 + className={`ka-split ka-mode-${layout}${dragging ? " dragging" : ""}`}
114 + style={{ "--ka-split": `${(ratio * 100).toFixed(2)}%` } as React.CSSProperties}
115 + >
116 + <div className="ka-split-list" aria-hidden={mapFull}>
117 + {props.list}
118 + </div>
119 + <div
120 + className="ka-split-handle"
121 + role="separator"
122 + aria-orientation="vertical"
123 + aria-label="Redimensionner les résultats et la carte"
124 + onPointerDown={onPointerDown}
125 + onPointerMove={onPointerMove}
126 + onPointerUp={onPointerUp}
127 + onPointerCancel={onPointerUp}
128 + onDoubleClick={() => {
129 + setRatio(DEFAULT_RATIO);
130 + persist(DEFAULT_RATIO);
131 + }}
132 + >
133 + <button
134 + type="button"
135 + className="ka-split-toggle"
136 + onClick={() => onLayout(mapFull ? "split" : "map")}
137 + onPointerDown={(e) => e.stopPropagation()}
138 + aria-label={mapFull ? "Réafficher la liste" : "Agrandir la carte"}
139 + title={mapFull ? "Réafficher la liste" : "Agrandir la carte"}
140 + >
141 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
142 + {mapFull ? <path d="M9 6l6 6-6 6" /> : <path d="M15 6l-6 6 6 6" />}
143 + </svg>
144 + </button>
145 + </div>
146 + <div className="ka-split-map">{props.children}</div>
147 + </div>
148 + );
149 +}
added src/react/KaMapShell.tsx +154 −0
@@ -0,0 +1,154 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaMapShell — le « mode carte » du Ka Map System v2. Prise de contrôle du
7 + * viewport : la carte devient LA surface de navigation, plus une vignette
8 + * dans une page. Barre supérieure ultra compacte, puis :
9 + * · mobile : carte edge-to-edge + résultats en bottom sheet à 3 crans ;
10 + * · desktop : split résultats|carte redimensionnable (KaMapDesktopSplit),
11 + * la carte pouvant passer quasi plein écran.
12 + * Le shell verrouille le scroll d'arrière-plan tant que le mode est actif.
13 + */
14 +
15 +import {
16 + createContext,
17 + useContext,
18 + useEffect,
19 + useMemo,
20 + useState,
21 + type ReactElement,
22 + type ReactNode,
23 +} from "react";
24 +import { KaMapBottomSheet, type KaSheetPosition } from "./KaMapBottomSheet.js";
25 +import { KaMapDesktopSplit, type KaSplitLayout } from "./KaMapDesktopSplit.js";
26 +
27 +export interface KaShellState {
28 + /** Point de rupture atteint : agencement mobile (sheet) ou desktop (split). */
29 + isMobile: boolean;
30 + /** Position courante du bottom sheet mobile. */
31 + sheet: KaSheetPosition;
32 + setSheet: (p: KaSheetPosition) => void;
33 + /** Agencement desktop courant (split | carte quasi plein écran). */
34 + layout: KaSplitLayout;
35 + setLayout: (l: KaSplitLayout) => void;
36 +}
37 +
38 +const KaShellContext = createContext<KaShellState | null>(null);
39 +
40 +/** État du mode carte (sheet, layout) depuis n'importe quel descendant. */
41 +export function useKaShell(): KaShellState | null {
42 + return useContext(KaShellContext);
43 +}
44 +
45 +export interface KaMapShellProps {
46 + /** Marque compacte affichée à gauche de la barre (ex. logo + « Carte »). */
47 + brand?: ReactNode;
48 + /** Sortie du mode carte (retour à la vue liste de l'app). */
49 + onExit?: () => void;
50 + exitLabel?: string;
51 + /** Zone droite de la barre : compteur, tri, bouton Filtres… */
52 + topExtras?: ReactNode;
53 + /** Volet résultats : colonne desktop ET contenu du sheet mobile. */
54 + list?: ReactNode;
55 + /** En-tête collant du volet résultats (compteur + tri) — aussi la partie
56 + * visible du sheet en position mini. */
57 + listHeader?: ReactNode;
58 + /** Largeur max (px) de l'agencement mobile. Défaut 780. */
59 + mobileBreakpoint?: number;
60 + /** Identifiant de persistance du ratio de split (localStorage). */
61 + storageKey?: string;
62 + /** La carte (KaMapView) — occupe toute la surface restante. */
63 + children: ReactNode;
64 + className?: string;
65 +}
66 +
67 +export function KaMapShell(props: KaMapShellProps): ReactElement {
68 + const breakpoint = props.mobileBreakpoint ?? 780;
69 + const [isMobile, setIsMobile] = useState(
70 + () => window.matchMedia(`(max-width: ${breakpoint}px)`).matches,
71 + );
72 + const [sheet, setSheet] = useState<KaSheetPosition>("half");
73 + const [layout, setLayout] = useState<KaSplitLayout>("split");
74 +
75 + useEffect(() => {
76 + const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
77 + const update = () => setIsMobile(mq.matches);
78 + mq.addEventListener("change", update);
79 + return () => mq.removeEventListener("change", update);
80 + }, [breakpoint]);
81 +
82 + // Mode carte = prise de contrôle : pas de scroll de page en dessous.
83 + useEffect(() => {
84 + const previous = document.body.style.overflow;
85 + document.body.style.overflow = "hidden";
86 + return () => {
87 + document.body.style.overflow = previous;
88 + };
89 + }, []);
90 +
91 + const state = useMemo<KaShellState>(
92 + () => ({ isMobile, sheet, setSheet, layout, setLayout }),
93 + [isMobile, sheet, layout],
94 + );
95 +
96 + return (
97 + <KaShellContext.Provider value={state}>
98 + <div
99 + className={`ka-shell ${isMobile ? "ka-shell-mobile" : "ka-shell-desktop"} ${props.className ?? ""}`}
100 + role="region"
101 + aria-label="Mode carte"
102 + >
103 + <div className="ka-shell-top">
104 + {props.onExit ? (
105 + <button
106 + type="button"
107 + className="ka-shell-exit"
108 + onClick={props.onExit}
109 + aria-label={props.exitLabel ?? "Revenir à la liste"}
110 + >
111 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
112 + <path d="M15 18l-6-6 6-6" />
113 + </svg>
114 + <span>{props.exitLabel ?? "Liste"}</span>
115 + </button>
116 + ) : null}
117 + {props.brand ? <div className="ka-shell-brand">{props.brand}</div> : null}
118 + <div className="ka-shell-extras">{props.topExtras}</div>
119 + </div>
120 +
121 + {isMobile ? (
122 + <>
123 + <div className="ka-shell-map">{props.children}</div>
124 + {props.list ? (
125 + <KaMapBottomSheet
126 + position={sheet}
127 + onPosition={setSheet}
128 + header={props.listHeader}
129 + >
130 + {props.list}
131 + </KaMapBottomSheet>
132 + ) : null}
133 + </>
134 + ) : (
135 + <KaMapDesktopSplit
136 + layout={layout}
137 + onLayout={setLayout}
138 + storageKey={props.storageKey}
139 + list={
140 + <>
141 + {props.listHeader ? (
142 + <div className="ka-pane-head">{props.listHeader}</div>
143 + ) : null}
144 + <div className="ka-pane-body">{props.list}</div>
145 + </>
146 + }
147 + >
148 + {props.children}
149 + </KaMapDesktopSplit>
150 + )}
151 + </div>
152 + </KaShellContext.Provider>
153 + );
154 +}
added src/react/KaMapToolbar.tsx +209 −0
@@ -0,0 +1,209 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaMapToolbar — LA grappe de contrôles du Ka Map System v2. Fini les
7 + * pilules indépendantes qui flottent partout : une seule colonne compacte
8 + * de boutons iconographiques (zoom, 3D, dessiner, me localiser, filtres…),
9 + * regroupés par segments hairline. Chaque bouton expose son libellé en
10 + * infobulle/aria — le chrome reste minimal, la carte respire.
11 + */
12 +
13 +import {
14 + useEffect,
15 + useState,
16 + type ReactElement,
17 + type ReactNode,
18 +} from "react";
19 +import { useKaMap } from "./KaMapView.js";
20 +
21 +/* ------------------------------------------------------------------ icônes */
22 +
23 +function Icon({ d, filled }: { d: string; filled?: boolean }): ReactElement {
24 + return (
25 + <svg
26 + width="17"
27 + height="17"
28 + viewBox="0 0 24 24"
29 + fill={filled ? "currentColor" : "none"}
30 + stroke="currentColor"
31 + strokeWidth="2"
32 + strokeLinecap="round"
33 + strokeLinejoin="round"
34 + aria-hidden="true"
35 + >
36 + <path d={d} />
37 + </svg>
38 + );
39 +}
40 +
41 +const ICONS = {
42 + plus: "M12 5v14M5 12h14",
43 + minus: "M5 12h14",
44 + locate: "M12 2v3M12 19v3M2 12h3M19 12h3M12 8a4 4 0 100 8 4 4 0 000-8z",
45 + draw: "M12 19l7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.586 7.586M11 13a2 2 0 100-4 2 2 0 000 4z",
46 + layers: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
47 + filters: "M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4",
48 + close: "M18 6L6 18M6 6l12 12",
49 +};
50 +
51 +/* ------------------------------------------------------------- primitives */
52 +
53 +export interface KaToolbarButtonProps {
54 + label: string;
55 + onClick: () => void;
56 + active?: boolean;
57 + disabled?: boolean;
58 + icon?: keyof typeof ICONS;
59 + /** Icône/contenu custom (prime sur `icon`). */
60 + children?: ReactNode;
61 + badge?: string | number;
62 +}
63 +
64 +export function KaToolbarButton(props: KaToolbarButtonProps): ReactElement {
65 + return (
66 + <button
67 + type="button"
68 + className={`ka-tb-btn${props.active ? " on" : ""}`}
69 + onClick={props.onClick}
70 + disabled={props.disabled}
71 + aria-label={props.label}
72 + aria-pressed={props.active}
73 + title={props.label}
74 + >
75 + {props.children ?? (props.icon ? <Icon d={ICONS[props.icon]} /> : null)}
76 + {props.badge != null && props.badge !== 0 ? (
77 + <span className="ka-tb-badge">{props.badge}</span>
78 + ) : null}
79 + </button>
80 + );
81 +}
82 +
83 +/** Conteneur : colonne de segments (chaque enfant direct = un groupe). */
84 +export function KaMapToolbar(props: {
85 + children: ReactNode;
86 + className?: string;
87 +}): ReactElement {
88 + return (
89 + <div className={`ka-toolbar ${props.className ?? ""}`} role="toolbar" aria-label="Outils de carte">
90 + {props.children}
91 + </div>
92 + );
93 +}
94 +
95 +export function KaToolbarGroup(props: { children: ReactNode }): ReactElement {
96 + return <div className="ka-tb-group">{props.children}</div>;
97 +}
98 +
99 +/* ------------------------------------------------------- boutons intégrés */
100 +
101 +/** Zoom +/− (remplace le NavigationControl natif — passer navControl:false). */
102 +export function KaToolbarZoom(): ReactElement | null {
103 + const map = useKaMap();
104 + if (!map) return null;
105 + return (
106 + <>
107 + <KaToolbarButton label="Zoom avant" icon="plus" onClick={() => map.map.zoomIn()} />
108 + <KaToolbarButton label="Zoom arrière" icon="minus" onClick={() => map.map.zoomOut()} />
109 + </>
110 + );
111 +}
112 +
113 +/** Bascule 3D — libellé texte court, état visuel accentué. */
114 +export function KaToolbar3D(): ReactElement | null {
115 + const map = useKaMap();
116 + const [tilted, setTilted] = useState(() => map?.isTilted() ?? false);
117 +
118 + useEffect(() => {
119 + if (!map) return;
120 + return map.events.on("moveend", () => setTilted(map.isTilted()));
121 + }, [map]);
122 +
123 + if (!map) return null;
124 + return (
125 + <KaToolbarButton
126 + label={tilted ? "Vue à plat (2D)" : "Vue en relief (3D)"}
127 + active={tilted}
128 + onClick={() => map.setTilt(!tilted)}
129 + >
130 + <span className="ka-tb-txt">{tilted ? "2D" : "3D"}</span>
131 + </KaToolbarButton>
132 + );
133 +}
134 +
135 +/** Dessiner une zone — icône crayon ; état actif pendant le tracé et tant
136 + * qu'une zone est posée (re-clic : annule ou efface). */
137 +export function KaToolbarDraw(): ReactElement | null {
138 + const map = useKaMap();
139 + const [drawing, setDrawing] = useState(false);
140 + const [hasZone, setHasZone] = useState(false);
141 +
142 + useEffect(() => {
143 + if (!map) return;
144 + setDrawing(map.isDrawing());
145 + setHasZone(map.getDrawnPolygon() !== null);
146 + return map.events.on("draw", ({ polygon, drawing: d }) => {
147 + setDrawing(d);
148 + setHasZone(polygon !== null);
149 + });
150 + }, [map]);
151 +
152 + if (!map) return null;
153 + return (
154 + <KaToolbarButton
155 + label={
156 + drawing
157 + ? "Annuler le tracé"
158 + : hasZone
159 + ? "Effacer la zone dessinée"
160 + : "Dessiner une zone"
161 + }
162 + active={drawing || hasZone}
163 + icon="draw"
164 + onClick={() => {
165 + if (drawing) map.cancelDraw();
166 + else if (hasZone) map.clearDrawnPolygon();
167 + else map.startDraw();
168 + }}
169 + />
170 + );
171 +}
172 +
173 +/** Me localiser — géolocalisation sur geste explicite uniquement. */
174 +export function KaToolbarLocate(props: {
175 + /** Recentrage effectué — l'app peut synchroniser sa liste. */
176 + onLocated?: (pos: { lat: number; lng: number }) => void;
177 +}): ReactElement | null {
178 + const map = useKaMap();
179 + const [state, setState] = useState<"idle" | "busy" | "denied">("idle");
180 +
181 + if (!map) return null;
182 +
183 + const locate = () => {
184 + if (!("geolocation" in navigator)) {
185 + setState("denied");
186 + return;
187 + }
188 + setState("busy");
189 + navigator.geolocation.getCurrentPosition(
190 + (pos) => {
191 + setState("idle");
192 + const at = { lat: pos.coords.latitude, lng: pos.coords.longitude };
193 + map.flyTo(at, { zoom: Math.max(map.map.getZoom(), 13.5), duration: 700 });
194 + props.onLocated?.(at);
195 + },
196 + () => setState("denied"),
197 + { enableHighAccuracy: true, timeout: 10_000 },
198 + );
199 + };
200 +
201 + return (
202 + <KaToolbarButton
203 + label={state === "denied" ? "Géolocalisation indisponible" : "Me localiser"}
204 + icon="locate"
205 + disabled={state === "busy"}
206 + onClick={locate}
207 + />
208 + );
209 +}
modified src/react/KaMapView.tsx +1 −0
@@ -85,6 +85,7 @@ export function KaMapView(props: KaMapViewProps): ReactElement {
85 85 searchMode: p.searchMode,
86 86 query: p.query,
87 87 cooperativeGestures: p.cooperativeGestures,
88 + navControl: p.navControl,
88 89 cluster: p.cluster,
89 90 });
90 91
added src/react/KaPropertyPreview.tsx +177 −0
@@ -0,0 +1,177 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaPropertyPreview — la fiche contextuelle v2. Tap sur une pastille :
7 + * pas de navigation, une carte élégante — photo, prix, adresse, traits
8 + * principaux, lien fiche (contenu rendu par l'app, render prop).
9 + * · mobile : bottom card flottante, glissement horizontal pour passer
10 + * aux propriétés voisines (l'ordre de proximité est figé à l'ouverture) ;
11 + * · desktop : carte flottante ancrée en bas à gauche de la carte ;
12 + * · chevrons ‹ › des deux côtés, Échap/✕ pour fermer.
13 + */
14 +
15 +import {
16 + useEffect,
17 + useRef,
18 + useState,
19 + type ReactElement,
20 + type ReactNode,
21 +} from "react";
22 +import type { MapProperty } from "../types/index.js";
23 +import { haversineMeters } from "../utils/geo.js";
24 +import { useKaMap } from "./KaMapView.js";
25 +
26 +export interface KaPropertyPreviewProps {
27 + /** Rendu du CONTENU (photo, prix, traits, favori…) — app-owned. */
28 + render: (property: MapProperty, close: () => void) => ReactNode;
29 + /** Navigation ‹ › + swipe entre propriétés proches. Défaut : activée. */
30 + withNav?: boolean;
31 + mobileBreakpoint?: number;
32 + closeLabel?: string;
33 +}
34 +
35 +export function KaPropertyPreview(props: KaPropertyPreviewProps): ReactElement | null {
36 + const map = useKaMap();
37 + const [property, setProperty] = useState<MapProperty | null>(null);
38 + const [mobile, setMobile] = useState(false);
39 + const [slide, setSlide] = useState<"left" | "right" | null>(null);
40 + const breakpoint = props.mobileBreakpoint ?? 780;
41 +
42 + /** Ordre de navigation figé quand la fiche s'ouvre : propriétés triées
43 + * par distance de l'élément sélectionné (les « voisines » d'abord). */
44 + const orderRef = useRef<string[]>([]);
45 + const touchRef = useRef<{ x: number; y: number } | null>(null);
46 +
47 + useEffect(() => {
48 + if (!map) return;
49 + return map.events.on("select", ({ propertyId }) => {
50 + const p = propertyId ? map.getProperty(propertyId) ?? null : null;
51 + setProperty((previous) => {
52 + if (p && !previous) {
53 + // ouverture : figer l'ordre de proximité autour de p
54 + orderRef.current = map
55 + .getVisibleProperties()
56 + .map((item) => ({
57 + id: item.id,
58 + d: haversineMeters(p.latitude, p.longitude, item.latitude, item.longitude),
59 + }))
60 + .sort((a, b) => a.d - b.d)
61 + .map((item) => item.id);
62 + }
63 + if (!p) orderRef.current = [];
64 + return p;
65 + });
66 + });
67 + }, [map]);
68 +
69 + useEffect(() => {
70 + const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
71 + const update = () => setMobile(mq.matches);
72 + update();
73 + mq.addEventListener("change", update);
74 + return () => mq.removeEventListener("change", update);
75 + }, [breakpoint]);
76 +
77 + useEffect(() => {
78 + if (!property || !map) return;
79 + const onKey = (e: KeyboardEvent) => {
80 + if (e.key === "Escape") map.select(null, "app");
81 + if (e.key === "ArrowRight") step(1);
82 + if (e.key === "ArrowLeft") step(-1);
83 + };
84 + window.addEventListener("keydown", onKey);
85 + return () => window.removeEventListener("keydown", onKey);
86 + // eslint-disable-next-line react-hooks/exhaustive-deps
87 + }, [property, map]);
88 +
89 + if (!map || !property) return null;
90 +
91 + const close = () => map.select(null, "app");
92 +
93 + const order = orderRef.current;
94 + const index = order.indexOf(property.id);
95 + const canNav = props.withNav !== false && order.length > 1 && index !== -1;
96 +
97 + const step = (dir: 1 | -1) => {
98 + if (!canNav) return;
99 + const next = order[(index + dir + order.length) % order.length];
100 + if (!next) return;
101 + setSlide(dir === 1 ? "left" : "right");
102 + map.select(next, "app");
103 + };
104 +
105 + const onTouchStart = (e: React.TouchEvent) => {
106 + const t = e.touches[0];
107 + if (t) touchRef.current = { x: t.clientX, y: t.clientY };
108 + };
109 + const onTouchEnd = (e: React.TouchEvent) => {
110 + const start = touchRef.current;
111 + touchRef.current = null;
112 + const t = e.changedTouches[0];
113 + if (!start || !t) return;
114 + const dx = t.clientX - start.x;
115 + const dy = t.clientY - start.y;
116 + if (Math.abs(dx) > 52 && Math.abs(dx) > Math.abs(dy) * 1.4) {
117 + step(dx < 0 ? 1 : -1);
118 + }
119 + };
120 +
121 + return (
122 + <div
123 + className={`ka-prev ${mobile ? "ka-prev-mobile" : "ka-prev-desktop"}`}
124 + role="dialog"
125 + aria-label={property.address ?? "Propriété sélectionnée"}
126 + onTouchStart={onTouchStart}
127 + onTouchEnd={onTouchEnd}
128 + >
129 + <button
130 + type="button"
131 + className="ka-prev-close"
132 + onClick={close}
133 + aria-label={props.closeLabel ?? "Fermer"}
134 + >
135 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" aria-hidden="true">
136 + <path d="M18 6L6 18M6 6l12 12" />
137 + </svg>
138 + </button>
139 + {canNav ? (
140 + <>
141 + <button
142 + type="button"
143 + className="ka-prev-nav ka-prev-prev"
144 + onClick={() => step(-1)}
145 + aria-label="Propriété précédente"
146 + >
147 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
148 + <path d="M15 18l-6-6 6-6" />
149 + </svg>
150 + </button>
151 + <button
152 + type="button"
153 + className="ka-prev-nav ka-prev-next"
154 + onClick={() => step(1)}
155 + aria-label="Propriété suivante"
156 + >
157 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
158 + <path d="M9 6l6 6-6 6" />
159 + </svg>
160 + </button>
161 + </>
162 + ) : null}
163 + <div
164 + key={property.id}
165 + className={`ka-prev-card${slide ? ` ka-slide-${slide}` : ""}`}
166 + onAnimationEnd={() => setSlide(null)}
167 + >
168 + {props.render(property, close)}
169 + </div>
170 + {canNav ? (
171 + <div className="ka-prev-pos" aria-hidden="true">
172 + {index + 1} / {order.length}
173 + </div>
174 + ) : null}
175 + </div>
176 + );
177 +}
modified src/react/index.ts +33 −0
@@ -18,3 +18,36 @@ export {
18 18 Tilt3DControl,
19 19 } from "./controls.js";
20 20 export { PropertyPreview, type PropertyPreviewProps } from "./PropertyPreview.js";
21 +
22 +// ---- Ka Map System v2 ----
23 +export {
24 + KaMapShell,
25 + useKaShell,
26 + type KaMapShellProps,
27 + type KaShellState,
28 +} from "./KaMapShell.js";
29 +export {
30 + KaMapBottomSheet,
31 + type KaMapBottomSheetProps,
32 + type KaSheetPosition,
33 +} from "./KaMapBottomSheet.js";
34 +export {
35 + KaMapDesktopSplit,
36 + type KaMapDesktopSplitProps,
37 + type KaSplitLayout,
38 +} from "./KaMapDesktopSplit.js";
39 +export {
40 + KaMapToolbar,
41 + KaToolbarButton,
42 + KaToolbarGroup,
43 + KaToolbar3D,
44 + KaToolbarDraw,
45 + KaToolbarLocate,
46 + KaToolbarZoom,
47 + type KaToolbarButtonProps,
48 +} from "./KaMapToolbar.js";
49 +export { KaDrawAreaMode, type KaDrawAreaModeProps } from "./KaDrawAreaMode.js";
50 +export {
51 + KaPropertyPreview,
52 + type KaPropertyPreviewProps,
53 +} from "./KaPropertyPreview.js";
modified src/styles/ka-maps.css +525 −0
@@ -379,3 +379,528 @@
379 379 font-weight: 500;
380 380 opacity: 0.75;
381 381 }
382 +
383 +/* =============================================================================
384 + KA MAP SYSTEM v2 — mode carte immersif (shell, sheet, split, toolbar,
385 + mode dessin, fiche contextuelle). Chrome minimal : surfaces translucides
386 + floutées, hairlines, une seule grappe de contrôles. Thémé par les mêmes
387 + variables --ka-* que la v1.
388 +============================================================================= */
389 +
390 +.ka-shell {
391 + position: fixed;
392 + inset: 0;
393 + z-index: var(--ka-shell-z, 640);
394 + background: var(--ka-surface);
395 + font-family: var(--ka-font);
396 + display: flex;
397 + flex-direction: column;
398 + overscroll-behavior: contain;
399 +}
400 +
401 +/* ---- barre supérieure ultra compacte ---- */
402 +.ka-shell-top {
403 + flex: 0 0 auto;
404 + height: 52px;
405 + display: flex;
406 + align-items: center;
407 + gap: 10px;
408 + padding: 0 10px 0 6px;
409 + background: var(--ka-surface);
410 + border-bottom: 1px solid color-mix(in srgb, var(--ka-ink) 14%, transparent);
411 + z-index: 30;
412 +}
413 +
414 +.ka-shell-exit {
415 + display: inline-flex;
416 + align-items: center;
417 + gap: 4px;
418 + border: 0;
419 + background: transparent;
420 + color: var(--ka-ink);
421 + font: 600 13px/1 var(--ka-font);
422 + padding: 8px 10px;
423 + border-radius: 10px;
424 + cursor: pointer;
425 + min-height: 40px;
426 +}
427 +.ka-shell-exit:hover { background: color-mix(in srgb, var(--ka-ink) 7%, transparent); }
428 +
429 +.ka-shell-brand {
430 + display: inline-flex;
431 + align-items: center;
432 + gap: 8px;
433 + min-width: 0;
434 + font: 700 14px/1 var(--ka-font);
435 + color: var(--ka-ink);
436 +}
437 +
438 +.ka-shell-extras {
439 + margin-left: auto;
440 + display: inline-flex;
441 + align-items: center;
442 + gap: 8px;
443 + min-width: 0;
444 +}
445 +
446 +/* ---- mobile : carte edge-to-edge ---- */
447 +.ka-shell-mobile .ka-shell-map {
448 + position: relative;
449 + flex: 1 1 auto;
450 + min-height: 0;
451 +}
452 +.ka-shell-mobile .ka-shell-map .ka-map { position: absolute; inset: 0; }
453 +
454 +/* ---- bottom sheet à crans ---- */
455 +.ka-sheet {
456 + position: absolute;
457 + left: 0;
458 + right: 0;
459 + bottom: 0;
460 + z-index: 40;
461 + display: flex;
462 + flex-direction: column;
463 + background: var(--ka-surface);
464 + border-radius: 18px 18px 0 0;
465 + box-shadow: 0 -10px 34px rgba(12, 14, 18, 0.18);
466 + border-top: 1px solid color-mix(in srgb, var(--ka-ink) 10%, transparent);
467 + transition: height 0.26s cubic-bezier(0.3, 0.9, 0.3, 1);
468 + padding-bottom: env(safe-area-inset-bottom);
469 + touch-action: none;
470 +}
471 +.ka-sheet.dragging { transition: none; }
472 +.ka-sheet-body { touch-action: pan-y; }
473 +
474 +.ka-sheet-grip {
475 + flex: 0 0 auto;
476 + padding: 7px 14px 8px;
477 + cursor: grab;
478 + user-select: none;
479 +}
480 +.ka-sheet-grip:active { cursor: grabbing; }
481 +.ka-sheet-handle {
482 + display: block;
483 + width: 42px;
484 + height: 4px;
485 + border-radius: 999px;
486 + margin: 0 auto 6px;
487 + background: color-mix(in srgb, var(--ka-ink) 22%, transparent);
488 +}
489 +.ka-sheet-head {
490 + display: flex;
491 + align-items: center;
492 + justify-content: space-between;
493 + gap: 10px;
494 + min-height: 30px;
495 +}
496 +
497 +.ka-sheet-body {
498 + flex: 1 1 auto;
499 + min-height: 0;
500 + overflow: hidden;
501 + padding: 0 14px;
502 +}
503 +.ka-sheet-full .ka-sheet-body { overflow-y: auto; -webkit-overflow-scrolling: touch; }
504 +.ka-sheet-half .ka-sheet-body { overflow-y: auto; -webkit-overflow-scrolling: touch; }
505 +.ka-sheet-mini .ka-sheet-body { visibility: hidden; }
506 +
507 +/* ---- desktop : split résultats | carte ---- */
508 +.ka-split {
509 + position: relative;
510 + flex: 1 1 auto;
511 + min-height: 0;
512 + display: grid;
513 + grid-template-columns: var(--ka-split, 40%) 14px 1fr;
514 +}
515 +.ka-split.dragging { cursor: col-resize; user-select: none; }
516 +.ka-split-map { position: relative; min-width: 0; }
517 +.ka-split-map .ka-map { position: absolute; inset: 0; }
518 +
519 +.ka-split-list {
520 + min-width: 0;
521 + min-height: 0;
522 + display: flex;
523 + flex-direction: column;
524 + background: var(--ka-surface);
525 +}
526 +.ka-split.ka-mode-map { grid-template-columns: 0 14px 1fr; }
527 +.ka-split.ka-mode-map .ka-split-list { display: none; }
528 +
529 +.ka-pane-head {
530 + flex: 0 0 auto;
531 + padding: 10px 16px 8px;
532 + border-bottom: 1px solid color-mix(in srgb, var(--ka-ink) 10%, transparent);
533 +}
534 +.ka-pane-body {
535 + flex: 1 1 auto;
536 + min-height: 0;
537 + overflow-y: auto;
538 + padding: 12px 16px 22px;
539 + scrollbar-width: thin;
540 +}
541 +
542 +.ka-split-handle {
543 + position: relative;
544 + cursor: col-resize;
545 + z-index: 20;
546 +}
547 +.ka-split-handle::before {
548 + content: "";
549 + position: absolute;
550 + top: 0;
551 + bottom: 0;
552 + left: 50%;
553 + width: 1px;
554 + background: color-mix(in srgb, var(--ka-ink) 14%, transparent);
555 +}
556 +.ka-split-handle:hover::before,
557 +.ka-split.dragging .ka-split-handle::before {
558 + width: 3px;
559 + margin-left: -1px;
560 + background: var(--ka-accent);
561 +}
562 +.ka-split-toggle {
563 + position: absolute;
564 + top: 50%;
565 + left: 50%;
566 + transform: translate(-50%, -50%);
567 + width: 26px;
568 + height: 44px;
569 + border-radius: 8px;
570 + border: 1px solid color-mix(in srgb, var(--ka-ink) 16%, transparent);
571 + background: var(--ka-surface);
572 + color: var(--ka-ink);
573 + cursor: pointer;
574 + display: grid;
575 + place-items: center;
576 + box-shadow: 0 2px 10px rgba(12, 14, 18, 0.12);
577 +}
578 +.ka-split-toggle:hover { border-color: var(--ka-accent); color: var(--ka-accent); }
579 +
580 +/* ---- toolbar : LA grappe de contrôles ---- */
581 +.ka-toolbar {
582 + position: absolute;
583 + top: 12px;
584 + right: 12px;
585 + z-index: 20;
586 + display: flex;
587 + flex-direction: column;
588 + gap: 8px;
589 +}
590 +.ka-tb-group {
591 + display: flex;
592 + flex-direction: column;
593 + border-radius: 12px;
594 + overflow: hidden;
595 + border: 1px solid color-mix(in srgb, var(--ka-ink) 14%, transparent);
596 + background: color-mix(in srgb, var(--ka-surface) 88%, transparent);
597 + backdrop-filter: blur(10px);
598 + -webkit-backdrop-filter: blur(10px);
599 + box-shadow: 0 3px 14px rgba(12, 14, 18, 0.13);
600 +}
601 +.ka-tb-btn {
602 + position: relative;
603 + width: 42px;
604 + height: 42px;
605 + border: 0;
606 + background: transparent;
607 + color: var(--ka-ink);
608 + display: grid;
609 + place-items: center;
610 + cursor: pointer;
611 +}
612 +.ka-tb-btn + .ka-tb-btn {
613 + border-top: 1px solid color-mix(in srgb, var(--ka-ink) 9%, transparent);
614 +}
615 +.ka-tb-btn:hover { background: color-mix(in srgb, var(--ka-ink) 6%, transparent); }
616 +.ka-tb-btn.on { background: var(--ka-accent); color: var(--ka-on-accent); }
617 +.ka-tb-btn:disabled { opacity: 0.5; cursor: default; }
618 +.ka-tb-txt { font: 800 11px/1 var(--ka-font); letter-spacing: 0.02em; }
619 +.ka-tb-badge {
620 + position: absolute;
621 + top: 5px;
622 + right: 5px;
623 + min-width: 15px;
624 + height: 15px;
625 + padding: 0 4px;
626 + border-radius: 999px;
627 + background: var(--ka-accent);
628 + color: var(--ka-on-accent);
629 + font: 700 9.5px/15px var(--ka-font);
630 + text-align: center;
631 +}
632 +.ka-tb-btn.on .ka-tb-badge { background: var(--ka-on-accent); color: var(--ka-accent); }
633 +
634 +/* ---- mode dessin (temporaire) ---- */
635 +.ka-drawmode {
636 + position: absolute;
637 + top: 12px;
638 + left: 50%;
639 + transform: translateX(-50%);
640 + z-index: 25;
641 + display: inline-flex;
642 + align-items: center;
643 + gap: 10px;
644 + max-width: calc(100% - 24px);
645 + background: color-mix(in srgb, var(--ka-ink) 88%, transparent);
646 + color: #fff;
647 + border-radius: 999px;
648 + padding: 8px 8px 8px 16px;
649 + font: 500 12px/1.3 var(--ka-font);
650 + backdrop-filter: blur(8px);
651 + -webkit-backdrop-filter: blur(8px);
652 + box-shadow: 0 4px 18px rgba(12, 14, 18, 0.25);
653 +}
654 +.ka-drawmode-cancel {
655 + border: 0;
656 + border-radius: 999px;
657 + background: rgba(255, 255, 255, 0.16);
658 + color: #fff;
659 + font: 600 12px/1 var(--ka-font);
660 + padding: 7px 12px;
661 + cursor: pointer;
662 +}
663 +.ka-drawmode-cancel:hover { background: rgba(255, 255, 255, 0.28); }
664 +
665 +.ka-drawzone {
666 + position: absolute;
667 + bottom: 18px;
668 + left: 50%;
669 + transform: translateX(-50%);
670 + z-index: 25;
671 + display: inline-flex;
672 + align-items: stretch;
673 + border-radius: 999px;
674 + overflow: hidden;
675 + box-shadow: 0 5px 20px rgba(12, 14, 18, 0.22);
676 +}
677 +.ka-drawzone-see {
678 + border: 0;
679 + background: var(--ka-accent);
680 + color: var(--ka-on-accent);
681 + font: 700 13px/1 var(--ka-font);
682 + padding: 12px 18px;
683 + cursor: pointer;
684 + white-space: nowrap;
685 +}
686 +.ka-drawzone-see:hover { filter: brightness(0.94); }
687 +.ka-drawzone-clear {
688 + border: 0;
689 + border-left: 1px solid color-mix(in srgb, var(--ka-on-accent) 30%, transparent);
690 + background: var(--ka-accent);
691 + color: var(--ka-on-accent);
692 + font: 600 13px/1 var(--ka-font);
693 + padding: 0 14px;
694 + cursor: pointer;
695 +}
696 +.ka-drawzone-clear:hover { filter: brightness(0.88); }
697 +
698 +/* ---- fiche contextuelle v2 ---- */
699 +.ka-prev {
700 + position: absolute;
701 + z-index: 50;
702 +}
703 +.ka-prev-desktop {
704 + left: 14px;
705 + bottom: 14px;
706 + width: 332px;
707 + max-width: calc(100% - 28px);
708 +}
709 +.ka-prev-mobile {
710 + left: 10px;
711 + right: 10px;
712 + bottom: calc(104px + env(safe-area-inset-bottom));
713 +}
714 +
715 +.ka-prev-card {
716 + background: var(--ka-surface);
717 + border: 1px solid color-mix(in srgb, var(--ka-ink) 12%, transparent);
718 + border-radius: 16px;
719 + overflow: hidden;
720 + box-shadow: 0 10px 34px rgba(12, 14, 18, 0.22);
721 +}
722 +.ka-slide-left { animation: ka-slide-left 0.22s cubic-bezier(0.3, 0.8, 0.3, 1); }
723 +.ka-slide-right { animation: ka-slide-right 0.22s cubic-bezier(0.3, 0.8, 0.3, 1); }
724 +@keyframes ka-slide-left {
725 + from { transform: translateX(34px); opacity: 0.4; }
726 + to { transform: translateX(0); opacity: 1; }
727 +}
728 +@keyframes ka-slide-right {
729 + from { transform: translateX(-34px); opacity: 0.4; }
730 + to { transform: translateX(0); opacity: 1; }
731 +}
732 +@media (prefers-reduced-motion: reduce) {
733 + .ka-slide-left, .ka-slide-right { animation: none; }
734 +}
735 +
736 +.ka-prev-close {
737 + position: absolute;
738 + top: 8px;
739 + right: 8px;
740 + z-index: 3;
741 + width: 28px;
742 + height: 28px;
743 + border-radius: 50%;
744 + border: 0;
745 + background: rgba(16, 18, 22, 0.55);
746 + color: #fff;
747 + display: grid;
748 + place-items: center;
749 + cursor: pointer;
750 + backdrop-filter: blur(4px);
751 + -webkit-backdrop-filter: blur(4px);
752 +}
753 +.ka-prev-close:hover { background: rgba(16, 18, 22, 0.75); }
754 +
755 +.ka-prev-nav {
756 + position: absolute;
757 + top: 64px;
758 + z-index: 3;
759 + width: 30px;
760 + height: 30px;
761 + border-radius: 50%;
762 + border: 0;
763 + background: rgba(16, 18, 22, 0.5);
764 + color: #fff;
765 + display: grid;
766 + place-items: center;
767 + cursor: pointer;
768 + backdrop-filter: blur(4px);
769 + -webkit-backdrop-filter: blur(4px);
770 +}
771 +.ka-prev-nav:hover { background: rgba(16, 18, 22, 0.75); }
772 +.ka-prev-prev { left: 8px; }
773 +.ka-prev-next { right: 8px; }
774 +
775 +.ka-prev-pos {
776 + position: absolute;
777 + top: 10px;
778 + left: 10px;
779 + z-index: 3;
780 + background: rgba(16, 18, 22, 0.55);
781 + color: #fff;
782 + border-radius: 999px;
783 + padding: 3px 9px;
784 + font: 600 10.5px/1.3 var(--ka-font);
785 + pointer-events: none;
786 + backdrop-filter: blur(4px);
787 + -webkit-backdrop-filter: blur(4px);
788 +}
789 +
790 +/* structure de contenu de fiche partagée (les apps remplissent) */
791 +.ka-prevcard-media { position: relative; height: 148px; background: color-mix(in srgb, var(--ka-ink) 8%, transparent); }
792 +.ka-prevcard-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
793 +.ka-prevcard-noimg { display: grid; place-items: center; height: 100%; font-size: 30px; color: color-mix(in srgb, var(--ka-ink) 40%, transparent); }
794 +.ka-prevcard-body { padding: 11px 14px 13px; }
795 +.ka-prevcard-price {
796 + display: flex;
797 + align-items: baseline;
798 + gap: 8px;
799 + font: 700 18px/1.2 var(--ka-font);
800 + color: var(--ka-ink);
801 +}
802 +.ka-prevcard-price small { font: 400 12px/1 var(--ka-font); opacity: 0.65; }
803 +.ka-prevcard-addr {
804 + margin-top: 2px;
805 + font: 500 12.5px/1.35 var(--ka-font);
806 + color: color-mix(in srgb, var(--ka-ink) 76%, transparent);
807 + white-space: nowrap;
808 + overflow: hidden;
809 + text-overflow: ellipsis;
810 +}
811 +.ka-prevcard-meta {
812 + margin-top: 6px;
813 + font: 500 11.5px/1.4 var(--ka-font);
814 + color: color-mix(in srgb, var(--ka-ink) 60%, transparent);
815 +}
816 +.ka-prevcard-actions {
817 + display: flex;
818 + align-items: center;
819 + gap: 8px;
820 + margin-top: 11px;
821 +}
822 +.ka-prevcard-cta {
823 + flex: 1;
824 + display: inline-flex;
825 + align-items: center;
826 + justify-content: center;
827 + gap: 6px;
828 + padding: 10px 12px;
829 + border: 0;
830 + border-radius: 10px;
831 + background: var(--ka-accent);
832 + color: var(--ka-on-accent);
833 + font: 700 13px/1 var(--ka-font);
834 + text-decoration: none;
835 + cursor: pointer;
836 +}
837 +.ka-prevcard-cta:hover { filter: brightness(0.94); }
838 +.ka-prevcard-fav {
839 + flex: 0 0 auto;
840 + width: 38px;
841 + height: 38px;
842 + border-radius: 10px;
843 + border: 1px solid color-mix(in srgb, var(--ka-ink) 18%, transparent);
844 + background: var(--ka-surface);
845 + color: var(--ka-ink);
846 + display: grid;
847 + place-items: center;
848 + cursor: pointer;
849 +}
850 +.ka-prevcard-fav.on { color: var(--ka-accent); border-color: var(--ka-accent); }
851 +.ka-prevcard-badge {
852 + display: inline-block;
853 + padding: 2.5px 8px;
854 + border-radius: 999px;
855 + background: var(--ka-accent);
856 + color: var(--ka-on-accent);
857 + font: 700 9.5px/1.4 var(--ka-font);
858 + text-transform: uppercase;
859 + letter-spacing: 0.05em;
860 +}
861 +
862 +/* ---- chrome v1 adouci à l'intérieur du shell ---- */
863 +.ka-shell .ka-search-area { top: 12px; }
864 +.ka-shell .ka-search-area-btn {
865 + border: 0;
866 + box-shadow: 0 4px 16px rgba(12, 14, 18, 0.2);
867 + font: 600 12.5px/1 var(--ka-font);
868 + padding: 9px 15px;
869 + min-height: 36px;
870 +}
871 +.ka-shell .ka-search-area-auto {
872 + border: 1px solid color-mix(in srgb, var(--ka-ink) 14%, transparent);
873 + background: color-mix(in srgb, var(--ka-surface) 88%, transparent);
874 + backdrop-filter: blur(8px);
875 + -webkit-backdrop-filter: blur(8px);
876 +}
877 +.ka-shell .ka-loading {
878 + border: 1px solid color-mix(in srgb, var(--ka-ink) 12%, transparent);
879 + box-shadow: 0 3px 14px rgba(12, 14, 18, 0.13);
880 + background: color-mix(in srgb, var(--ka-surface) 90%, transparent);
881 + backdrop-filter: blur(8px);
882 + -webkit-backdrop-filter: blur(8px);
883 +}
884 +.ka-shell .ka-brand {
885 + border: 0;
886 + background: transparent;
887 + box-shadow: none;
888 + padding: 2px 4px;
889 + opacity: 0.75;
890 +}
891 +.ka-shell .ka-empty {
892 + border: 1px solid color-mix(in srgb, var(--ka-ink) 12%, transparent);
893 + box-shadow: 0 6px 24px rgba(12, 14, 18, 0.14);
894 +}
895 +/* le zoom natif est remplacé par la toolbar dans le shell */
896 +.ka-shell .mapboxgl-ctrl-top-right { display: none; }
897 +/* attribution : fine, sous la toolbar */
898 +.ka-shell .mapboxgl-ctrl-bottom-right { bottom: 2px; }
899 +.ka-shell .ka-map .mapboxgl-ctrl-attrib { background: color-mix(in srgb, var(--ka-surface) 70%, transparent); }
900 +
901 +/* mobile : loading remonté au-dessus du sheet mini, marque masquée */
902 +.ka-shell-mobile .ka-loading { bottom: auto; top: 60px; }
903 +.ka-shell-mobile .ka-brand { display: none; }
904 +.ka-shell-mobile .ka-drawzone { bottom: calc(112px + env(safe-area-inset-bottom)); }
905 +.ka-shell-mobile .ka-toolbar { top: 10px; right: 10px; }
906 +.ka-shell-mobile .ka-tb-btn { width: 44px; height: 44px; }
modified src/theming/tokens.ts +4 −0
@@ -72,6 +72,10 @@ export interface KaMapTheme {
72 72 background: string;
73 73 text: string;
74 74 border: string;
75 + /** Valeur indicative affichée sous la bulle (v2) — défaut : border. */
76 + valueText?: string;
77 + /** Halo du texte de valeur (v2) — défaut : blanc translucide. */
78 + valueHalo?: string;
75 79 };
76 80 /** Optional per-app overrides merged over the Ka base palettes. */
77 81 basemapOverrides?: {
78 82