SPB Git

spb/ka-maps

Public

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 (Aug 26, 2026) parent 5cd05c0 Browse files

14 changed files +1,626 −42

modified package-lock.json +2 −2
@@ -1,12 +1,12 @@
1 { 1 {
2 "name": "@groupe-ka/ka-maps", 2 "name": "@groupe-ka/ka-maps",
3− "version": "0.1.0", 3+ "version": "0.2.0",
4 "lockfileVersion": 3, 4 "lockfileVersion": 3,
5 "requires": true, 5 "requires": true,
6 "packages": { 6 "packages": {
7 "": { 7 "": {
8 "name": "@groupe-ka/ka-maps", 8 "name": "@groupe-ka/ka-maps",
9− "version": "0.1.0", 9+ "version": "0.2.0",
10 "license": "UNLICENSED", 10 "license": "UNLICENSED",
11 "dependencies": { 11 "dependencies": {
12 "@types/geojson": "^7946.0.16", 12 "@types/geojson": "^7946.0.16",
modified package.json +1 −1
@@ -1,6 +1,6 @@
1 { 1 {
2 "name": "@groupe-ka/ka-maps", 2 "name": "@groupe-ka/ka-maps",
3− "version": "0.1.0", 3+ "version": "0.2.0",
4 "description": "Ka Maps — real-estate geographic intelligence framework by Groupe Ka. Powers Lou-Ka, Immo-Ka and Vrai-Prix maps.", 4 "description": "Ka Maps — real-estate geographic intelligence framework by Groupe Ka. Powers Lou-Ka, Immo-Ka and Vrai-Prix maps.",
5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>", 5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 "license": "UNLICENSED", 6 "license": "UNLICENSED",
modified src/core/KaMap.ts +50 −8
@@ -43,6 +43,7 @@ import {
43 expandBBox, 43 expandBBox,
44 hashId, 44 hashId,
45 isValidCoordinate, 45 isValidCoordinate,
46+ pointInPolygon,
46 propertiesToGeoJSON, 47 propertiesToGeoJSON,
47 } from "../utils/geo.js"; 48 } from "../utils/geo.js";
48 import { 49 import {
@@ -74,6 +75,9 @@ export interface KaMapOptions {
74 query?: BoundsQueryOptions; 75 query?: BoundsQueryOptions;
75 /** Cooperative gestures on embedded maps (two-finger pan hint). */ 76 /** Cooperative gestures on embedded maps (two-finger pan hint). */
76 cooperativeGestures?: boolean; 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 /** Clustering tuning. Lou-Ka style per-building geocoding wants 81 /** Clustering tuning. Lou-Ka style per-building geocoding wants
78 * maxZoom 15 so stacked units stay grouped as long as possible. 82 * maxZoom 15 so stacked units stay grouped as long as possible.
79 * `valueClamp` bounds each item's contribution to the bubble mean. */ 83 * `valueClamp` bounds each item's contribution to the bubble mean. */
@@ -107,6 +111,12 @@ export class KaMap {
107 private data: GeoJSON.FeatureCollection = EMPTY_FC; 111 private data: GeoJSON.FeatureCollection = EMPTY_FC;
108 private byId = new Map<string, { hash: number; property: MapProperty }>(); 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 private selectedId: string | null = null; 120 private selectedId: string | null = null;
111 private hoveredId: string | null = null; 121 private hoveredId: string | null = null;
112 private searchedBBox: BBox | null = null; 122 private searchedBBox: BBox | null = null;
@@ -175,10 +185,12 @@ export class KaMap {
175 new mapboxgl.AttributionControl({ compact: true }), 185 new mapboxgl.AttributionControl({ compact: true }),
176 "bottom-right", 186 "bottom-right",
177 ); 187 );
178− this.map.addControl( 188+ if (options.navControl !== false) {
179− new mapboxgl.NavigationControl({ showCompass: false }), 189+ this.map.addControl(
180− "top-right", 190+ new mapboxgl.NavigationControl({ showCompass: false }),
181− ); 191+ "top-right",
192+ );
193+ }
182 // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise. 194 // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise.
183 this.map.touchZoomRotate.disableRotation(); 195 this.map.touchZoomRotate.disableRotation();
184 this.map.dragRotate.disable(); 196 this.map.dragRotate.disable();
@@ -187,11 +199,8 @@ export class KaMap {
187 if (options.adapter) { 199 if (options.adapter) {
188 this.scheduler = new BoundsQueryScheduler(options.adapter, options.query); 200 this.scheduler = new BoundsQueryScheduler(options.adapter, options.query);
189 this.scheduler.onResult((result) => { 201 this.scheduler.onResult((result) => {
202+ this.lastTotalCount = result.totalCount;
190 this.setProperties(result.properties); 203 this.setProperties(result.properties);
191− this.events.emit("data", {
192− count: result.properties.length,
193− totalCount: result.totalCount,
194− });
195 }); 204 });
196 this.scheduler.onError((error) => 205 this.scheduler.onError((error) =>
197 this.events.emit("error", { scope: "query", error }), 206 this.events.emit("error", { scope: "query", error }),
@@ -448,6 +457,19 @@ export class KaMap {
448 457
449 /** Replace the rendered property set (adapter results or app-pushed). */ 458 /** Replace the rendered property set (adapter results or app-pushed). */
450 setProperties(properties: MapProperty[]): void { 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 this.byId.clear(); 473 this.byId.clear();
452 for (const p of properties) { 474 for (const p of properties) {
453 if (!isValidCoordinate(p.latitude, p.longitude)) continue; 475 if (!isValidCoordinate(p.latitude, p.longitude)) continue;
@@ -460,12 +482,32 @@ export class KaMap {
460 if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app"); 482 if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app");
461 this.applyFeatureStates(); 483 this.applyFeatureStates();
462 if (this.overlayInstalled) this.scheduleOverlayVerify(); 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 getProperty(id: string): MapProperty | undefined { 502 getProperty(id: string): MapProperty | undefined {
466 return this.byId.get(id)?.property; 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 /** Current filters forwarded to the adapter on every query. */ 511 /** Current filters forwarded to the adapter on every query. */
470 setFilters(filters: Record<string, unknown> | undefined): void { 512 setFilters(filters: Record<string, unknown> | undefined): void {
471 this.filters = filters; 513 this.filters = filters;
modified src/layers/propertyLayer.ts +59 −31
@@ -20,6 +20,7 @@ import { markerTokens } from "../theming/tokens.js";
20 export const PROPERTY_SOURCE_ID = "ka-properties"; 20 export const PROPERTY_SOURCE_ID = "ka-properties";
21 21
22 export const LAYER_IDS = { 22 export const LAYER_IDS = {
23+ clusterRing: "ka-cluster-ring",
23 clusters: "ka-clusters", 24 clusters: "ka-clusters",
24 clusterCount: "ka-cluster-count", 25 clusterCount: "ka-cluster-count",
25 clusterValue: "ka-cluster-value", 26 clusterValue: "ka-cluster-value",
@@ -72,20 +73,22 @@ export function pillImageId(family: PillFamily, selected = false): string {
72 export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void { 73 export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {
73 const DPR = 2; 74 const DPR = 2;
74 const W = 64; 75 const W = 64;
75− const H = 30; 76+ const H = 26;
76− const R = 13; 77+ const R = 12.5;
77− const TAIL = 6; 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 const draw = (background: string, border: string): ImageData => { 82 const draw = (background: string, border: string): ImageData => {
80 const canvas = document.createElement("canvas"); 83 const canvas = document.createElement("canvas");
81 canvas.width = W * DPR; 84 canvas.width = W * DPR;
82− canvas.height = (H + TAIL + 3) * DPR; 85+ canvas.height = (H + TAIL + 4) * DPR;
83 const ctx = canvas.getContext("2d"); 86 const ctx = canvas.getContext("2d");
84 if (!ctx) throw new Error("Canvas 2D indisponible"); 87 if (!ctx) throw new Error("Canvas 2D indisponible");
85 ctx.scale(DPR, DPR); 88 ctx.scale(DPR, DPR);
86− ctx.shadowColor = "rgba(10, 12, 10, 0.28)"; 89+ ctx.shadowColor = "rgba(12, 14, 18, 0.20)";
87− ctx.shadowBlur = 4; 90+ ctx.shadowBlur = 7;
88− ctx.shadowOffsetY = 1.5; 91+ ctx.shadowOffsetY = 2;
89 // corps de la pastille 92 // corps de la pastille
90 ctx.beginPath(); 93 ctx.beginPath();
91 ctx.roundRect(1, 1, W - 2, H - 2, R); 94 ctx.roundRect(1, 1, W - 2, H - 2, R);
@@ -94,16 +97,16 @@ export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {
94 // pointe vers la coordonnée 97 // pointe vers la coordonnée
95 ctx.shadowColor = "transparent"; 98 ctx.shadowColor = "transparent";
96 ctx.beginPath(); 99 ctx.beginPath();
97− ctx.moveTo(W / 2 - 5.5, H - 1.5); 100+ ctx.moveTo(W / 2 - 4.5, H - 1.5);
98 ctx.lineTo(W / 2, H + TAIL - 1); 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 ctx.closePath(); 103 ctx.closePath();
101 ctx.fillStyle = background; 104 ctx.fillStyle = background;
102 ctx.fill(); 105 ctx.fill();
103 ctx.beginPath(); 106 ctx.beginPath();
104 ctx.roundRect(1, 1, W - 2, H - 2, R); 107 ctx.roundRect(1, 1, W - 2, H - 2, R);
105 ctx.strokeStyle = border; 108 ctx.strokeStyle = border;
106− ctx.lineWidth = 1.4; 109+ ctx.lineWidth = 1.1;
107 ctx.stroke(); 110 ctx.stroke();
108 return ctx.getImageData(0, 0, canvas.width, canvas.height); 111 return ctx.getImageData(0, 0, canvas.width, canvas.height);
109 }; 112 };
@@ -236,6 +239,34 @@ export function buildPropertyLayers(
236 1, 239 1,
237 ] as ExpressionSpecification; 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 const clusterCircle: CircleLayerSpecification = { 270 const clusterCircle: CircleLayerSpecification = {
240 id: LAYER_IDS.clusters, 271 id: LAYER_IDS.clusters,
241 slot: "top", 272 slot: "top",
@@ -245,19 +276,9 @@ export function buildPropertyLayers(
245 paint: { 276 paint: {
246 "circle-color": theme.cluster.background, 277 "circle-color": theme.cluster.background,
247 "circle-stroke-color": theme.cluster.border, 278 "circle-stroke-color": theme.cluster.border,
248− "circle-stroke-width": 2.5, 279+ "circle-stroke-width": 1.5,
249− "circle-radius": [ 280+ "circle-radius": clusterRadius,
250− "step", 281+ "circle-opacity": 0.96,
251− ["get", "point_count"],
252− 16,
253− 10,
254− 20,
255− 50,
256− 25,
257− 200,
258− 31,
259− ],
260− "circle-opacity": 0.95,
261 }, 282 },
262 }; 283 };
263 284
@@ -270,20 +291,21 @@ export function buildPropertyLayers(
270 layout: { 291 layout: {
271 "text-field": ["get", "point_count_abbreviated"], 292 "text-field": ["get", "point_count_abbreviated"],
272 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"], 293 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
273− "text-size": 13, 294+ "text-size": ["step", ["get", "point_count"], 11.5, 50, 12.5],
274− "text-offset": [0, -0.32],
275 "text-allow-overlap": true, 295 "text-allow-overlap": true,
276 }, 296 },
277 paint: { "text-color": theme.cluster.text }, 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 const clusterValue: SymbolLayerSpecification = { 302 const clusterValue: SymbolLayerSpecification = {
282 id: LAYER_IDS.clusterValue, 303 id: LAYER_IDS.clusterValue,
283 slot: "top", 304 slot: "top",
284 type: "symbol", 305 type: "symbol",
285 source: sourceId, 306 source: sourceId,
286 filter: ["all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0]], 307 filter: ["all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0]],
308+ minzoom: 9,
287 layout: { 309 layout: {
288 "text-field": [ 310 "text-field": [
289 "concat", 311 "concat",
@@ -296,10 +318,16 @@ export function buildPropertyLayers(
296 ], 318 ],
297 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], 319 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],
298 "text-size": 10, 320 "text-size": 10,
299− "text-offset": [0, 0.75], 321+ "text-anchor": "top",
322+ "text-offset": [0, 1.55],
300 "text-allow-overlap": true, 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 const pointDot: CircleLayerSpecification = { 333 const pointDot: CircleLayerSpecification = {
@@ -336,13 +364,13 @@ export function buildPropertyLayers(
336 layout: { 364 layout: {
337 "icon-image": pillIconExpression(null), 365 "icon-image": pillIconExpression(null),
338 "icon-text-fit": "both", 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 "icon-anchor": "bottom", 368 "icon-anchor": "bottom",
341 "icon-allow-overlap": false, 369 "icon-allow-overlap": false,
342 "icon-optional": false, 370 "icon-optional": false,
343 "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification), 371 "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification),
344 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"], 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 "text-anchor": "bottom", 374 "text-anchor": "bottom",
347 "text-offset": [0, -0.9], 375 "text-offset": [0, -0.9],
348 "text-allow-overlap": false, 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 searchMode: p.searchMode, 85 searchMode: p.searchMode,
86 query: p.query, 86 query: p.query,
87 cooperativeGestures: p.cooperativeGestures, 87 cooperativeGestures: p.cooperativeGestures,
88+ navControl: p.navControl,
88 cluster: p.cluster, 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 Tilt3DControl, 18 Tilt3DControl,
19 } from "./controls.js"; 19 } from "./controls.js";
20 export { PropertyPreview, type PropertyPreviewProps } from "./PropertyPreview.js"; 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 font-weight: 500; 379 font-weight: 500;
380 opacity: 0.75; 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
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme