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 propertiesToGeoJSON, 34 propertiesToGeoJSON,
35 roundBBox, 35 roundBBox,
36 } from "./utils/geo.js"; 36 } from "./utils/geo.js";
37+export {
38+ computeLensStats,
39+ propertiesInBBox,
40+ propertiesInPolygon,
41+} from "./utils/lens.js";
37 export { 42 export {
38 cameraFromParams, 43 cameraFromParams,
39 cameraToParams, 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