SPB Git

spb/ka-maps

Public

Ka Lens (stats réelles bbox/polygone) + documentation du framework

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 13, 2026) parent 966b83e Browse files

4 changed files +338 −0

added docs/ka-maps.md +164 −0
@@ -0,0 +1,164 @@
1 +# Ka Maps — framework cartographique de Groupe Ka
2 +
3 +**Auteur : Simon-Pierre Boucher — contact@spboucher.ai**
4 +
5 +Ka Maps est le moteur géographique partagé des applications Groupe Ka.
6 +Implémenté une fois ici, consommé par **Lou-Ka**, **Immo-Ka** et
7 +**Vrai-Prix** — chaque app n'apporte que son **thème** et son
8 +**adaptateur de données**.
9 +
10 +```
11 +Lou-Ka / Immo-Ka / Vrai-Prix (frontends)
12 + │ thème + adaptateur + configuration
13 + ▼
14 + Ka Maps ← ce dépôt (~/Desktop/ka-maps)
15 + │
16 + ▼
17 + Mapbox GL JS v3 — style Mapbox Standard (3D)
18 +```
19 +
20 +## Empaquetage (décision d'architecture)
21 +
22 +Trois dépôts séparés → paquet local `@groupe-ka/ka-maps` :
23 +
24 +| App | Consommation | Pourquoi |
25 +|---|---|---|
26 +| Lou-Ka (`lou-ka/frontend`) | `file:../../ka-maps` (lien) + `resolve.dedupe` Vite | itération à chaud |
27 +| Immo-Ka (`agent-courtage/frontend`) | idem | idem |
28 +| Vrai-Prix (Next 16) | `file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz` | Turbopack ne résout pas les liens hors racine |
29 +
30 +Après toute modification du framework :
31 +`npm run build` (les apps Vite la voient immédiatement) puis
32 +`npm run pack:tarball` + `npm install` dans vrai-prix.
33 +
34 +⚠️ Les apps Vite doivent déduper `react`, `react-dom`, `mapbox-gl`
35 +(`resolve.dedupe`) et pointer `paths` tsconfig vers **leurs**
36 +`@types/react` — sinon double React (crash hooks) et double moteur GL.
37 +
38 +## Architecture des sources
39 +
40 +```
41 +src/
42 + types/ MapProperty, KaDataAdapter, BBox, KaMapState,
43 + GeographicMarketSummary, KaLensStats, HeatmapMetric…
44 + core/ KaMap (moteur), KaEventHub (événements centralisés)
45 + layers/ propertyLayer (pastilles + grappes), registry (couches par app)
46 + services/ BoundsQueryScheduler (debounce, AbortController, cache LRU,
47 + livraison monotone — jamais une réponse périmée)
48 + styles/ kaBaseStyle (Mapbox Standard + réglages immobiliers), ka-maps.css
49 + theming/ KaMapTheme (jetons par app), palettes
50 + utils/ format (prix fr-CA), geo (bbox, haversine, GeoJSON),
51 + url (caméra partageable), lens (Ka Lens)
52 + react/ KaMapView, useKaMap, SearchAreaControl, PropertyPreview,
53 + ResultCount, LoadingIndicator, LocateControl, Tilt3DControl,
54 + KaBrandBadge
55 +```
56 +
57 +## Le contrat d'intégration (ajouter une app Groupe Ka)
58 +
59 +1. **Adaptateur** — comment vos données deviennent des `MapProperty` :
60 +
61 +```ts
62 +const monAdapter: KaDataAdapter = {
63 + id: "mon-app-items",
64 + appSource: "mon-app",
65 + async fetchInBounds({ bbox, zoom, filters, signal }) {
66 + const res = await fetch(`/api/…?bbox=${bboxToString(bbox)}`, { signal });
67 + return { properties: (await res.json()).map(toMapProperty), totalCount };
68 + },
69 +};
70 +```
71 +
72 +2. **Thème** — `KaMapTheme` : accent, familles de pastilles
73 + (`sale`/`rent`/`valuation`/`highlight` × normal/sélection), grappe.
74 +3. **Montage** :
75 +
76 +```tsx
77 +<KaMapView theme={theme} adapter={adapter} mapboxToken={TOKEN}
78 + filters={filters} pitch={50} cluster={{ maxZoom: 15 }}>
79 + <KaBrandBadge /> <Tilt3DControl /> <SearchAreaControl />
80 + <LoadingIndicator /> <ResultCount />
81 + <PropertyPreview render={(p) => <MaCarte p={p} />} />
82 +</KaMapView>
83 +```
84 +
85 +4. **Jetons CSS** — sur `.ka-map` : `--ka-accent`, `--ka-surface`,
86 + `--ka-ink`, `--ka-line`, `--ka-radius`, `--ka-shadow`, `--ka-font`.
87 +
88 +## Rendu des propriétés
89 +
90 +- **Aucun marqueur DOM** : source GeoJSON + couches symbole/cercle GPU —
91 + tenue à 100 000+ points.
92 +- **Pastilles de prix** : images canvas 9-slice étirables par famille ×
93 + état (le 9-slice n'est pas supporté sur les icônes SDF). `icon-image`
94 + est une propriété *layout* (feature-state interdit) : la sélection est
95 + réinjectée par `setLayoutProperty`.
96 +- **Grappes** : clustering natif, compte + **valeur moyenne indicative**
97 + (`≈`) via `clusterProperties` (somme/nombre) — `valueClamp` borne la
98 + contribution de chaque point (un prix aberrant ne pollue pas la bulle).
99 + La médiane exacte n'est pas réductible par supercluster ; elle est
100 + disponible côté Ka Lens.
101 +- **États** : `hovered`/`selected`/`dimmed` par feature-state (peinture).
102 +
103 +## Pièges connus du moteur (payés une fois, documentés ici)
104 +
105 +- **Polices** : les couches symbole doivent utiliser des polices du
106 + serveur de glyphes Mapbox (`DIN Pro …`, `Arial Unicode MS …`). Une
107 + police inconnue fait échouer le parsing des tuiles → **source vide,
108 + silencieuse**.
109 +- **Style à imports (Standard)** : installer les couches sur `load`,
110 + config basemap ensuite ; auto-réparation (vérification + reconstruction
111 + avec id de source rotatif) intégrée à `KaMap`.
112 +- **`clusterMaxZoom` entier** obligatoire (sinon `Invalid array length`
113 + dans le worker) — arrondi par `KaMap`.
114 +- **`promoteId: undefined`** rejeté par la validation Mapbox.
115 +- `getClusterExpansionZoom` est à **callback** (pas une promesse).
116 +
117 +## Fond de carte
118 +
119 +`KA_STYLE_URL` = `mapbox://styles/mapbox/standard` +
120 +`applyKaBasemapConfig` : thème `faded` (les prix dominent), POI/transit
121 +masqués, `lightPreset` day/dusk = Ka Light/Ka Dark (`setMode`, sans
122 +rechargement de style). 3D native (bâtiments, repères) ; inclinaison par
123 +défaut 50°, contrôle `Tilt3DControl` (2D/3D). Jeton public `pk.…` fourni
124 +par l'app (`mapboxToken`) — jamais de secret serveur dans le navigateur.
125 +Attribution Mapbox/OSM repliée en ⓘ (jamais retirée), logo conservé.
126 +
127 +## Synchronisation carte ↔ résultats
128 +
129 +`BoundsQueryScheduler` : `moveend` → debounce → adaptateur → rendu.
130 +Mode `manual` : le viewport divergent affiche « Rechercher dans cette
131 +zone » (tolérance 15 %) ; mode `auto` : requête à chaque déplacement
132 +posé. Annulation `AbortController`, cache LRU (clé bbox+zoom+filtres),
133 +livraison strictement monotone.
134 +
135 +## Couches, agrégats, Ka Lens (fondations)
136 +
137 +- `layers/registry.ts` : vocabulaire complet (PROPERTY/MARKET/LAND/
138 + LIFESTYLE/INVESTMENT) ; `buildLayerRegistry` marque ce que chaque app
139 + supporte — rien d'autre n'est exposé en production.
140 +- `GeographicMarketSummary` : contrat des agrégats par géographie
141 + (province → quartier) pour Ka Market Pulse — les API restent à
142 + implémenter par app (aucune valeur fictive).
143 +- `utils/lens.ts` : `computeLensStats` (médianes, mix de types, parts de
144 + baisses/90 j+) sur les propriétés réellement chargées ;
145 + `propertiesInBBox` / `propertiesInPolygon` (ray casting) pour la
146 + sélection Ka Lens ; `KaMap.setDimmedExcept` atténue le reste.
147 +- `HeatmapMetric` : contrat de la future infra heatmap.
148 +
149 +## Évolution serveur prévue (architecturé, non déployé)
150 +
151 +```
152 +SQLite (bbox + index (lat,lng)) ← aujourd'hui, les 3 apps
153 +PostgreSQL + PostGIS (GIST, ST_Intersects) ← quand la volumétrie l'exige
154 +Martin → tuiles vectorielles MVT → CDN ← /api/map/…/{z}/{x}/{y}.pbf
155 +```
156 +
157 +Le client est prêt : remplacer la source GeoJSON par une source
158 +`vector` + `source-layer` ne touche ni les couches ni les apps.
159 +
160 +## Commandes
161 +
162 +`npm run build` · `npm run typecheck` · `npm test` (30 tests : formats
163 +fr-CA, bbox/URL, scheduler — debounce/annulation/cache/monotonie —, Ka
164 +Lens) · `npm run pack:tarball`.
modified src/index.ts +5 −0
@@ -34,6 +34,11 @@ export {
34 34 propertiesToGeoJSON,
35 35 roundBBox,
36 36 } from "./utils/geo.js";
37 +export {
38 + computeLensStats,
39 + propertiesInBBox,
40 + propertiesInPolygon,
41 +} from "./utils/lens.js";
37 42 export {
38 43 cameraFromParams,
39 44 cameraToParams,
added src/utils/lens.ts +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Ka Lens — statistiques d'une sélection géographique. Calcule sur les
7 + * MapProperty réellement chargées (viewport ou polygone) ; seules les
8 + * métriques dont les données existent sont renseignées — jamais de
9 + * valeurs inventées. Les agrégats temporels (7 j/30 j/1 an) relèvent des
10 + * API d'agrégation des apps (GeographicMarketSummary) quand elles
11 + * existeront.
12 + */
13 +
14 +import type { BBox, KaLensStats, MapProperty } from "../types/index.js";
15 +import { bboxContainsPoint } from "./geo.js";
16 +import { median } from "./format.js";
17 +
18 +/** Statistiques Ka Lens sur un ensemble de propriétés. */
19 +export function computeLensStats(properties: MapProperty[]): KaLensStats {
20 + const count = properties.length;
21 + const prices = properties
22 + .map((p) => p.price)
23 + .filter((v): v is number => Number.isFinite(v as number));
24 + const estimates = properties
25 + .map((p) => p.estimatedValue)
26 + .filter((v): v is number => Number.isFinite(v as number));
27 + const dom = properties
28 + .map((p) => p.daysOnMarket)
29 + .filter((v): v is number => Number.isFinite(v as number));
30 + const cuts = properties.filter(
31 + (p) => typeof p.priceChange === "number" && p.priceChange < 0,
32 + ).length;
33 + const stale = properties.filter(
34 + (p) => typeof p.daysOnMarket === "number" && p.daysOnMarket > 90,
35 + ).length;
36 +
37 + const typeCounts = new Map<string, number>();
38 + for (const p of properties) {
39 + if (!p.propertyType) continue;
40 + typeCounts.set(p.propertyType, (typeCounts.get(p.propertyType) ?? 0) + 1);
41 + }
42 + const typed = [...typeCounts.values()].reduce((a, b) => a + b, 0);
43 +
44 + const stats: KaLensStats = { count };
45 + const medPrice = median(prices);
46 + if (medPrice !== undefined) stats.medianPrice = medPrice;
47 + const medEst = median(estimates);
48 + if (medEst !== undefined) stats.medianEstimatedValue = medEst;
49 + const medDom = median(dom);
50 + if (medDom !== undefined) stats.medianDaysOnMarket = medDom;
51 + if (dom.length > 0) stats.stale90dShare = stale / dom.length;
52 + if (properties.some((p) => typeof p.priceChange === "number")) {
53 + stats.priceCutShare = count > 0 ? cuts / count : 0;
54 + }
55 + if (typed > 0) {
56 + stats.typeMix = Object.fromEntries(
57 + [...typeCounts.entries()]
58 + .sort((a, b) => b[1] - a[1])
59 + .map(([k, v]) => [k, v / typed]),
60 + );
61 + }
62 + return stats;
63 +}
64 +
65 +/** Sous-ensemble des propriétés dans un rectangle (sélection visible). */
66 +export function propertiesInBBox(
67 + properties: MapProperty[],
68 + bbox: BBox,
69 +): MapProperty[] {
70 + return properties.filter((p) =>
71 + bboxContainsPoint(bbox, p.latitude, p.longitude),
72 + );
73 +}
74 +
75 +/** Sous-ensemble dans un polygone GeoJSON (Ka Lens dessiné) — ray casting. */
76 +export function propertiesInPolygon(
77 + properties: MapProperty[],
78 + polygon: GeoJSON.Polygon,
79 +): MapProperty[] {
80 + const ring = polygon.coordinates[0];
81 + if (!ring || ring.length < 4) return [];
82 + return properties.filter((p) => pointInRing(ring, p.longitude, p.latitude));
83 +}
84 +
85 +function pointInRing(ring: GeoJSON.Position[], x: number, y: number): boolean {
86 + let inside = false;
87 + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
88 + const xi = ring[i]![0]!;
89 + const yi = ring[i]![1]!;
90 + const xj = ring[j]![0]!;
91 + const yj = ring[j]![1]!;
92 + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
93 + inside = !inside;
94 + }
95 + }
96 + return inside;
97 +}
added tests/lens.test.ts +72 −0
@@ -0,0 +1,72 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + */
6 +
7 +import { describe, expect, it } from "vitest";
8 +import {
9 + computeLensStats,
10 + propertiesInBBox,
11 + propertiesInPolygon,
12 +} from "../src/utils/lens.js";
13 +import type { MapProperty } from "../src/types/index.js";
14 +
15 +function prop(over: Partial<MapProperty>): MapProperty {
16 + return {
17 + id: over.id ?? Math.random().toString(36),
18 + appSource: "lou-ka",
19 + latitude: 46.8,
20 + longitude: -71.2,
21 + kind: "listing",
22 + ...over,
23 + };
24 +}
25 +
26 +describe("computeLensStats", () => {
27 + it("computes medians and type mix from real fields only", () => {
28 + const stats = computeLensStats([
29 + prop({ price: 300_000, propertyType: "Maison", daysOnMarket: 10 }),
30 + prop({ price: 500_000, propertyType: "Maison", daysOnMarket: 100 }),
31 + prop({ price: 700_000, propertyType: "Condo", priceChange: -0.05 }),
32 + ]);
33 + expect(stats.count).toBe(3);
34 + expect(stats.medianPrice).toBe(500_000);
35 + expect(stats.medianDaysOnMarket).toBe(55);
36 + expect(stats.stale90dShare).toBe(0.5);
37 + expect(stats.priceCutShare).toBeCloseTo(1 / 3);
38 + expect(stats.typeMix?.["Maison"]).toBeCloseTo(2 / 3);
39 + // pas de valeurs estimées dans les données → pas de médiane inventée
40 + expect(stats.medianEstimatedValue).toBeUndefined();
41 + });
42 +
43 + it("returns bare count on empty/opaque data", () => {
44 + const stats = computeLensStats([]);
45 + expect(stats).toEqual({ count: 0 });
46 + });
47 +});
48 +
49 +describe("geographic selection", () => {
50 + const items = [
51 + prop({ id: "in", latitude: 46.8, longitude: -71.2 }),
52 + prop({ id: "out", latitude: 45.5, longitude: -73.6 }),
53 + ];
54 +
55 + it("filters by bbox", () => {
56 + const sel = propertiesInBBox(items, {
57 + west: -71.4,
58 + south: 46.7,
59 + east: -71.0,
60 + north: 46.9,
61 + });
62 + expect(sel.map((p) => p.id)).toEqual(["in"]);
63 + });
64 +
65 + it("filters by polygon (ray casting)", () => {
66 + const sel = propertiesInPolygon(items, {
67 + type: "Polygon",
68 + coordinates: [[[-71.4, 46.7], [-71.0, 46.7], [-71.0, 46.9], [-71.4, 46.9], [-71.4, 46.7]]],
69 + });
70 + expect(sel.map((p) => p.id)).toEqual(["in"]);
71 + });
72 +});
73
Menu

Theme