SPB Git forge

spb/ka-maps

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

Ka Maps 0.1 — moteur cartographique Groupe Ka + synchro liste↔carte

Framework partagé (Lou-Ka, Immo-Ka, Vrai-Prix) : moteur KaMap (Mapbox GL,
style Standard auto-réparant), pastilles de prix GPU, clustering avec
fourchette min/max, sélection/survol bidirectionnels par feature-state,
fitBounds avec distinction geste utilisateur / mouvement programmé (byUser),
outil polygone intégré (DrawControl), état « vu », liaisons React,
pipeline viewport→données (debounce, abort, monotone), thèmes par app.
Docs : docs/SEARCH-SYNC.md (le modèle de synchronisation liste ↔ carte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026)

32 changed files +6,159 −0

added .gitignore +4 −0
@@ -0,0 +1,4 @@
1 +node_modules/
2 +dist/
3 +*.tgz
4 +.DS_Store
added docs/SEARCH-SYNC.md +96 −0
@@ -0,0 +1,96 @@
1 +# Ka Maps — Synchronisation liste ↔ carte (le modèle Groupe-KA)
2 +
3 +Référence du module « recherche synchronisée » introduit avec Lou-Ka
4 +(`frontend/src/search/MapSearch.tsx`), conçu pour être décliné tel quel sur
5 +**immo-ka**, **resto-ka**, **sorti-ka** et servir de contrat à l'app iOS KA.
6 +
7 +## Le principe : une seule vérité partagée
8 +
9 +La liste et la carte sont **deux vues du même état de recherche** :
10 +
11 +```
12 +État = { filtres, zone (bbox visible OU polygone dessiné), tri, page }
13 +```
14 +
15 +Chaque changement d'état déclenche **UNE requête** vers l'endpoint unifié
16 +(`/api/search` chez Lou-Ka) qui renvoie **dans la même réponse** :
17 +
18 +| Champ | Rôle |
19 +|----------------|------|
20 +| `total` | compteur partagé — affiché en tête de liste, égal PAR CONSTRUCTION au nombre de points carte |
21 +| `listings` | la page de liste demandée (objets complets) |
22 +| `points` | TOUS les points carte au format compact `[uid, lng, lat, prix, verdict]`, **triés comme la liste** |
23 +| `unpositioned` | annonces filtrées sans coordonnées (affichées honnêtement, jamais silencieusement perdues) |
24 +
25 +Corollaires structurels (pas des efforts de synchronisation, des invariants) :
26 +
27 +- compteur liste = nombre de marqueurs, dans 100 % des cas ;
28 +- `index d'un uid dans points` ÷ `page_size` = sa page de liste → un clic sur
29 + n'importe quel marqueur peut TOUJOURS faire défiler la liste vers l'annonce,
30 + même si elle est sur une autre page ;
31 +- aucune requête périmée ne peut écraser une récente (numéro de séquence +
32 + `AbortController` ; le serveur n'est jamais la source du tri d'arrivée).
33 +
34 +## Répartition des rôles
35 +
36 +**Ka Maps (framework)** fournit :
37 +
38 +- `KaMap.fitBounds(bbox)` / `fitToProperties()` — recadrages animés marqués
39 + *programmés* ;
40 +- `moveend { byUser }` — distinction geste utilisateur / mouvement du code,
41 + c'est la clé du « respect de l'intention » : seuls les gestes verrouillent
42 + la vue (`userLocked`) et déclenchent la recherche par zone ;
43 +- sélection & survol bidirectionnels par feature-state GPU
44 + (`select(uid, "map"|"app")`, `setHovered`, événements `select`/`hover`) ;
45 +- outil polygone intégré (`startDraw`, `clearDrawnPolygon`,
46 + `setDrawnPolygon`, événement `draw`) + `<DrawControl/>` ;
47 +- fourchette de prix des clusters au survol (`clusterHover`, accumulateurs
48 + `valueMin`/`valueMax`) ;
49 +- état « vu » (`setSeenIds`) — pastilles atténuées des annonces consultées ;
50 +- utilitaires : `bboxOfProperties`, `pointInPolygon`, `bboxToString`,
51 + `cameraToParams`/`cameraFromParams`.
52 +
53 +**L'app** possède : l'état de recherche, la requête unifiée, l'URL, la liste,
54 +le carrousel mobile et le langage visuel des cartes/mini-fiches.
55 +
56 +## La machine d'états côté app (copier ce comportement)
57 +
58 +1. **Arrivée sans caméra dans l'URL** : requête sans zone → `fitBounds`
59 + animé sur les résultats (`byUser: false`, ne verrouille pas la vue).
60 +2. **Arrivée avec caméra (lien partagé)** : la zone visible restaurée devient
61 + la contrainte spatiale de la première requête ; vue considérée verrouillée.
62 +3. **Geste utilisateur** (`byUser: true`) : verrouille la vue ; si
63 + « Rechercher quand je déplace la carte » (défaut : coché) → nouvelle
64 + requête avec la bbox (debounce ~250 ms après la fin du geste) ; sinon
65 + marquer la zone divergée et montrer « Rechercher dans cette zone ».
66 +4. **Filtre modifié** : page 1 ; si la vue n'est PAS verrouillée → requête
67 + sans zone + fitBounds ; si verrouillée → requête dans la zone courante et
68 + bouton discret « Recadrer sur les résultats ».
69 +5. **Polygone dessiné** : remplace la bbox (il EST la zone), devient une puce
70 + retirable, encodé dans l'URL (`zone=lng,lat;…`), fitBounds sur son contenu.
71 +6. **Tri/page** : même requête ; un changement de page passe
72 + `include=liste` (les points, identiques, ne sont pas retéléchargés).
73 +7. **URL** : caméra (`lat/lng/zoom`) + `tri` + `page` + `zone` + `move=0`
74 + via `replaceState` — partager le lien reproduit la recherche à l'identique.
75 +
76 +## Miroirs (latence < 100 ms)
77 +
78 +- survol carte d'annonce → `setHovered(uid, "app")` (feature-state, aucun re-rendu carte) ;
79 +- survol marqueur → événement `hover` → classe CSS sur la carte d'annonce +
80 + indicateur « ▲/▼ annonce hors écran » si elle n'est pas visible ;
81 +- clic marqueur → `select` → page ajustée au besoin → défilement animé + pulsation ;
82 +- clic annonce → `select(uid, "app")` → recentrage doux (uniquement si hors
83 + champ) + mini-fiche ; Cmd/Ctrl-clic ou 2ᵉ clic → navigation vers la fiche.
84 +
85 +## Mobile
86 +
87 +Bascule Liste ↔ Carte sans perte (état dans l'URL). En vue carte : carrousel
88 +horizontal `scroll-snap` en bas, alimenté par la MÊME page de liste ;
89 +balayage → sélection du marqueur (`select`, origin `"app"`), marqueur tapé →
90 +défilement du carrousel. Un seul état, deux projections.
91 +
92 +## Anti-régression
93 +
94 +- E2E : `lou-ka/scripts/test-sync.mjs` (25 vérifications, critères 1-9).
95 +- Unitaires framework : `ka-maps/tests/drawGeo.test.ts` (+ suites existantes).
96 +- API : `lou-ka/tests/test_search.py` (règle d'or, tris, polygone, bornes).
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`.
added package-lock.json +1709 −0
@@ -0,0 +1,1709 @@
1 +{
2 + "name": "@groupe-ka/ka-maps",
3 + "version": "0.1.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "@groupe-ka/ka-maps",
9 + "version": "0.1.0",
10 + "license": "UNLICENSED",
11 + "dependencies": {
12 + "@types/geojson": "^7946.0.16",
13 + "mapbox-gl": "^3.28.1"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^19.0.0",
17 + "typescript": "^5.8.0",
18 + "vitest": "^3.0.0"
19 + },
20 + "peerDependencies": {
21 + "react": ">=18"
22 + },
23 + "peerDependenciesMeta": {
24 + "react": {
25 + "optional": true
26 + }
27 + }
28 + },
29 + "node_modules/@esbuild/aix-ppc64": {
30 + "version": "0.28.2",
31 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
32 + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
33 + "cpu": [
34 + "ppc64"
35 + ],
36 + "dev": true,
37 + "license": "MIT",
38 + "optional": true,
39 + "os": [
40 + "aix"
41 + ],
42 + "engines": {
43 + "node": ">=18"
44 + }
45 + },
46 + "node_modules/@esbuild/android-arm": {
47 + "version": "0.28.2",
48 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
49 + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
50 + "cpu": [
51 + "arm"
52 + ],
53 + "dev": true,
54 + "license": "MIT",
55 + "optional": true,
56 + "os": [
57 + "android"
58 + ],
59 + "engines": {
60 + "node": ">=18"
61 + }
62 + },
63 + "node_modules/@esbuild/android-arm64": {
64 + "version": "0.28.2",
65 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
66 + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
67 + "cpu": [
68 + "arm64"
69 + ],
70 + "dev": true,
71 + "license": "MIT",
72 + "optional": true,
73 + "os": [
74 + "android"
75 + ],
76 + "engines": {
77 + "node": ">=18"
78 + }
79 + },
80 + "node_modules/@esbuild/android-x64": {
81 + "version": "0.28.2",
82 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
83 + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
84 + "cpu": [
85 + "x64"
86 + ],
87 + "dev": true,
88 + "license": "MIT",
89 + "optional": true,
90 + "os": [
91 + "android"
92 + ],
93 + "engines": {
94 + "node": ">=18"
95 + }
96 + },
97 + "node_modules/@esbuild/darwin-arm64": {
98 + "version": "0.28.2",
99 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
100 + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
101 + "cpu": [
102 + "arm64"
103 + ],
104 + "dev": true,
105 + "license": "MIT",
106 + "optional": true,
107 + "os": [
108 + "darwin"
109 + ],
110 + "engines": {
111 + "node": ">=18"
112 + }
113 + },
114 + "node_modules/@esbuild/darwin-x64": {
115 + "version": "0.28.2",
116 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
117 + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
118 + "cpu": [
119 + "x64"
120 + ],
121 + "dev": true,
122 + "license": "MIT",
123 + "optional": true,
124 + "os": [
125 + "darwin"
126 + ],
127 + "engines": {
128 + "node": ">=18"
129 + }
130 + },
131 + "node_modules/@esbuild/freebsd-arm64": {
132 + "version": "0.28.2",
133 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
134 + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
135 + "cpu": [
136 + "arm64"
137 + ],
138 + "dev": true,
139 + "license": "MIT",
140 + "optional": true,
141 + "os": [
142 + "freebsd"
143 + ],
144 + "engines": {
145 + "node": ">=18"
146 + }
147 + },
148 + "node_modules/@esbuild/freebsd-x64": {
149 + "version": "0.28.2",
150 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
151 + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
152 + "cpu": [
153 + "x64"
154 + ],
155 + "dev": true,
156 + "license": "MIT",
157 + "optional": true,
158 + "os": [
159 + "freebsd"
160 + ],
161 + "engines": {
162 + "node": ">=18"
163 + }
164 + },
165 + "node_modules/@esbuild/linux-arm": {
166 + "version": "0.28.2",
167 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
168 + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
169 + "cpu": [
170 + "arm"
171 + ],
172 + "dev": true,
173 + "license": "MIT",
174 + "optional": true,
175 + "os": [
176 + "linux"
177 + ],
178 + "engines": {
179 + "node": ">=18"
180 + }
181 + },
182 + "node_modules/@esbuild/linux-arm64": {
183 + "version": "0.28.2",
184 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
185 + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
186 + "cpu": [
187 + "arm64"
188 + ],
189 + "dev": true,
190 + "license": "MIT",
191 + "optional": true,
192 + "os": [
193 + "linux"
194 + ],
195 + "engines": {
196 + "node": ">=18"
197 + }
198 + },
199 + "node_modules/@esbuild/linux-ia32": {
200 + "version": "0.28.2",
201 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
202 + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
203 + "cpu": [
204 + "ia32"
205 + ],
206 + "dev": true,
207 + "license": "MIT",
208 + "optional": true,
209 + "os": [
210 + "linux"
211 + ],
212 + "engines": {
213 + "node": ">=18"
214 + }
215 + },
216 + "node_modules/@esbuild/linux-loong64": {
217 + "version": "0.28.2",
218 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
219 + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
220 + "cpu": [
221 + "loong64"
222 + ],
223 + "dev": true,
224 + "license": "MIT",
225 + "optional": true,
226 + "os": [
227 + "linux"
228 + ],
229 + "engines": {
230 + "node": ">=18"
231 + }
232 + },
233 + "node_modules/@esbuild/linux-mips64el": {
234 + "version": "0.28.2",
235 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
236 + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
237 + "cpu": [
238 + "mips64el"
239 + ],
240 + "dev": true,
241 + "license": "MIT",
242 + "optional": true,
243 + "os": [
244 + "linux"
245 + ],
246 + "engines": {
247 + "node": ">=18"
248 + }
249 + },
250 + "node_modules/@esbuild/linux-ppc64": {
251 + "version": "0.28.2",
252 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
253 + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
254 + "cpu": [
255 + "ppc64"
256 + ],
257 + "dev": true,
258 + "license": "MIT",
259 + "optional": true,
260 + "os": [
261 + "linux"
262 + ],
263 + "engines": {
264 + "node": ">=18"
265 + }
266 + },
267 + "node_modules/@esbuild/linux-riscv64": {
268 + "version": "0.28.2",
269 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
270 + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
271 + "cpu": [
272 + "riscv64"
273 + ],
274 + "dev": true,
275 + "license": "MIT",
276 + "optional": true,
277 + "os": [
278 + "linux"
279 + ],
280 + "engines": {
281 + "node": ">=18"
282 + }
283 + },
284 + "node_modules/@esbuild/linux-s390x": {
285 + "version": "0.28.2",
286 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
287 + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
288 + "cpu": [
289 + "s390x"
290 + ],
291 + "dev": true,
292 + "license": "MIT",
293 + "optional": true,
294 + "os": [
295 + "linux"
296 + ],
297 + "engines": {
298 + "node": ">=18"
299 + }
300 + },
301 + "node_modules/@esbuild/linux-x64": {
302 + "version": "0.28.2",
303 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
304 + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
305 + "cpu": [
306 + "x64"
307 + ],
308 + "dev": true,
309 + "license": "MIT",
310 + "optional": true,
311 + "os": [
312 + "linux"
313 + ],
314 + "engines": {
315 + "node": ">=18"
316 + }
317 + },
318 + "node_modules/@esbuild/netbsd-arm64": {
319 + "version": "0.28.2",
320 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
321 + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
322 + "cpu": [
323 + "arm64"
324 + ],
325 + "dev": true,
326 + "license": "MIT",
327 + "optional": true,
328 + "os": [
329 + "netbsd"
330 + ],
331 + "engines": {
332 + "node": ">=18"
333 + }
334 + },
335 + "node_modules/@esbuild/netbsd-x64": {
336 + "version": "0.28.2",
337 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
338 + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
339 + "cpu": [
340 + "x64"
341 + ],
342 + "dev": true,
343 + "license": "MIT",
344 + "optional": true,
345 + "os": [
346 + "netbsd"
347 + ],
348 + "engines": {
349 + "node": ">=18"
350 + }
351 + },
352 + "node_modules/@esbuild/openbsd-arm64": {
353 + "version": "0.28.2",
354 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
355 + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
356 + "cpu": [
357 + "arm64"
358 + ],
359 + "dev": true,
360 + "license": "MIT",
361 + "optional": true,
362 + "os": [
363 + "openbsd"
364 + ],
365 + "engines": {
366 + "node": ">=18"
367 + }
368 + },
369 + "node_modules/@esbuild/openbsd-x64": {
370 + "version": "0.28.2",
371 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
372 + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
373 + "cpu": [
374 + "x64"
375 + ],
376 + "dev": true,
377 + "license": "MIT",
378 + "optional": true,
379 + "os": [
380 + "openbsd"
381 + ],
382 + "engines": {
383 + "node": ">=18"
384 + }
385 + },
386 + "node_modules/@esbuild/openharmony-arm64": {
387 + "version": "0.28.2",
388 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
389 + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
390 + "cpu": [
391 + "arm64"
392 + ],
393 + "dev": true,
394 + "license": "MIT",
395 + "optional": true,
396 + "os": [
397 + "openharmony"
398 + ],
399 + "engines": {
400 + "node": ">=18"
401 + }
402 + },
403 + "node_modules/@esbuild/sunos-x64": {
404 + "version": "0.28.2",
405 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
406 + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
407 + "cpu": [
408 + "x64"
409 + ],
410 + "dev": true,
411 + "license": "MIT",
412 + "optional": true,
413 + "os": [
414 + "sunos"
415 + ],
416 + "engines": {
417 + "node": ">=18"
418 + }
419 + },
420 + "node_modules/@esbuild/win32-arm64": {
421 + "version": "0.28.2",
422 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
423 + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
424 + "cpu": [
425 + "arm64"
426 + ],
427 + "dev": true,
428 + "license": "MIT",
429 + "optional": true,
430 + "os": [
431 + "win32"
432 + ],
433 + "engines": {
434 + "node": ">=18"
435 + }
436 + },
437 + "node_modules/@esbuild/win32-ia32": {
438 + "version": "0.28.2",
439 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
440 + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
441 + "cpu": [
442 + "ia32"
443 + ],
444 + "dev": true,
445 + "license": "MIT",
446 + "optional": true,
447 + "os": [
448 + "win32"
449 + ],
450 + "engines": {
451 + "node": ">=18"
452 + }
453 + },
454 + "node_modules/@esbuild/win32-x64": {
455 + "version": "0.28.2",
456 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
457 + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
458 + "cpu": [
459 + "x64"
460 + ],
461 + "dev": true,
462 + "license": "MIT",
463 + "optional": true,
464 + "os": [
465 + "win32"
466 + ],
467 + "engines": {
468 + "node": ">=18"
469 + }
470 + },
471 + "node_modules/@jridgewell/sourcemap-codec": {
472 + "version": "1.5.5",
473 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
474 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
475 + "dev": true,
476 + "license": "MIT"
477 + },
478 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
479 + "version": "1.5.1",
480 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
481 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
482 + "cpu": [
483 + "x64"
484 + ],
485 + "dev": true,
486 + "libc": [
487 + "glibc"
488 + ],
489 + "license": "MIT",
490 + "optional": true,
491 + "os": [
492 + "linux"
493 + ],
494 + "engines": {
495 + "node": "^22.20 || ^24.12 || >=25"
496 + }
497 + },
498 + "node_modules/@rollup/rollup-android-arm-eabi": {
499 + "version": "4.62.4",
500 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
501 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
502 + "cpu": [
503 + "arm"
504 + ],
505 + "dev": true,
506 + "license": "MIT",
507 + "optional": true,
508 + "os": [
509 + "android"
510 + ]
511 + },
512 + "node_modules/@rollup/rollup-android-arm64": {
513 + "version": "4.62.4",
514 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
515 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
516 + "cpu": [
517 + "arm64"
518 + ],
519 + "dev": true,
520 + "license": "MIT",
521 + "optional": true,
522 + "os": [
523 + "android"
524 + ]
525 + },
526 + "node_modules/@rollup/rollup-darwin-arm64": {
527 + "version": "4.62.4",
528 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
529 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
530 + "cpu": [
531 + "arm64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "darwin"
538 + ]
539 + },
540 + "node_modules/@rollup/rollup-darwin-x64": {
541 + "version": "4.62.4",
542 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
543 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
544 + "cpu": [
545 + "x64"
546 + ],
547 + "dev": true,
548 + "license": "MIT",
549 + "optional": true,
550 + "os": [
551 + "darwin"
552 + ]
553 + },
554 + "node_modules/@rollup/rollup-freebsd-arm64": {
555 + "version": "4.62.4",
556 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
557 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
558 + "cpu": [
559 + "arm64"
560 + ],
561 + "dev": true,
562 + "license": "MIT",
563 + "optional": true,
564 + "os": [
565 + "freebsd"
566 + ]
567 + },
568 + "node_modules/@rollup/rollup-freebsd-x64": {
569 + "version": "4.62.4",
570 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
571 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
572 + "cpu": [
573 + "x64"
574 + ],
575 + "dev": true,
576 + "license": "MIT",
577 + "optional": true,
578 + "os": [
579 + "freebsd"
580 + ]
581 + },
582 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
583 + "version": "4.62.4",
584 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
585 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
586 + "cpu": [
587 + "arm"
588 + ],
589 + "dev": true,
590 + "libc": [
591 + "glibc"
592 + ],
593 + "license": "MIT",
594 + "optional": true,
595 + "os": [
596 + "linux"
597 + ]
598 + },
599 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
600 + "version": "4.62.4",
601 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
602 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
603 + "cpu": [
604 + "arm"
605 + ],
606 + "dev": true,
607 + "libc": [
608 + "musl"
609 + ],
610 + "license": "MIT",
611 + "optional": true,
612 + "os": [
613 + "linux"
614 + ]
615 + },
616 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
617 + "version": "4.62.4",
618 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
619 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
620 + "cpu": [
621 + "arm64"
622 + ],
623 + "dev": true,
624 + "libc": [
625 + "glibc"
626 + ],
627 + "license": "MIT",
628 + "optional": true,
629 + "os": [
630 + "linux"
631 + ]
632 + },
633 + "node_modules/@rollup/rollup-linux-arm64-musl": {
634 + "version": "4.62.4",
635 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
636 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
637 + "cpu": [
638 + "arm64"
639 + ],
640 + "dev": true,
641 + "libc": [
642 + "musl"
643 + ],
644 + "license": "MIT",
645 + "optional": true,
646 + "os": [
647 + "linux"
648 + ]
649 + },
650 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
651 + "version": "4.62.4",
652 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
653 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
654 + "cpu": [
655 + "loong64"
656 + ],
657 + "dev": true,
658 + "libc": [
659 + "glibc"
660 + ],
661 + "license": "MIT",
662 + "optional": true,
663 + "os": [
664 + "linux"
665 + ]
666 + },
667 + "node_modules/@rollup/rollup-linux-loong64-musl": {
668 + "version": "4.62.4",
669 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
670 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
671 + "cpu": [
672 + "loong64"
673 + ],
674 + "dev": true,
675 + "libc": [
676 + "musl"
677 + ],
678 + "license": "MIT",
679 + "optional": true,
680 + "os": [
681 + "linux"
682 + ]
683 + },
684 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
685 + "version": "4.62.4",
686 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
687 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
688 + "cpu": [
689 + "ppc64"
690 + ],
691 + "dev": true,
692 + "libc": [
693 + "glibc"
694 + ],
695 + "license": "MIT",
696 + "optional": true,
697 + "os": [
698 + "linux"
699 + ]
700 + },
701 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
702 + "version": "4.62.4",
703 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
704 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
705 + "cpu": [
706 + "ppc64"
707 + ],
708 + "dev": true,
709 + "libc": [
710 + "musl"
711 + ],
712 + "license": "MIT",
713 + "optional": true,
714 + "os": [
715 + "linux"
716 + ]
717 + },
718 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
719 + "version": "4.62.4",
720 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
721 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
722 + "cpu": [
723 + "riscv64"
724 + ],
725 + "dev": true,
726 + "libc": [
727 + "glibc"
728 + ],
729 + "license": "MIT",
730 + "optional": true,
731 + "os": [
732 + "linux"
733 + ]
734 + },
735 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
736 + "version": "4.62.4",
737 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
738 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
739 + "cpu": [
740 + "riscv64"
741 + ],
742 + "dev": true,
743 + "libc": [
744 + "musl"
745 + ],
746 + "license": "MIT",
747 + "optional": true,
748 + "os": [
749 + "linux"
750 + ]
751 + },
752 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
753 + "version": "4.62.4",
754 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
755 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
756 + "cpu": [
757 + "s390x"
758 + ],
759 + "dev": true,
760 + "libc": [
761 + "glibc"
762 + ],
763 + "license": "MIT",
764 + "optional": true,
765 + "os": [
766 + "linux"
767 + ]
768 + },
769 + "node_modules/@rollup/rollup-linux-x64-gnu": {
770 + "version": "4.62.4",
771 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
772 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
773 + "cpu": [
774 + "x64"
775 + ],
776 + "dev": true,
777 + "libc": [
778 + "glibc"
779 + ],
780 + "license": "MIT",
781 + "optional": true,
782 + "os": [
783 + "linux"
784 + ]
785 + },
786 + "node_modules/@rollup/rollup-linux-x64-musl": {
787 + "version": "4.62.4",
788 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
789 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
790 + "cpu": [
791 + "x64"
792 + ],
793 + "dev": true,
794 + "libc": [
795 + "musl"
796 + ],
797 + "license": "MIT",
798 + "optional": true,
799 + "os": [
800 + "linux"
801 + ]
802 + },
803 + "node_modules/@rollup/rollup-openbsd-x64": {
804 + "version": "4.62.4",
805 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
806 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
807 + "cpu": [
808 + "x64"
809 + ],
810 + "dev": true,
811 + "license": "MIT",
812 + "optional": true,
813 + "os": [
814 + "openbsd"
815 + ]
816 + },
817 + "node_modules/@rollup/rollup-openharmony-arm64": {
818 + "version": "4.62.4",
819 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
820 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
821 + "cpu": [
822 + "arm64"
823 + ],
824 + "dev": true,
825 + "license": "MIT",
826 + "optional": true,
827 + "os": [
828 + "openharmony"
829 + ]
830 + },
831 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
832 + "version": "4.62.4",
833 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
834 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
835 + "cpu": [
836 + "arm64"
837 + ],
838 + "dev": true,
839 + "license": "MIT",
840 + "optional": true,
841 + "os": [
842 + "win32"
843 + ]
844 + },
845 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
846 + "version": "4.62.4",
847 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
848 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
849 + "cpu": [
850 + "ia32"
851 + ],
852 + "dev": true,
853 + "license": "MIT",
854 + "optional": true,
855 + "os": [
856 + "win32"
857 + ]
858 + },
859 + "node_modules/@rollup/rollup-win32-x64-gnu": {
860 + "version": "4.62.4",
861 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
862 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
863 + "cpu": [
864 + "x64"
865 + ],
866 + "dev": true,
867 + "license": "MIT",
868 + "optional": true,
869 + "os": [
870 + "win32"
871 + ]
872 + },
873 + "node_modules/@rollup/rollup-win32-x64-msvc": {
874 + "version": "4.62.4",
875 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
876 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
877 + "cpu": [
878 + "x64"
879 + ],
880 + "dev": true,
881 + "license": "MIT",
882 + "optional": true,
883 + "os": [
884 + "win32"
885 + ]
886 + },
887 + "node_modules/@types/chai": {
888 + "version": "5.2.3",
889 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
890 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
891 + "dev": true,
892 + "license": "MIT",
893 + "dependencies": {
894 + "@types/deep-eql": "*",
895 + "assertion-error": "^2.0.1"
896 + }
897 + },
898 + "node_modules/@types/deep-eql": {
899 + "version": "4.0.2",
900 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
901 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
902 + "dev": true,
903 + "license": "MIT"
904 + },
905 + "node_modules/@types/estree": {
906 + "version": "1.0.9",
907 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
908 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
909 + "dev": true,
910 + "license": "MIT"
911 + },
912 + "node_modules/@types/geojson": {
913 + "version": "7946.0.16",
914 + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
915 + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
916 + "license": "MIT"
917 + },
918 + "node_modules/@types/react": {
919 + "version": "19.2.18",
920 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
921 + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
922 + "dev": true,
923 + "license": "MIT",
924 + "dependencies": {
925 + "csstype": "^3.2.2"
926 + }
927 + },
928 + "node_modules/@vitest/expect": {
929 + "version": "3.2.7",
930 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
931 + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
932 + "dev": true,
933 + "license": "MIT",
934 + "dependencies": {
935 + "@types/chai": "^5.2.2",
936 + "@vitest/spy": "3.2.7",
937 + "@vitest/utils": "3.2.7",
938 + "chai": "^5.2.0",
939 + "tinyrainbow": "^2.0.0"
940 + },
941 + "funding": {
942 + "url": "https://opencollective.com/vitest"
943 + }
944 + },
945 + "node_modules/@vitest/mocker": {
946 + "version": "3.2.7",
947 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
948 + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
949 + "dev": true,
950 + "license": "MIT",
951 + "dependencies": {
952 + "@vitest/spy": "3.2.7",
953 + "estree-walker": "^3.0.3",
954 + "magic-string": "^0.30.17"
955 + },
956 + "funding": {
957 + "url": "https://opencollective.com/vitest"
958 + },
959 + "peerDependencies": {
960 + "msw": "^2.4.9",
961 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
962 + },
963 + "peerDependenciesMeta": {
964 + "msw": {
965 + "optional": true
966 + },
967 + "vite": {
968 + "optional": true
969 + }
970 + }
971 + },
972 + "node_modules/@vitest/pretty-format": {
973 + "version": "3.2.7",
974 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
975 + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
976 + "dev": true,
977 + "license": "MIT",
978 + "dependencies": {
979 + "tinyrainbow": "^2.0.0"
980 + },
981 + "funding": {
982 + "url": "https://opencollective.com/vitest"
983 + }
984 + },
985 + "node_modules/@vitest/runner": {
986 + "version": "3.2.7",
987 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
988 + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
989 + "dev": true,
990 + "license": "MIT",
991 + "dependencies": {
992 + "@vitest/utils": "3.2.7",
993 + "pathe": "^2.0.3",
994 + "strip-literal": "^3.0.0"
995 + },
996 + "funding": {
997 + "url": "https://opencollective.com/vitest"
998 + }
999 + },
1000 + "node_modules/@vitest/snapshot": {
1001 + "version": "3.2.7",
1002 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
1003 + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
1004 + "dev": true,
1005 + "license": "MIT",
1006 + "dependencies": {
1007 + "@vitest/pretty-format": "3.2.7",
1008 + "magic-string": "^0.30.17",
1009 + "pathe": "^2.0.3"
1010 + },
1011 + "funding": {
1012 + "url": "https://opencollective.com/vitest"
1013 + }
1014 + },
1015 + "node_modules/@vitest/spy": {
1016 + "version": "3.2.7",
1017 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
1018 + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
1019 + "dev": true,
1020 + "license": "MIT",
1021 + "dependencies": {
1022 + "tinyspy": "^4.0.3"
1023 + },
1024 + "funding": {
1025 + "url": "https://opencollective.com/vitest"
1026 + }
1027 + },
1028 + "node_modules/@vitest/utils": {
1029 + "version": "3.2.7",
1030 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
1031 + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
1032 + "dev": true,
1033 + "license": "MIT",
1034 + "dependencies": {
1035 + "@vitest/pretty-format": "3.2.7",
1036 + "loupe": "^3.1.4",
1037 + "tinyrainbow": "^2.0.0"
1038 + },
1039 + "funding": {
1040 + "url": "https://opencollective.com/vitest"
1041 + }
1042 + },
1043 + "node_modules/assertion-error": {
1044 + "version": "2.0.1",
1045 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1046 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1047 + "dev": true,
1048 + "license": "MIT",
1049 + "engines": {
1050 + "node": ">=12"
1051 + }
1052 + },
1053 + "node_modules/cac": {
1054 + "version": "6.7.14",
1055 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
1056 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
1057 + "dev": true,
1058 + "license": "MIT",
1059 + "engines": {
1060 + "node": ">=8"
1061 + }
1062 + },
1063 + "node_modules/chai": {
1064 + "version": "5.3.3",
1065 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
1066 + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
1067 + "dev": true,
1068 + "license": "MIT",
1069 + "dependencies": {
1070 + "assertion-error": "^2.0.1",
1071 + "check-error": "^2.1.1",
1072 + "deep-eql": "^5.0.1",
1073 + "loupe": "^3.1.0",
1074 + "pathval": "^2.0.0"
1075 + },
1076 + "engines": {
1077 + "node": ">=18"
1078 + }
1079 + },
1080 + "node_modules/check-error": {
1081 + "version": "2.1.3",
1082 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
1083 + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
1084 + "dev": true,
1085 + "license": "MIT",
1086 + "engines": {
1087 + "node": ">= 16"
1088 + }
1089 + },
1090 + "node_modules/csstype": {
1091 + "version": "3.2.3",
1092 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1093 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1094 + "dev": true,
1095 + "license": "MIT"
1096 + },
1097 + "node_modules/debug": {
1098 + "version": "4.4.3",
1099 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1100 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1101 + "dev": true,
1102 + "license": "MIT",
1103 + "dependencies": {
1104 + "ms": "^2.1.3"
1105 + },
1106 + "engines": {
1107 + "node": ">=6.0"
1108 + },
1109 + "peerDependenciesMeta": {
1110 + "supports-color": {
1111 + "optional": true
1112 + }
1113 + }
1114 + },
1115 + "node_modules/deep-eql": {
1116 + "version": "5.0.2",
1117 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1118 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1119 + "dev": true,
1120 + "license": "MIT",
1121 + "engines": {
1122 + "node": ">=6"
1123 + }
1124 + },
1125 + "node_modules/es-module-lexer": {
1126 + "version": "1.7.0",
1127 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1128 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1129 + "dev": true,
1130 + "license": "MIT"
1131 + },
1132 + "node_modules/esbuild": {
1133 + "version": "0.28.2",
1134 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
1135 + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
1136 + "dev": true,
1137 + "hasInstallScript": true,
1138 + "license": "MIT",
1139 + "bin": {
1140 + "esbuild": "bin/esbuild"
1141 + },
1142 + "engines": {
1143 + "node": ">=18"
1144 + },
1145 + "optionalDependencies": {
1146 + "@esbuild/aix-ppc64": "0.28.2",
1147 + "@esbuild/android-arm": "0.28.2",
1148 + "@esbuild/android-arm64": "0.28.2",
1149 + "@esbuild/android-x64": "0.28.2",
1150 + "@esbuild/darwin-arm64": "0.28.2",
1151 + "@esbuild/darwin-x64": "0.28.2",
1152 + "@esbuild/freebsd-arm64": "0.28.2",
1153 + "@esbuild/freebsd-x64": "0.28.2",
1154 + "@esbuild/linux-arm": "0.28.2",
1155 + "@esbuild/linux-arm64": "0.28.2",
1156 + "@esbuild/linux-ia32": "0.28.2",
1157 + "@esbuild/linux-loong64": "0.28.2",
1158 + "@esbuild/linux-mips64el": "0.28.2",
1159 + "@esbuild/linux-ppc64": "0.28.2",
1160 + "@esbuild/linux-riscv64": "0.28.2",
1161 + "@esbuild/linux-s390x": "0.28.2",
1162 + "@esbuild/linux-x64": "0.28.2",
1163 + "@esbuild/netbsd-arm64": "0.28.2",
1164 + "@esbuild/netbsd-x64": "0.28.2",
1165 + "@esbuild/openbsd-arm64": "0.28.2",
1166 + "@esbuild/openbsd-x64": "0.28.2",
1167 + "@esbuild/openharmony-arm64": "0.28.2",
1168 + "@esbuild/sunos-x64": "0.28.2",
1169 + "@esbuild/win32-arm64": "0.28.2",
1170 + "@esbuild/win32-ia32": "0.28.2",
1171 + "@esbuild/win32-x64": "0.28.2"
1172 + }
1173 + },
1174 + "node_modules/estree-walker": {
1175 + "version": "3.0.3",
1176 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1177 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1178 + "dev": true,
1179 + "license": "MIT",
1180 + "dependencies": {
1181 + "@types/estree": "^1.0.0"
1182 + }
1183 + },
1184 + "node_modules/expect-type": {
1185 + "version": "1.4.0",
1186 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
1187 + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
1188 + "dev": true,
1189 + "license": "Apache-2.0",
1190 + "engines": {
1191 + "node": ">=12.0.0"
1192 + }
1193 + },
1194 + "node_modules/fdir": {
1195 + "version": "6.5.0",
1196 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1197 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1198 + "dev": true,
1199 + "license": "MIT",
1200 + "engines": {
1201 + "node": ">=12.0.0"
1202 + },
1203 + "peerDependencies": {
1204 + "picomatch": "^3 || ^4"
1205 + },
1206 + "peerDependenciesMeta": {
1207 + "picomatch": {
1208 + "optional": true
1209 + }
1210 + }
1211 + },
1212 + "node_modules/fsevents": {
1213 + "version": "2.3.3",
1214 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1215 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1216 + "dev": true,
1217 + "hasInstallScript": true,
1218 + "license": "MIT",
1219 + "optional": true,
1220 + "os": [
1221 + "darwin"
1222 + ],
1223 + "engines": {
1224 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1225 + }
1226 + },
1227 + "node_modules/js-tokens": {
1228 + "version": "9.0.1",
1229 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
1230 + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
1231 + "dev": true,
1232 + "license": "MIT"
1233 + },
1234 + "node_modules/loupe": {
1235 + "version": "3.2.1",
1236 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
1237 + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
1238 + "dev": true,
1239 + "license": "MIT"
1240 + },
1241 + "node_modules/magic-string": {
1242 + "version": "0.30.21",
1243 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1244 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "dependencies": {
1248 + "@jridgewell/sourcemap-codec": "^1.5.5"
1249 + }
1250 + },
1251 + "node_modules/mapbox-gl": {
1252 + "version": "3.28.1",
1253 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1254 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1255 + "license": "SEE LICENSE IN LICENSE.txt",
1256 + "workspaces": [
1257 + "src/style-spec",
1258 + "plugins/mapbox-gl-pmtiles-provider",
1259 + "test/bundlers/*",
1260 + "test/build/typings"
1261 + ]
1262 + },
1263 + "node_modules/ms": {
1264 + "version": "2.1.3",
1265 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1266 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1267 + "dev": true,
1268 + "license": "MIT"
1269 + },
1270 + "node_modules/nanoid": {
1271 + "version": "3.3.18",
1272 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1273 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1274 + "dev": true,
1275 + "funding": [
1276 + {
1277 + "type": "github",
1278 + "url": "https://github.com/sponsors/ai"
1279 + }
1280 + ],
1281 + "license": "MIT",
1282 + "bin": {
1283 + "nanoid": "bin/nanoid.cjs"
1284 + },
1285 + "engines": {
1286 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1287 + }
1288 + },
1289 + "node_modules/pathe": {
1290 + "version": "2.0.3",
1291 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1292 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1293 + "dev": true,
1294 + "license": "MIT"
1295 + },
1296 + "node_modules/pathval": {
1297 + "version": "2.0.1",
1298 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
1299 + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
1300 + "dev": true,
1301 + "license": "MIT",
1302 + "engines": {
1303 + "node": ">= 14.16"
1304 + }
1305 + },
1306 + "node_modules/picocolors": {
1307 + "version": "1.1.1",
1308 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1309 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1310 + "dev": true,
1311 + "license": "ISC"
1312 + },
1313 + "node_modules/picomatch": {
1314 + "version": "4.0.5",
1315 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1316 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1317 + "dev": true,
1318 + "license": "MIT",
1319 + "engines": {
1320 + "node": ">=12"
1321 + },
1322 + "funding": {
1323 + "url": "https://github.com/sponsors/jonschlinkert"
1324 + }
1325 + },
1326 + "node_modules/postcss": {
1327 + "version": "8.5.26",
1328 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1329 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1330 + "dev": true,
1331 + "funding": [
1332 + {
1333 + "type": "opencollective",
1334 + "url": "https://opencollective.com/postcss/"
1335 + },
1336 + {
1337 + "type": "tidelift",
1338 + "url": "https://tidelift.com/funding/github/npm/postcss"
1339 + },
1340 + {
1341 + "type": "github",
1342 + "url": "https://github.com/sponsors/ai"
1343 + }
1344 + ],
1345 + "license": "MIT",
1346 + "dependencies": {
1347 + "nanoid": "^3.3.17",
1348 + "picocolors": "^1.1.1",
1349 + "source-map-js": "^1.2.1"
1350 + },
1351 + "engines": {
1352 + "node": "^10 || ^12 || >=14"
1353 + }
1354 + },
1355 + "node_modules/rollup": {
1356 + "version": "4.62.4",
1357 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1358 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1359 + "dev": true,
1360 + "license": "MIT",
1361 + "dependencies": {
1362 + "@types/estree": "1.0.9"
1363 + },
1364 + "bin": {
1365 + "rollup": "dist/bin/rollup"
1366 + },
1367 + "engines": {
1368 + "node": ">=18.0.0",
1369 + "npm": ">=8.0.0"
1370 + },
1371 + "optionalDependencies": {
1372 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1373 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1374 + "@rollup/rollup-android-arm64": "4.62.4",
1375 + "@rollup/rollup-darwin-arm64": "4.62.4",
1376 + "@rollup/rollup-darwin-x64": "4.62.4",
1377 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1378 + "@rollup/rollup-freebsd-x64": "4.62.4",
1379 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1380 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1381 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1382 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1383 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1384 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1385 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1386 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1387 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1388 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1389 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1390 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1391 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1392 + "@rollup/rollup-openbsd-x64": "4.62.4",
1393 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1394 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1395 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1396 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1397 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1398 + "fsevents": "~2.3.2"
1399 + }
1400 + },
1401 + "node_modules/siginfo": {
1402 + "version": "2.0.0",
1403 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
1404 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
1405 + "dev": true,
1406 + "license": "ISC"
1407 + },
1408 + "node_modules/source-map-js": {
1409 + "version": "1.2.1",
1410 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1411 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1412 + "dev": true,
1413 + "license": "BSD-3-Clause",
1414 + "engines": {
1415 + "node": ">=0.10.0"
1416 + }
1417 + },
1418 + "node_modules/stackback": {
1419 + "version": "0.0.2",
1420 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
1421 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
1422 + "dev": true,
1423 + "license": "MIT"
1424 + },
1425 + "node_modules/std-env": {
1426 + "version": "3.10.0",
1427 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
1428 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
1429 + "dev": true,
1430 + "license": "MIT"
1431 + },
1432 + "node_modules/strip-literal": {
1433 + "version": "3.1.0",
1434 + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
1435 + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
1436 + "dev": true,
1437 + "license": "MIT",
1438 + "dependencies": {
1439 + "js-tokens": "^9.0.1"
1440 + },
1441 + "funding": {
1442 + "url": "https://github.com/sponsors/antfu"
1443 + }
1444 + },
1445 + "node_modules/tinybench": {
1446 + "version": "2.9.0",
1447 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
1448 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
1449 + "dev": true,
1450 + "license": "MIT"
1451 + },
1452 + "node_modules/tinyexec": {
1453 + "version": "0.3.2",
1454 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
1455 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
1456 + "dev": true,
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/tinyglobby": {
1460 + "version": "0.2.17",
1461 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1462 + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "dependencies": {
1466 + "fdir": "^6.5.0",
1467 + "picomatch": "^4.0.4"
1468 + },
1469 + "engines": {
1470 + "node": ">=12.0.0"
1471 + },
1472 + "funding": {
1473 + "url": "https://github.com/sponsors/SuperchupuDev"
1474 + }
1475 + },
1476 + "node_modules/tinypool": {
1477 + "version": "1.1.1",
1478 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
1479 + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
1480 + "dev": true,
1481 + "license": "MIT",
1482 + "engines": {
1483 + "node": "^18.0.0 || >=20.0.0"
1484 + }
1485 + },
1486 + "node_modules/tinyrainbow": {
1487 + "version": "2.0.0",
1488 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
1489 + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
1490 + "dev": true,
1491 + "license": "MIT",
1492 + "engines": {
1493 + "node": ">=14.0.0"
1494 + }
1495 + },
1496 + "node_modules/tinyspy": {
1497 + "version": "4.0.4",
1498 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
1499 + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
1500 + "dev": true,
1501 + "license": "MIT",
1502 + "engines": {
1503 + "node": ">=14.0.0"
1504 + }
1505 + },
1506 + "node_modules/typescript": {
1507 + "version": "5.9.3",
1508 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1509 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1510 + "dev": true,
1511 + "license": "Apache-2.0",
1512 + "bin": {
1513 + "tsc": "bin/tsc",
1514 + "tsserver": "bin/tsserver"
1515 + },
1516 + "engines": {
1517 + "node": ">=14.17"
1518 + }
1519 + },
1520 + "node_modules/vite": {
1521 + "version": "7.3.6",
1522 + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
1523 + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
1524 + "dev": true,
1525 + "license": "MIT",
1526 + "dependencies": {
1527 + "esbuild": "^0.27.0 || ^0.28.0",
1528 + "fdir": "^6.5.0",
1529 + "picomatch": "^4.0.3",
1530 + "postcss": "^8.5.6",
1531 + "rollup": "^4.43.0",
1532 + "tinyglobby": "^0.2.15"
1533 + },
1534 + "bin": {
1535 + "vite": "bin/vite.js"
1536 + },
1537 + "engines": {
1538 + "node": "^20.19.0 || >=22.12.0"
1539 + },
1540 + "funding": {
1541 + "url": "https://github.com/vitejs/vite?sponsor=1"
1542 + },
1543 + "optionalDependencies": {
1544 + "fsevents": "~2.3.3"
1545 + },
1546 + "peerDependencies": {
1547 + "@types/node": "^20.19.0 || >=22.12.0",
1548 + "jiti": ">=1.21.0",
1549 + "less": "^4.0.0",
1550 + "lightningcss": "^1.21.0",
1551 + "sass": "^1.70.0",
1552 + "sass-embedded": "^1.70.0",
1553 + "stylus": ">=0.54.8",
1554 + "sugarss": "^5.0.0",
1555 + "terser": "^5.16.0",
1556 + "tsx": "^4.8.1",
1557 + "yaml": "^2.4.2"
1558 + },
1559 + "peerDependenciesMeta": {
1560 + "@types/node": {
1561 + "optional": true
1562 + },
1563 + "jiti": {
1564 + "optional": true
1565 + },
1566 + "less": {
1567 + "optional": true
1568 + },
1569 + "lightningcss": {
1570 + "optional": true
1571 + },
1572 + "sass": {
1573 + "optional": true
1574 + },
1575 + "sass-embedded": {
1576 + "optional": true
1577 + },
1578 + "stylus": {
1579 + "optional": true
1580 + },
1581 + "sugarss": {
1582 + "optional": true
1583 + },
1584 + "terser": {
1585 + "optional": true
1586 + },
1587 + "tsx": {
1588 + "optional": true
1589 + },
1590 + "yaml": {
1591 + "optional": true
1592 + }
1593 + }
1594 + },
1595 + "node_modules/vite-node": {
1596 + "version": "3.2.4",
1597 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
1598 + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
1599 + "dev": true,
1600 + "license": "MIT",
1601 + "dependencies": {
1602 + "cac": "^6.7.14",
1603 + "debug": "^4.4.1",
1604 + "es-module-lexer": "^1.7.0",
1605 + "pathe": "^2.0.3",
1606 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
1607 + },
1608 + "bin": {
1609 + "vite-node": "vite-node.mjs"
1610 + },
1611 + "engines": {
1612 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1613 + },
1614 + "funding": {
1615 + "url": "https://opencollective.com/vitest"
1616 + }
1617 + },
1618 + "node_modules/vitest": {
1619 + "version": "3.2.7",
1620 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
1621 + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
1622 + "dev": true,
1623 + "license": "MIT",
1624 + "dependencies": {
1625 + "@types/chai": "^5.2.2",
1626 + "@vitest/expect": "3.2.7",
1627 + "@vitest/mocker": "3.2.7",
1628 + "@vitest/pretty-format": "^3.2.7",
1629 + "@vitest/runner": "3.2.7",
1630 + "@vitest/snapshot": "3.2.7",
1631 + "@vitest/spy": "3.2.7",
1632 + "@vitest/utils": "3.2.7",
1633 + "chai": "^5.2.0",
1634 + "debug": "^4.4.1",
1635 + "expect-type": "^1.2.1",
1636 + "magic-string": "^0.30.17",
1637 + "pathe": "^2.0.3",
1638 + "picomatch": "^4.0.2",
1639 + "std-env": "^3.9.0",
1640 + "tinybench": "^2.9.0",
1641 + "tinyexec": "^0.3.2",
1642 + "tinyglobby": "^0.2.14",
1643 + "tinypool": "^1.1.1",
1644 + "tinyrainbow": "^2.0.0",
1645 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
1646 + "vite-node": "3.2.4",
1647 + "why-is-node-running": "^2.3.0"
1648 + },
1649 + "bin": {
1650 + "vitest": "vitest.mjs"
1651 + },
1652 + "engines": {
1653 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1654 + },
1655 + "funding": {
1656 + "url": "https://opencollective.com/vitest"
1657 + },
1658 + "peerDependencies": {
1659 + "@edge-runtime/vm": "*",
1660 + "@types/debug": "^4.1.12",
1661 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1662 + "@vitest/browser": "3.2.7",
1663 + "@vitest/ui": "3.2.7",
1664 + "happy-dom": "*",
1665 + "jsdom": "*"
1666 + },
1667 + "peerDependenciesMeta": {
1668 + "@edge-runtime/vm": {
1669 + "optional": true
1670 + },
1671 + "@types/debug": {
1672 + "optional": true
1673 + },
1674 + "@types/node": {
1675 + "optional": true
1676 + },
1677 + "@vitest/browser": {
1678 + "optional": true
1679 + },
1680 + "@vitest/ui": {
1681 + "optional": true
1682 + },
1683 + "happy-dom": {
1684 + "optional": true
1685 + },
1686 + "jsdom": {
1687 + "optional": true
1688 + }
1689 + }
1690 + },
1691 + "node_modules/why-is-node-running": {
1692 + "version": "2.3.0",
1693 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
1694 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
1695 + "dev": true,
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "siginfo": "^2.0.0",
1699 + "stackback": "0.0.2"
1700 + },
1701 + "bin": {
1702 + "why-is-node-running": "cli.js"
1703 + },
1704 + "engines": {
1705 + "node": ">=8"
1706 + }
1707 + }
1708 + }
1709 +}
added package.json +49 −0
@@ -0,0 +1,49 @@
1 +{
2 + "name": "@groupe-ka/ka-maps",
3 + "version": "0.1.0",
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>",
6 + "license": "UNLICENSED",
7 + "private": true,
8 + "type": "module",
9 + "main": "./dist/index.js",
10 + "types": "./dist/index.d.ts",
11 + "exports": {
12 + ".": {
13 + "types": "./dist/index.d.ts",
14 + "import": "./dist/index.js"
15 + },
16 + "./react": {
17 + "types": "./dist/react/index.d.ts",
18 + "import": "./dist/react/index.js"
19 + },
20 + "./styles.css": "./dist/ka-maps.css"
21 + },
22 + "files": [
23 + "dist"
24 + ],
25 + "scripts": {
26 + "build": "tsc -p tsconfig.build.json && cp src/styles/ka-maps.css dist/ka-maps.css",
27 + "typecheck": "tsc --noEmit",
28 + "test": "vitest run",
29 + "test:watch": "vitest",
30 + "pack:tarball": "npm run build && npm pack --pack-destination ."
31 + },
32 + "dependencies": {
33 + "@types/geojson": "^7946.0.16",
34 + "mapbox-gl": "^3.28.1"
35 + },
36 + "peerDependencies": {
37 + "react": ">=18"
38 + },
39 + "peerDependenciesMeta": {
40 + "react": {
41 + "optional": true
42 + }
43 + },
44 + "devDependencies": {
45 + "@types/react": "^19.0.0",
46 + "typescript": "^5.8.0",
47 + "vitest": "^3.0.0"
48 + }
49 +}
added src/core/KaMap.ts +1038 −0
@@ -0,0 +1,1038 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * KaMap — the framework engine. Owns the Mapbox GL instance, the Ka base
7 + * style, the property source/layers, feature states, selection, the
8 + * viewport→data pipeline and all map event listeners. Apps talk to this
9 + * class (directly or through the React bindings), never to Mapbox GL.
10 + */
11 +
12 +import mapboxgl from "mapbox-gl";
13 +import type {
14 + GeoJSONSource,
15 + Map as MapboxMap,
16 + MapLayerMouseEvent,
17 + MapMouseEvent,
18 + TargetFeature,
19 +} from "mapbox-gl";
20 +
21 +/** Feature reçue par les gestionnaires de clic/survol des couches. */
22 +type LayerFeature = NonNullable<MapLayerMouseEvent["features"]>[number];
23 +import type {
24 + BBox,
25 + KaDataAdapter,
26 + KaMapState,
27 + MapProperty,
28 +} from "../types/index.js";
29 +import { KaEventHub } from "./events.js";
30 +import {
31 + applyKaBasemapConfig,
32 + buildKaStyle,
33 + type KaBasemapOptions,
34 +} from "../styles/kaBaseStyle.js";
35 +import { markerTokens, type KaMapMode, type KaMapTheme } from "../theming/tokens.js";
36 +import {
37 + BoundsQueryScheduler,
38 + type BoundsQueryOptions,
39 +} from "../services/boundsQuery.js";
40 +import {
41 + bboxContains,
42 + bboxOfProperties,
43 + expandBBox,
44 + hashId,
45 + isValidCoordinate,
46 + propertiesToGeoJSON,
47 +} from "../utils/geo.js";
48 +import {
49 + buildClusterProperties,
50 + buildPropertyLayers,
51 + LAYER_IDS,
52 + pillIconExpression,
53 + PROPERTY_SOURCE_ID,
54 + registerPillImages,
55 +} from "../layers/propertyLayer.js";
56 +
57 +export interface KaMapOptions {
58 + container: HTMLElement;
59 + theme: KaMapTheme;
60 + /** Jeton public Mapbox (pk.…) — conçu pour être exposé côté client. */
61 + mapboxToken: string;
62 + mode?: KaMapMode;
63 + adapter?: KaDataAdapter;
64 + center?: { lat: number; lng: number };
65 + zoom?: number;
66 + /** Inclinaison initiale — 3D par défaut (50°), bouton 2D pour revenir. */
67 + pitch?: number;
68 + minZoom?: number;
69 + maxZoom?: number;
70 + /** Réglages du fond Mapbox Standard (réalisme, lumière, repères 3D). */
71 + basemap?: KaBasemapOptions;
72 + /** "auto": refetch after every settled move. "manual": show Search-this-area. */
73 + searchMode?: "auto" | "manual";
74 + query?: BoundsQueryOptions;
75 + /** Cooperative gestures on embedded maps (two-finger pan hint). */
76 + cooperativeGestures?: boolean;
77 + /** Clustering tuning. Lou-Ka style per-building geocoding wants
78 + * maxZoom 15 so stacked units stay grouped as long as possible.
79 + * `valueClamp` bounds each item's contribution to the bubble mean. */
80 + cluster?: { maxZoom?: number; radius?: number; valueClamp?: [number, number] };
81 +}
82 +
83 +const EMPTY_FC: GeoJSON.FeatureCollection = {
84 + type: "FeatureCollection",
85 + features: [],
86 +};
87 +
88 +/** Couches de l'outil « dessiner une zone » (Ka Draw). */
89 +const DRAW_SOURCE_ID = "ka-draw";
90 +const DRAW_LAYER_IDS = {
91 + fill: "ka-draw-fill",
92 + line: "ka-draw-line",
93 + vertex: "ka-draw-vertex",
94 +} as const;
95 +
96 +export class KaMap {
97 + readonly events = new KaEventHub();
98 + readonly map: MapboxMap;
99 +
100 + private theme: KaMapTheme;
101 + private mode: KaMapMode;
102 + private basemap: KaBasemapOptions | undefined;
103 + private scheduler: BoundsQueryScheduler | null = null;
104 + private searchMode: "auto" | "manual";
105 + private filters: Record<string, unknown> | undefined;
106 +
107 + private data: GeoJSON.FeatureCollection = EMPTY_FC;
108 + private byId = new Map<string, { hash: number; property: MapProperty }>();
109 +
110 + private selectedId: string | null = null;
111 + private hoveredId: string | null = null;
112 + private searchedBBox: BBox | null = null;
113 + private searchAreaDirty = false;
114 + private destroyed = false;
115 + private overlayInstalled = false;
116 + private overlayAttempts = 0;
117 + /** Id de source courant — tourne à chaque reconstruction : l'état worker
118 + * d'un id « zombifié » par une recomposition Standard est irrécupérable. */
119 + private sourceId = PROPERTY_SOURCE_ID;
120 + private sourceGen = 0;
121 + private verifyTimer: ReturnType<typeof setTimeout> | null = null;
122 + private clusterOptions: {
123 + maxZoom: number;
124 + radius: number;
125 + valueClamp?: [number, number];
126 + };
127 +
128 + /** Mouvement en cours déclenché par le code (fitBounds, easeTo interne) :
129 + * consommé par handleMoveEnd pour distinguer le geste de l'utilisateur. */
130 + private programmaticMove = false;
131 +
132 + /** Outil de dessin de zone : tracé en cours + polygone posé. */
133 + private drawing = false;
134 + private drawVertices: [number, number][] = [];
135 + private drawnPolygon: [number, number][] | null = null;
136 + private drawCursor: [number, number] | null = null;
137 + private drawKeyHandler: ((e: KeyboardEvent) => void) | null = null;
138 +
139 + /** Annonces déjà consultées — pastilles atténuées (feature-state seen). */
140 + private seenIds: Set<string> = new Set();
141 +
142 + /** Bâtiment mis en évidence (fiche) — featureset "buildings" du Standard. */
143 + private buildingFocus: { lng: number; lat: number } | null = null;
144 + private focusedBuildings: TargetFeature[] = [];
145 + private focusAttempts = 0;
146 + private focusRetryTimer: ReturnType<typeof setTimeout> | null = null;
147 +
148 + constructor(options: KaMapOptions) {
149 + this.theme = options.theme;
150 + this.mode = options.mode ?? "light";
151 + this.basemap = options.basemap;
152 + this.searchMode = options.searchMode ?? "manual";
153 + this.clusterOptions = {
154 + // entier obligatoire : supercluster fait `new Array(maxZoom + 2)` —
155 + // une valeur fractionnaire plante le worker (Invalid array length)
156 + maxZoom: Math.round(options.cluster?.maxZoom ?? 14),
157 + radius: options.cluster?.radius ?? 46,
158 + valueClamp: options.cluster?.valueClamp,
159 + };
160 +
161 + this.map = new mapboxgl.Map({
162 + container: options.container,
163 + accessToken: options.mapboxToken,
164 + style: buildKaStyle(this.mode, this.theme),
165 + center: [options.center?.lng ?? -71.254, options.center?.lat ?? 46.813],
166 + zoom: options.zoom ?? 11,
167 + pitch: options.pitch ?? 50,
168 + minZoom: options.minZoom ?? 4,
169 + maxZoom: options.maxZoom ?? 18.5,
170 + attributionControl: false,
171 + cooperativeGestures: options.cooperativeGestures ?? false,
172 + });
173 + // Attribution repliée (ⓘ) — conforme, discrète ; logo Mapbox conservé.
174 + this.map.addControl(
175 + new mapboxgl.AttributionControl({ compact: true }),
176 + "bottom-right",
177 + );
178 + this.map.addControl(
179 + new mapboxgl.NavigationControl({ showCompass: false }),
180 + "top-right",
181 + );
182 + // Nord en haut : rotation désactivée, l'inclinaison 3D reste permise.
183 + this.map.touchZoomRotate.disableRotation();
184 + this.map.dragRotate.disable();
185 + this.map.keyboard.enable();
186 +
187 + if (options.adapter) {
188 + this.scheduler = new BoundsQueryScheduler(options.adapter, options.query);
189 + this.scheduler.onResult((result) => {
190 + this.setProperties(result.properties);
191 + this.events.emit("data", {
192 + count: result.properties.length,
193 + totalCount: result.totalCount,
194 + });
195 + });
196 + this.scheduler.onError((error) =>
197 + this.events.emit("error", { scope: "query", error }),
198 + );
199 + this.scheduler.onLoading((loading) =>
200 + this.events.emit("loading", { loading }),
201 + );
202 + }
203 +
204 + // Poignée de débogage/tests E2E (comme l'ancien window._loukaMap).
205 + (globalThis as { __kaMap?: KaMap }).__kaMap = this;
206 +
207 + // S'assurer que l'attribution démarre repliée (bouton ⓘ).
208 + this.map.once("load", () => {
209 + const attrib = options.container.querySelector(".mapboxgl-ctrl-attrib");
210 + attrib?.classList.remove("mapboxgl-compact-show");
211 + attrib?.removeAttribute("open");
212 + });
213 +
214 + // Mapbox Standard est un style à imports : ses recompositions peuvent
215 + // « zombifier » une source ajoutée au mauvais moment (l'objet survit,
216 + // le worker ne traite plus rien). Stratégie : installer l'overlay au
217 + // chargement, appliquer la config du basemap APRÈS, puis VÉRIFIER que
218 + // des features sont réellement traitées — sinon on détruit et on
219 + // reconstruit la source et les couches (plafonné).
220 + this.map.once("load", () => {
221 + this.installOverlay();
222 + this.map.once("idle", () =>
223 + applyKaBasemapConfig(this.map, this.mode, this.basemap),
224 + );
225 + });
226 + this.map.on("styledata", () => {
227 + if (!this.overlayInstalled) return;
228 + if (!this.map.getSource(this.sourceId) || !this.map.getLayer(LAYER_IDS.pointPill)) {
229 + this.map.once("idle", () => this.rebuildOverlay());
230 + }
231 + // Une recomposition du Standard peut perdre l'état du featureset
232 + // buildings : réappliquer le focus (idempotent, peu coûteux).
233 + if (this.buildingFocus) {
234 + this.focusedBuildings = [];
235 + this.scheduleBuildingFocus();
236 + }
237 + });
238 + // Tout geste direct annule le marquage « mouvement programmé » : si
239 + // l'utilisateur interrompt un fitBounds, le moveend redevient le sien.
240 + for (const gesture of ["dragstart", "wheel", "boxzoomstart", "dblclick"] as const) {
241 + this.map.on(gesture, () => {
242 + this.programmaticMove = false;
243 + });
244 + }
245 + this.map.on("touchmove", () => {
246 + this.programmaticMove = false;
247 + });
248 + this.map.on("moveend", () => this.handleMoveEnd());
249 + this.map.on("error", (e) => {
250 + // Garder la trace en console (MapLibre se tairait dès qu'un handler
251 + // existe — les couches invalides deviendraient indétectables).
252 + console.warn("[ka-maps]", e.error);
253 + this.events.emit("error", { scope: "tiles", error: e.error });
254 + });
255 +
256 + this.map.on("click", (e) => this.handleBaseClick(e));
257 + this.map.on("mousemove", (e) => {
258 + if (this.drawing && this.drawVertices.length > 0) {
259 + this.drawCursor = [e.lngLat.lng, e.lngLat.lat];
260 + this.updateDrawSource();
261 + }
262 + });
263 + this.bindLayerInteractions();
264 + }
265 +
266 + // ---------------------------------------------------------------- overlay
267 +
268 + /** (Re)install property source + layers — self-healing after any style
269 + * recomposition (Mapbox Standard imports). */
270 + private installOverlay(): void {
271 + if (this.destroyed) return;
272 + const map = this.map;
273 + registerPillImages(map, this.theme);
274 + this.overlayInstalled = true;
275 +
276 + if (!map.getSource(this.sourceId)) {
277 + map.addSource(this.sourceId, {
278 + type: "geojson",
279 + data: this.data,
280 + cluster: true,
281 + clusterMaxZoom: this.clusterOptions.maxZoom,
282 + clusterRadius: this.clusterOptions.radius,
283 + clusterProperties: buildClusterProperties(
284 + this.clusterOptions.valueClamp,
285 + ) as never,
286 + // (pas de promoteId : les ids de features sont déjà des numériques
287 + // hachés par propertiesToGeoJSON)
288 + });
289 + }
290 + for (const layer of buildPropertyLayers(this.theme, this.sourceId)) {
291 + if (!map.getLayer(layer.id)) map.addLayer(layer);
292 + }
293 + this.installDrawLayers();
294 + this.applyFeatureStates();
295 + this.scheduleOverlayVerify();
296 + }
297 +
298 + /** Détruit et réinstalle la source + les couches (source zombie). */
299 + private rebuildOverlay(): void {
300 + if (this.destroyed) return;
301 + const map = this.map;
302 + try {
303 + for (const id of Object.values(LAYER_IDS)) {
304 + if (map.getLayer(id)) map.removeLayer(id);
305 + }
306 + if (map.getSource(this.sourceId)) map.removeSource(this.sourceId);
307 + } catch {
308 + // style en transition : la prochaine vérification retentera
309 + }
310 + this.sourceGen++;
311 + this.sourceId = `${PROPERTY_SOURCE_ID}-r${this.sourceGen}`;
312 + this.installOverlay();
313 + }
314 +
315 + /** Vérifie que le worker traite bien la source ; sinon, reconstruit. */
316 + private scheduleOverlayVerify(): void {
317 + if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);
318 + this.verifyTimer = setTimeout(() => {
319 + this.verifyTimer = null;
320 + if (this.destroyed || this.data.features.length === 0) return;
321 + let processed = 0;
322 + try {
323 + processed = this.map.querySourceFeatures(this.sourceId).length;
324 + } catch {
325 + return;
326 + }
327 + if (processed > 0) {
328 + this.overlayAttempts = 0;
329 + return;
330 + }
331 + if (this.overlayAttempts >= 6) return;
332 + this.overlayAttempts++;
333 + console.warn(
334 + `[ka-maps] source non traitée par le worker — reconstruction (${this.overlayAttempts}/6)`,
335 + );
336 + this.rebuildOverlay();
337 + }, 1200);
338 + }
339 +
340 + private bindLayerInteractions(): void {
341 + const map = this.map;
342 +
343 + map.on("click", LAYER_IDS.clusters, (e) => this.expandCluster(e));
344 + map.on("click", LAYER_IDS.pointPill, (e) => this.clickProperty(e));
345 + map.on("click", LAYER_IDS.pointDot, (e) => this.clickProperty(e));
346 +
347 + for (const id of [LAYER_IDS.clusters, LAYER_IDS.pointPill, LAYER_IDS.pointDot]) {
348 + map.on("mouseenter", id, () => {
349 + map.getCanvas().style.cursor = "pointer";
350 + });
351 + map.on("mouseleave", id, () => {
352 + map.getCanvas().style.cursor = "";
353 + });
354 + }
355 +
356 + map.on("mousemove", LAYER_IDS.pointPill, (e) => this.hoverFrom(e));
357 + map.on("mousemove", LAYER_IDS.pointDot, (e) => this.hoverFrom(e));
358 + map.on("mouseleave", LAYER_IDS.pointPill, () => this.setHovered(null, "map"));
359 + map.on("mouseleave", LAYER_IDS.pointDot, () => this.setHovered(null, "map"));
360 +
361 + // Survol d'un cluster : fourchette de prix du contenu (tooltip app).
362 + map.on("mousemove", LAYER_IDS.clusters, (e) => {
363 + const f = e.features?.[0];
364 + if (!f) return;
365 + const props = f.properties ?? {};
366 + const count = (props["point_count"] as number | undefined) ?? 0;
367 + const rawMin = props["valueMin"] as number | undefined;
368 + const rawMax = props["valueMax"] as number | undefined;
369 + // 99999999 / 0 sont les sentinelles des agrégats pour les valeurs
370 + // nulles (voir buildClusterProperties) — jamais de vrais loyers.
371 + const min =
372 + typeof rawMin === "number" && Number.isFinite(rawMin) && rawMin < 99999999
373 + ? rawMin
374 + : null;
375 + const max =
376 + typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0
377 + ? rawMax
378 + : null;
379 + this.events.emit("clusterHover", {
380 + info: { count, min, max, x: e.point.x, y: e.point.y },
381 + });
382 + });
383 + map.on("mouseleave", LAYER_IDS.clusters, () =>
384 + this.events.emit("clusterHover", { info: null }),
385 + );
386 + }
387 +
388 + private featureId(feature: LayerFeature): string | null {
389 + const raw = feature.properties?.["id"];
390 + return typeof raw === "string" ? raw : null;
391 + }
392 +
393 + private expandCluster(e: MapLayerMouseEvent): void {
394 + if (this.drawing) return;
395 + const feature = e.features?.[0];
396 + if (!feature) return;
397 + const clusterId = feature.properties?.["cluster_id"] as number | undefined;
398 + const count = (feature.properties?.["point_count"] as number | undefined) ?? 0;
399 + const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;
400 + if (clusterId === undefined || !source) return;
401 + source.getClusterExpansionZoom(clusterId, (err, zoom) => {
402 + if (this.destroyed || err || zoom == null) return;
403 + const [lng, lat] = (feature.geometry as GeoJSON.Point).coordinates;
404 + // Le clic sur un cluster est un geste de l'utilisateur : le moveend
405 + // qui suit doit compter comme tel (liste ajustée en conséquence).
406 + this.programmaticMove = false;
407 + this.map.easeTo({
408 + center: [lng as number, lat as number],
409 + zoom: Math.min(zoom + 0.25, this.map.getMaxZoom()),
410 + duration: 480,
411 + });
412 + this.events.emit("clusterExpand", { count });
413 + });
414 + }
415 +
416 + private clickProperty(e: MapLayerMouseEvent): void {
417 + if (this.drawing) return;
418 + const feature = e.features?.[0];
419 + if (!feature) return;
420 + const id = this.featureId(feature);
421 + if (id) this.select(id, "map");
422 + }
423 +
424 + private handleBaseClick(e: MapMouseEvent): void {
425 + // Mode dessin : chaque clic pose un sommet ; un clic près du premier
426 + // sommet (≥ 3 posés) ferme le polygone.
427 + if (this.drawing) {
428 + this.addDrawVertex(e);
429 + return;
430 + }
431 + // Clicks that hit property layers are handled there; a bare map click
432 + // clears the selection.
433 + const hits = this.map.queryRenderedFeatures(e.point, {
434 + layers: [LAYER_IDS.pointPill, LAYER_IDS.pointDot, LAYER_IDS.clusters].filter(
435 + (l) => Boolean(this.map.getLayer(l)),
436 + ),
437 + });
438 + if (hits.length === 0 && this.selectedId) this.select(null, "map");
439 + }
440 +
441 + private hoverFrom(e: MapLayerMouseEvent): void {
442 + const feature = e.features?.[0];
443 + const id = feature ? this.featureId(feature) : null;
444 + this.setHovered(id, "map");
445 + }
446 +
447 + // ------------------------------------------------------------------ data
448 +
449 + /** Replace the rendered property set (adapter results or app-pushed). */
450 + setProperties(properties: MapProperty[]): void {
451 + this.byId.clear();
452 + for (const p of properties) {
453 + if (!isValidCoordinate(p.latitude, p.longitude)) continue;
454 + this.byId.set(p.id, { hash: hashId(p.id), property: p });
455 + }
456 + this.data = propertiesToGeoJSON(properties);
457 + const source = this.map.getSource(this.sourceId) as GeoJSONSource | undefined;
458 + if (source) source.setData(this.data as never);
459 + // Keep selection if the item is still visible, otherwise drop it.
460 + if (this.selectedId && !this.byId.has(this.selectedId)) this.select(null, "app");
461 + this.applyFeatureStates();
462 + if (this.overlayInstalled) this.scheduleOverlayVerify();
463 + }
464 +
465 + getProperty(id: string): MapProperty | undefined {
466 + return this.byId.get(id)?.property;
467 + }
468 +
469 + /** Current filters forwarded to the adapter on every query. */
470 + setFilters(filters: Record<string, unknown> | undefined): void {
471 + this.filters = filters;
472 + this.scheduler?.invalidate();
473 + this.refetch();
474 + }
475 +
476 + /** Fetch data for the current viewport immediately (Search this area). */
477 + searchThisArea(): void {
478 + this.refetch();
479 + }
480 +
481 + refetch(): void {
482 + if (!this.scheduler) return;
483 + const bbox = this.currentBBox();
484 + if (!bbox) return;
485 + this.searchedBBox = bbox;
486 + this.setDirty(false);
487 + this.scheduler.requestNow({ bbox, zoom: this.map.getZoom(), filters: this.filters });
488 + }
489 +
490 + setSearchMode(mode: "auto" | "manual"): void {
491 + this.searchMode = mode;
492 + if (mode === "auto" && this.searchAreaDirty) this.refetch();
493 + }
494 +
495 + getSearchMode(): "auto" | "manual" {
496 + return this.searchMode;
497 + }
498 +
499 + private handleMoveEnd(): void {
500 + const center = this.map.getCenter();
501 + const byUser = !this.programmaticMove;
502 + this.programmaticMove = false;
503 + this.events.emit("moveend", {
504 + center: { lat: center.lat, lng: center.lng },
505 + zoom: this.map.getZoom(),
506 + byUser,
507 + });
508 + if (!this.scheduler) return;
509 +
510 + const bbox = this.currentBBox();
511 + if (!bbox) return;
512 +
513 + if (this.searchMode === "auto") {
514 + this.searchedBBox = bbox;
515 + this.setDirty(false);
516 + this.scheduler.request({ bbox, zoom: this.map.getZoom(), filters: this.filters });
517 + return;
518 + }
519 + // Manual mode: flag divergence, let the app show "Search this area".
520 + if (!this.searchedBBox) {
521 + this.refetch(); // first load
522 + return;
523 + }
524 + const tolerant = expandBBox(this.searchedBBox, 0.15);
525 + this.setDirty(!bboxContains(tolerant, bbox));
526 + }
527 +
528 + private setDirty(dirty: boolean): void {
529 + if (this.searchAreaDirty === dirty) return;
530 + this.searchAreaDirty = dirty;
531 + this.events.emit("searchAreaDirty", { dirty });
532 + }
533 +
534 + // ------------------------------------------------------------- selection
535 +
536 + /** Select from map click or app (card click). Pass null to clear.
537 + * Selecting the already-selected id is a no-op (prevents feedback loops
538 + * when apps mirror the selection back declaratively). */
539 + select(id: string | null, origin: "map" | "app"): void {
540 + if (id === this.selectedId) return;
541 + const previous = this.selectedId;
542 + this.selectedId = id;
543 + if (previous) this.setFeatureState(previous, { selected: false });
544 + if (id) this.setFeatureState(id, { selected: true });
545 + this.refreshPillSelection();
546 + this.events.emit("select", { propertyId: id, origin });
547 +
548 + // Card-driven selection: reveal the item without a jarring recenter.
549 + if (origin === "app" && id) {
550 + const entry = this.byId.get(id);
551 + if (entry) {
552 + const { latitude, longitude } = entry.property;
553 + const bounds = this.map.getBounds();
554 + if (bounds && !bounds.contains([longitude, latitude])) {
555 + this.programmaticMove = true;
556 + this.map.easeTo({ center: [longitude, latitude], duration: 420 });
557 + }
558 + }
559 + }
560 + }
561 +
562 + getSelectedId(): string | null {
563 + return this.selectedId;
564 + }
565 +
566 + setHovered(id: string | null, origin: "map" | "app"): void {
567 + if (id === this.hoveredId) return;
568 + if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: false });
569 + this.hoveredId = id;
570 + if (id) this.setFeatureState(id, { hovered: true });
571 + if (origin === "map") this.events.emit("hover", { propertyId: id });
572 + }
573 +
574 + /** Dim everything except the given ids (Ka Lens, filter emphasis). */
575 + setDimmedExcept(ids: Set<string> | null): void {
576 + for (const [id, entry] of this.byId) {
577 + this.map.setFeatureState(
578 + { source: this.sourceId, id: entry.hash },
579 + { dimmed: ids !== null && !ids.has(id) },
580 + );
581 + }
582 + }
583 +
584 + /** Annonces déjà consultées : pastilles atténuées (état « vu »). */
585 + setSeenIds(ids: Iterable<string>): void {
586 + this.seenIds = new Set(ids);
587 + this.applySeenStates();
588 + }
589 +
590 + private applySeenStates(): void {
591 + if (this.seenIds.size === 0) return;
592 + for (const [id, entry] of this.byId) {
593 + if (!this.seenIds.has(id)) continue;
594 + try {
595 + this.map.setFeatureState(
596 + { source: this.sourceId, id: entry.hash },
597 + { seen: true },
598 + );
599 + } catch {
600 + // source en transition — réappliqué par applyFeatureStates
601 + }
602 + }
603 + }
604 +
605 + private setFeatureState(id: string, state: Record<string, boolean>): void {
606 + const entry = this.byId.get(id);
607 + if (!entry) return;
608 + try {
609 + this.map.setFeatureState({ source: this.sourceId, id: entry.hash }, state);
610 + } catch {
611 + // Source may be mid-reload during a style swap; states reapply after.
612 + }
613 + }
614 +
615 + private applyFeatureStates(): void {
616 + if (this.selectedId) this.setFeatureState(this.selectedId, { selected: true });
617 + if (this.hoveredId) this.setFeatureState(this.hoveredId, { hovered: true });
618 + this.applySeenStates();
619 + this.refreshPillSelection();
620 + }
621 +
622 + /** icon-image est une propriété layout (feature-state interdit) : la
623 + * pastille sélectionnée est réinjectée dans l'expression au besoin. */
624 + private refreshPillSelection(): void {
625 + if (!this.map.getLayer(LAYER_IDS.pointPill)) return;
626 + try {
627 + this.map.setLayoutProperty(
628 + LAYER_IDS.pointPill,
629 + "icon-image",
630 + pillIconExpression(this.selectedId),
631 + );
632 + } catch {
633 + // style en cours de rechargement — réappliqué par installOverlay
634 + }
635 + }
636 +
637 + // ----------------------------------------------------------------- dessin
638 +
639 + /** Démarre le tracé d'une zone : chaque clic pose un sommet, un clic sur
640 + * le premier sommet (≥ 3) ferme le polygone, Échap annule. */
641 + startDraw(): void {
642 + if (this.drawing) return;
643 + this.drawing = true;
644 + this.drawVertices = [];
645 + this.drawCursor = null;
646 + this.drawnPolygon = null;
647 + this.map.doubleClickZoom.disable();
648 + this.map.getCanvas().style.cursor = "crosshair";
649 + this.drawKeyHandler = (e: KeyboardEvent) => {
650 + if (e.key === "Escape") this.cancelDraw();
651 + };
652 + window.addEventListener("keydown", this.drawKeyHandler);
653 + this.updateDrawSource();
654 + this.events.emit("draw", { polygon: null, drawing: true });
655 + }
656 +
657 + /** Annule le tracé en cours (Échap ou bouton). */
658 + cancelDraw(): void {
659 + if (!this.drawing) return;
660 + this.endDrawMode();
661 + this.drawVertices = [];
662 + this.drawCursor = null;
663 + this.updateDrawSource();
664 + this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });
665 + }
666 +
667 + /** Efface le polygone posé (retrait de la puce « Zone dessinée »). */
668 + clearDrawnPolygon(): void {
669 + if (this.drawing) this.endDrawMode();
670 + this.drawing = false;
671 + this.drawVertices = [];
672 + this.drawCursor = null;
673 + this.drawnPolygon = null;
674 + this.updateDrawSource();
675 + this.events.emit("draw", { polygon: null, drawing: false });
676 + }
677 +
678 + /** Restaure un polygone (URL partagée) sans passer par le tracé. */
679 + setDrawnPolygon(polygon: [number, number][] | null): void {
680 + this.drawnPolygon = polygon && polygon.length >= 3 ? polygon : null;
681 + this.updateDrawSource();
682 + this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });
683 + }
684 +
685 + getDrawnPolygon(): [number, number][] | null {
686 + return this.drawnPolygon;
687 + }
688 +
689 + isDrawing(): boolean {
690 + return this.drawing;
691 + }
692 +
693 + private endDrawMode(): void {
694 + this.drawing = false;
695 + this.map.doubleClickZoom.enable();
696 + this.map.getCanvas().style.cursor = "";
697 + if (this.drawKeyHandler) {
698 + window.removeEventListener("keydown", this.drawKeyHandler);
699 + this.drawKeyHandler = null;
700 + }
701 + }
702 +
703 + private addDrawVertex(e: MapMouseEvent): void {
704 + const first = this.drawVertices[0];
705 + if (first && this.drawVertices.length >= 3) {
706 + const firstPt = this.map.project(first);
707 + const dx = firstPt.x - e.point.x;
708 + const dy = firstPt.y - e.point.y;
709 + if (Math.hypot(dx, dy) < 14) {
710 + // Fermeture : clic sur le premier sommet.
711 + this.drawnPolygon = [...this.drawVertices];
712 + this.endDrawMode();
713 + this.drawCursor = null;
714 + this.updateDrawSource();
715 + this.events.emit("draw", { polygon: this.drawnPolygon, drawing: false });
716 + return;
717 + }
718 + }
719 + this.drawVertices.push([e.lngLat.lng, e.lngLat.lat]);
720 + this.updateDrawSource();
721 + }
722 +
723 + private drawFeatureCollection(): GeoJSON.FeatureCollection {
724 + const features: GeoJSON.Feature[] = [];
725 + if (this.drawnPolygon) {
726 + const ring = [...this.drawnPolygon, this.drawnPolygon[0] as [number, number]];
727 + features.push({
728 + type: "Feature",
729 + properties: { role: "zone" },
730 + geometry: { type: "Polygon", coordinates: [ring] },
731 + });
732 + } else if (this.drawVertices.length > 0) {
733 + const line = this.drawCursor
734 + ? [...this.drawVertices, this.drawCursor]
735 + : [...this.drawVertices];
736 + if (line.length >= 2) {
737 + features.push({
738 + type: "Feature",
739 + properties: { role: "trace" },
740 + geometry: { type: "LineString", coordinates: line },
741 + });
742 + }
743 + for (const v of this.drawVertices) {
744 + features.push({
745 + type: "Feature",
746 + properties: { role: "sommet" },
747 + geometry: { type: "Point", coordinates: v },
748 + });
749 + }
750 + }
751 + return { type: "FeatureCollection", features };
752 + }
753 +
754 + private updateDrawSource(): void {
755 + const source = this.map.getSource(DRAW_SOURCE_ID) as GeoJSONSource | undefined;
756 + if (source) source.setData(this.drawFeatureCollection() as never);
757 + }
758 +
759 + /** Couches du dessin — réinstallées avec l'overlay (styledata). */
760 + private installDrawLayers(): void {
761 + const map = this.map;
762 + const accent = markerTokens(this.theme, "highlight").background;
763 + if (!map.getSource(DRAW_SOURCE_ID)) {
764 + map.addSource(DRAW_SOURCE_ID, {
765 + type: "geojson",
766 + data: this.drawFeatureCollection(),
767 + });
768 + }
769 + if (!map.getLayer(DRAW_LAYER_IDS.fill)) {
770 + map.addLayer({
771 + id: DRAW_LAYER_IDS.fill,
772 + slot: "top",
773 + type: "fill",
774 + source: DRAW_SOURCE_ID,
775 + filter: ["==", ["geometry-type"], "Polygon"],
776 + paint: { "fill-color": accent, "fill-opacity": 0.08 },
777 + });
778 + }
779 + if (!map.getLayer(DRAW_LAYER_IDS.line)) {
780 + map.addLayer({
781 + id: DRAW_LAYER_IDS.line,
782 + slot: "top",
783 + type: "line",
784 + source: DRAW_SOURCE_ID,
785 + filter: ["!=", ["geometry-type"], "Point"],
786 + paint: {
787 + "line-color": accent,
788 + "line-width": 2.25,
789 + "line-dasharray": [
790 + "case",
791 + ["==", ["get", "role"], "trace"],
792 + ["literal", [2, 1.6]],
793 + ["literal", [1, 0]],
794 + ] as never,
795 + },
796 + });
797 + }
798 + if (!map.getLayer(DRAW_LAYER_IDS.vertex)) {
799 + map.addLayer({
800 + id: DRAW_LAYER_IDS.vertex,
801 + slot: "top",
802 + type: "circle",
803 + source: DRAW_SOURCE_ID,
804 + filter: ["==", ["geometry-type"], "Point"],
805 + paint: {
806 + "circle-radius": 5,
807 + "circle-color": "#ffffff",
808 + "circle-stroke-color": accent,
809 + "circle-stroke-width": 2,
810 + },
811 + });
812 + }
813 + }
814 +
815 + // ------------------------------------------------------ building spotlight
816 +
817 + /**
818 + * Met en évidence le bâtiment situé aux coordonnées données (fiche d'une
819 + * propriété) : l'empreinte 3D du featureset « buildings » de Mapbox
820 + * Standard passe à l'état `select`, coloré via `colorBuildingSelect`
821 + * (option `basemap` ou `opts.color`). Passer `null` pour effacer.
822 + * Résilient : re-tenté tant que les tuiles ne sont pas rendues, et
823 + * réappliqué après chaque recomposition du style.
824 + */
825 + focusBuilding(
826 + at: { lng: number; lat: number } | null,
827 + opts?: { color?: string },
828 + ): void {
829 + for (const f of this.focusedBuildings) {
830 + try {
831 + this.map.setFeatureState(f, { select: false });
832 + } catch {
833 + // style en transition — l'état disparaît avec lui
834 + }
835 + }
836 + this.focusedBuildings = [];
837 + this.focusAttempts = 0;
838 + if (this.focusRetryTimer !== null) {
839 + clearTimeout(this.focusRetryTimer);
840 + this.focusRetryTimer = null;
841 + }
842 + this.buildingFocus = at;
843 + if (!at) return;
844 + if (opts?.color) {
845 + try {
846 + this.map.setConfigProperty("basemap", "colorBuildingSelect", opts.color);
847 + } catch {
848 + // style pas encore chargé : la couleur viendra de applyKaBasemapConfig
849 + }
850 + }
851 + this.scheduleBuildingFocus();
852 + }
853 +
854 + private scheduleBuildingFocus(): void {
855 + if (!this.buildingFocus || this.destroyed) return;
856 + if (this.map.isStyleLoaded() && this.map.loaded()) {
857 + this.applyBuildingFocus();
858 + } else {
859 + this.map.once("idle", () => this.applyBuildingFocus());
860 + }
861 + }
862 +
863 + private applyBuildingFocus(): void {
864 + const focus = this.buildingFocus;
865 + if (!focus || this.destroyed || this.focusedBuildings.length > 0) return;
866 +
867 + const pt = this.map.project([focus.lng, focus.lat]);
868 + // Le point géocodé tombe parfois sur la rue devant l'immeuble : on
869 + // interroge une boîte serrée, puis on élargit progressivement.
870 + const pads = [4, 14, 34];
871 + let found: TargetFeature[] = [];
872 + for (const pad of pads) {
873 + try {
874 + found = this.map.queryRenderedFeatures(
875 + [
876 + [pt.x - pad, pt.y - pad],
877 + [pt.x + pad, pt.y + pad],
878 + ],
879 + { target: { featuresetId: "buildings", importId: "basemap" } },
880 + );
881 + } catch {
882 + found = [];
883 + }
884 + if (found.length > 0) break;
885 + }
886 +
887 + if (found.length === 0) {
888 + // Tuiles vecteur pas encore prêtes, ou zone sans empreinte de
889 + // bâtiment : quelques re-tentatives espacées, puis on abandonne
890 + // proprement (le marqueur de prix reste le repère).
891 + if (this.focusAttempts++ < 8) {
892 + this.focusRetryTimer = setTimeout(() => {
893 + this.focusRetryTimer = null;
894 + this.scheduleBuildingFocus();
895 + }, 400);
896 + }
897 + return;
898 + }
899 +
900 + // Un immeuble = souvent plusieurs morceaux d'empreinte : on sélectionne
901 + // toutes les parties partageant l'id de la plus proche du point.
902 + const primary = found[0];
903 + if (!primary) return;
904 + const primaryId = primary.id;
905 + const parts = found.filter((f) => f.id === primaryId);
906 + for (const f of parts) {
907 + try {
908 + this.map.setFeatureState(f, { select: true });
909 + } catch {
910 + // recomposition en cours : le handler styledata re-planifiera
911 + }
912 + }
913 + this.focusedBuildings = parts;
914 + this.events.emit("buildingFocus", { found: true });
915 + }
916 +
917 + /** Recentre la caméra en douceur (fiche, spotlight, deep-link). */
918 + flyTo(
919 + center: { lng: number; lat: number },
920 + opts?: { zoom?: number; pitch?: number; bearing?: number; duration?: number },
921 + ): void {
922 + this.programmaticMove = true;
923 + this.map.easeTo({
924 + center: [center.lng, center.lat],
925 + zoom: opts?.zoom,
926 + pitch: opts?.pitch,
927 + bearing: opts?.bearing,
928 + duration: opts?.duration ?? 900,
929 + });
930 + }
931 +
932 + /** Cadre l'emprise donnée avec une animation douce. Mouvement programmé :
933 + * le moveend qui suit est émis avec `byUser: false` (la vue n'est pas
934 + * « volée » à l'utilisateur au sens de la synchro liste↔carte). */
935 + fitBounds(
936 + bbox: BBox,
937 + opts?: { padding?: number; maxZoom?: number; duration?: number },
938 + ): void {
939 + this.programmaticMove = true;
940 + this.map.fitBounds(
941 + [
942 + [bbox.west, bbox.south],
943 + [bbox.east, bbox.north],
944 + ],
945 + {
946 + padding: opts?.padding ?? 56,
947 + maxZoom: opts?.maxZoom ?? 16,
948 + duration: opts?.duration ?? 850,
949 + },
950 + );
951 + }
952 +
953 + /** Cadre l'ensemble des propriétés affichées (ou fournies). */
954 + fitToProperties(
955 + properties?: MapProperty[],
956 + opts?: { padding?: number; maxZoom?: number; duration?: number },
957 + ): void {
958 + const list = properties ?? [...this.byId.values()].map((e) => e.property);
959 + const bbox = bboxOfProperties(list);
960 + if (bbox) this.fitBounds(bbox, opts);
961 + }
962 +
963 + // ---------------------------------------------------------------- theming
964 +
965 + /** Vue 3D optionnelle : incline la caméra (les volumes existent déjà). */
966 + setTilt(on: boolean): void {
967 + this.programmaticMove = true;
968 + this.map.easeTo({ pitch: on ? 55 : 0, duration: 600 });
969 + }
970 +
971 + isTilted(): boolean {
972 + return this.map.getPitch() > 5;
973 + }
974 +
975 + setMode(mode: KaMapMode): void {
976 + if (mode === this.mode) return;
977 + this.mode = mode;
978 + // Jour/nuit via la config du style Standard — aucun rechargement.
979 + applyKaBasemapConfig(this.map, this.mode, this.basemap);
980 + }
981 +
982 + getMode(): KaMapMode {
983 + return this.mode;
984 + }
985 +
986 + getTheme(): KaMapTheme {
987 + return this.theme;
988 + }
989 +
990 + // ------------------------------------------------------------------ state
991 +
992 + currentBBox(): BBox | null {
993 + const b = this.map.getBounds();
994 + if (!b) return null;
995 + return {
996 + west: b.getWest(),
997 + south: b.getSouth(),
998 + east: b.getEast(),
999 + north: b.getNorth(),
1000 + };
1001 + }
1002 +
1003 + getState(): KaMapState {
1004 + const center = this.map.getCenter();
1005 + return {
1006 + center: { lat: center.lat, lng: center.lng },
1007 + zoom: this.map.getZoom(),
1008 + bearing: this.map.getBearing(),
1009 + pitch: this.map.getPitch(),
1010 + bounds: this.currentBBox(),
1011 + selectedPropertyId: this.selectedId,
1012 + hoveredPropertyId: this.hoveredId,
1013 + activeLayers: Object.values(LAYER_IDS).filter((l) =>
1014 + Boolean(this.map.getLayer(l)),
1015 + ),
1016 + searchAreaDirty: this.searchAreaDirty,
1017 + drawnGeometry: this.drawnPolygon
1018 + ? {
1019 + type: "Polygon",
1020 + coordinates: [[...this.drawnPolygon, this.drawnPolygon[0] as [number, number]]],
1021 + }
1022 + : null,
1023 + };
1024 + }
1025 +
1026 + destroy(): void {
1027 + this.destroyed = true;
1028 + if (this.drawKeyHandler) {
1029 + window.removeEventListener("keydown", this.drawKeyHandler);
1030 + this.drawKeyHandler = null;
1031 + }
1032 + if (this.verifyTimer !== null) clearTimeout(this.verifyTimer);
1033 + if (this.focusRetryTimer !== null) clearTimeout(this.focusRetryTimer);
1034 + this.scheduler?.destroy();
1035 + this.events.clear();
1036 + this.map.remove();
1037 + }
1038 +}
added src/core/events.ts +69 −0
@@ -0,0 +1,69 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Tiny typed event hub. All MapLibre listeners are centralized in KaMap
7 + * and re-emitted here so app components never attach raw map listeners
8 + * (and cleanup happens in exactly one place).
9 + */
10 +
11 +export type KaEventMap = {
12 + /** Camera settled after user interaction or animation.
13 + * `byUser` distingue le geste (drag, molette, pincement, boutons de
14 + * navigation) d'un mouvement programmé (fitBounds, easeTo interne) —
15 + * c'est ce qui permet de « respecter l'intention de l'utilisateur ». */
16 + moveend: { center: { lat: number; lng: number }; zoom: number; byUser: boolean };
17 + /** Viewport diverged from the last searched area. */
18 + searchAreaDirty: { dirty: boolean };
19 + /** A property was selected (click) or deselected (null). */
20 + select: { propertyId: string | null; origin: "map" | "app" };
21 + /** Hover changed (desktop only). */
22 + hover: { propertyId: string | null };
23 + /** A cluster was clicked; the map is easing to expansion zoom. */
24 + clusterExpand: { count: number };
25 + /** Data finished loading into the property source. */
26 + data: { count: number; totalCount?: number };
27 + /** Loading state of the bounds query pipeline. */
28 + loading: { loading: boolean };
29 + /** Non-fatal error (tiles, query…) the app may surface. */
30 + error: { scope: "tiles" | "query" | "style"; error: unknown };
31 + /** Le spotlight bâtiment (focusBuilding) a trouvé son empreinte 3D. */
32 + buildingFocus: { found: boolean };
33 + /** Outil de dessin : état courant (en cours de tracé et/ou polygone posé).
34 + * `polygon` est un anneau [lng, lat][] fermé implicitement (≥ 3 sommets). */
35 + draw: { polygon: [number, number][] | null; drawing: boolean };
36 + /** Survol d'une bulle de cluster — fourchette de prix + position écran. */
37 + clusterHover: {
38 + info: { count: number; min: number | null; max: number | null; x: number; y: number } | null;
39 + };
40 +};
41 +
42 +type Handler<T> = (payload: T) => void;
43 +
44 +export class KaEventHub {
45 + private handlers = new Map<keyof KaEventMap, Set<Handler<never>>>();
46 +
47 + on<K extends keyof KaEventMap>(
48 + event: K,
49 + handler: Handler<KaEventMap[K]>,
50 + ): () => void {
51 + let set = this.handlers.get(event);
52 + if (!set) {
53 + set = new Set();
54 + this.handlers.set(event, set);
55 + }
56 + set.add(handler as Handler<never>);
57 + return () => set?.delete(handler as Handler<never>);
58 + }
59 +
60 + emit<K extends keyof KaEventMap>(event: K, payload: KaEventMap[K]): void {
61 + const set = this.handlers.get(event);
62 + if (!set) return;
63 + for (const handler of set) (handler as Handler<KaEventMap[K]>)(payload);
64 + }
65 +
66 + clear(): void {
67 + this.handlers.clear();
68 + }
69 +}
added src/index.ts +81 −0
@@ -0,0 +1,81 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Ka Maps — framework by Groupe Ka.
7 + * Public API surface (framework core; React bindings under ./react).
8 + */
9 +
10 +export * from "./types/index.js";
11 +export { KaMap, type KaMapOptions } from "./core/KaMap.js";
12 +export { KaEventHub, type KaEventMap } from "./core/events.js";
13 +export {
14 + BoundsQueryScheduler,
15 + stableStringify,
16 + type BoundsQueryOptions,
17 + type BoundsRequest,
18 +} from "./services/boundsQuery.js";
19 +export {
20 + formatCompactPrice,
21 + formatFullPrice,
22 + formatPercent,
23 + median,
24 +} from "./utils/format.js";
25 +export {
26 + bboxContains,
27 + bboxContainsPoint,
28 + bboxOfProperties,
29 + bboxToString,
30 + expandBBox,
31 + hashId,
32 + haversineMeters,
33 + isValidCoordinate,
34 + parseBBox,
35 + pointInPolygon,
36 + propertiesToGeoJSON,
37 + roundBBox,
38 +} from "./utils/geo.js";
39 +export {
40 + computeLensStats,
41 + propertiesInBBox,
42 + propertiesInPolygon,
43 +} from "./utils/lens.js";
44 +export {
45 + cameraFromParams,
46 + cameraToParams,
47 + type CameraUrlState,
48 +} from "./utils/url.js";
49 +export {
50 + KA_DARK_PALETTE,
51 + KA_LIGHT_PALETTE,
52 + markerTokens,
53 + resolvePalette,
54 + type BasemapPalette,
55 + type KaMapMode,
56 + type KaMapTheme,
57 + type MarkerStyleTokens,
58 +} from "./theming/tokens.js";
59 +export {
60 + applyKaBasemapConfig,
61 + buildKaStyle,
62 + KA_ATTRIBUTION,
63 + KA_STYLE_URL,
64 + type KaBasemapOptions,
65 +} from "./styles/kaBaseStyle.js";
66 +export {
67 + buildClusterProperties,
68 + buildPropertyLayers,
69 + compactPriceExpression,
70 + CLUSTER_PROPERTIES,
71 + LAYER_IDS,
72 + pillIconExpression,
73 + pillImageId,
74 + PROPERTY_SOURCE_ID,
75 + registerPillImages,
76 +} from "./layers/propertyLayer.js";
77 +export {
78 + buildLayerRegistry,
79 + KNOWN_LAYERS,
80 + LAYER_GROUPS,
81 +} from "./layers/registry.js";
added src/layers/propertyLayer.ts +392 −0
@@ -0,0 +1,392 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * The canonical property layer: price/value pills + clusters, rendered
7 + * entirely by MapLibre (no DOM markers). One implementation for every
8 + * Groupe Ka app; colors come from the app theme, states from feature-state.
9 + */
10 +
11 +import type {
12 + CircleLayerSpecification,
13 + ExpressionSpecification,
14 + Map as MapboxMap,
15 + SymbolLayerSpecification,
16 +} from "mapbox-gl";
17 +import type { KaMapTheme } from "../theming/tokens.js";
18 +import { markerTokens } from "../theming/tokens.js";
19 +
20 +export const PROPERTY_SOURCE_ID = "ka-properties";
21 +
22 +export const LAYER_IDS = {
23 + clusters: "ka-clusters",
24 + clusterCount: "ka-cluster-count",
25 + clusterValue: "ka-cluster-value",
26 + pointDot: "ka-point-dot",
27 + pointPill: "ka-point-pill",
28 +} as const;
29 +
30 +/** Compact fr-CA price expression usable inside MapLibre layers.
31 + * Mirrors utils/format.formatCompactPrice for the common ranges.
32 + * Non-numeric/absent values render as "" (style-spec strict typing:
33 + * numeric operators get `to-number`-coerced operands). */
34 +export function compactPriceExpression(
35 + field: ExpressionSpecification,
36 +): ExpressionSpecification {
37 + const num: ExpressionSpecification = ["to-number", field];
38 + // Arrondis faits DANS l'expression : les options *-fraction-digits de
39 + // number-format ne sont pas fiables sur toutes les versions du moteur.
40 + const dollars: ExpressionSpecification = ["round", num];
41 + const thousands: ExpressionSpecification = ["round", ["/", num, 1000]];
42 + const millions: ExpressionSpecification = [
43 + "/",
44 + ["round", ["*", ["/", num, 1000000], 100]],
45 + 100,
46 + ];
47 + return [
48 + "case",
49 + ["!=", ["typeof", field], "number"],
50 + "",
51 + ["<", num, 10000],
52 + ["concat", ["number-format", dollars, { locale: "fr-CA" }], " $"],
53 + ["<", num, 999500],
54 + ["concat", ["number-format", thousands, { locale: "fr-CA" }], " k$"],
55 + ["concat", ["number-format", millions, { locale: "fr-CA" }], " M$"],
56 + ] as ExpressionSpecification;
57 +}
58 +
59 +const PILL_FAMILIES = ["sale", "rent", "valuation", "highlight"] as const;
60 +type PillFamily = (typeof PILL_FAMILIES)[number];
61 +
62 +/** Image id for a family pill, optionally in its selected state. */
63 +export function pillImageId(family: PillFamily, selected = false): string {
64 + return `ka-pill-${family}${selected ? "-sel" : ""}`;
65 +}
66 +
67 +/**
68 + * Register the stretchable price-pill backgrounds, one per marker family ×
69 + * state, pre-colored from the app theme. Canvas raster (non-SDF) because
70 + * MapLibre does not support nine-slice stretching on SDF icons.
71 + */
72 +export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {
73 + const DPR = 2;
74 + const W = 64;
75 + const H = 30;
76 + const R = 13;
77 + const TAIL = 6;
78 +
79 + const draw = (background: string, border: string): ImageData => {
80 + const canvas = document.createElement("canvas");
81 + canvas.width = W * DPR;
82 + canvas.height = (H + TAIL + 3) * DPR;
83 + const ctx = canvas.getContext("2d");
84 + if (!ctx) throw new Error("Canvas 2D indisponible");
85 + ctx.scale(DPR, DPR);
86 + ctx.shadowColor = "rgba(10, 12, 10, 0.28)";
87 + ctx.shadowBlur = 4;
88 + ctx.shadowOffsetY = 1.5;
89 + // corps de la pastille
90 + ctx.beginPath();
91 + ctx.roundRect(1, 1, W - 2, H - 2, R);
92 + ctx.fillStyle = background;
93 + ctx.fill();
94 + // pointe vers la coordonnée
95 + ctx.shadowColor = "transparent";
96 + ctx.beginPath();
97 + ctx.moveTo(W / 2 - 5.5, H - 1.5);
98 + ctx.lineTo(W / 2, H + TAIL - 1);
99 + ctx.lineTo(W / 2 + 5.5, H - 1.5);
100 + ctx.closePath();
101 + ctx.fillStyle = background;
102 + ctx.fill();
103 + ctx.beginPath();
104 + ctx.roundRect(1, 1, W - 2, H - 2, R);
105 + ctx.strokeStyle = border;
106 + ctx.lineWidth = 1.4;
107 + ctx.stroke();
108 + return ctx.getImageData(0, 0, canvas.width, canvas.height);
109 + };
110 +
111 + for (const family of PILL_FAMILIES) {
112 + const tokens = markerTokens(theme, family);
113 + const variants: [string, string, string][] = [
114 + [pillImageId(family), tokens.background, tokens.halo],
115 + [pillImageId(family, true), tokens.selectedBackground, tokens.halo],
116 + ];
117 + for (const [id, background, border] of variants) {
118 + if (map.hasImage(id)) continue;
119 + map.addImage(id, draw(background, border), {
120 + pixelRatio: DPR,
121 + stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],
122 + stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],
123 + content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],
124 + });
125 + }
126 + }
127 +}
128 +
129 +/** Family selector shared by icon and color expressions. */
130 +function familyCase(byFamily: Record<PillFamily, string>): ExpressionSpecification {
131 + return [
132 + "case",
133 + ["==", ["get", "highlight"], 1],
134 + byFamily.highlight,
135 + ["==", ["get", "kind"], "valuation"],
136 + byFamily.valuation,
137 + ["==", ["get", "listingType"], "rent"],
138 + byFamily.rent,
139 + byFamily.sale,
140 + ] as ExpressionSpecification;
141 +}
142 +
143 +/**
144 + * icon-image expression (layout ⇒ feature-state interdit) : la sélection est
145 + * réinjectée par KaMap via setLayoutProperty à chaque changement.
146 + */
147 +export function pillIconExpression(
148 + selectedId: string | null,
149 +): ExpressionSpecification {
150 + const base = familyCase({
151 + sale: pillImageId("sale"),
152 + rent: pillImageId("rent"),
153 + valuation: pillImageId("valuation"),
154 + highlight: pillImageId("highlight"),
155 + });
156 + if (selectedId === null) return base;
157 + const selected = familyCase({
158 + sale: pillImageId("sale", true),
159 + rent: pillImageId("rent", true),
160 + valuation: pillImageId("valuation", true),
161 + highlight: pillImageId("highlight", true),
162 + });
163 + return [
164 + "case",
165 + ["==", ["get", "id"], selectedId],
166 + selected,
167 + base,
168 + ] as ExpressionSpecification;
169 +}
170 +
171 +interface FamilyColors {
172 + background: ExpressionSpecification;
173 + text: ExpressionSpecification;
174 + halo: ExpressionSpecification;
175 +}
176 +
177 +/** Data-driven colors: family (sale/rent/valuation/highlight) × state. */
178 +function familyColorExpressions(theme: KaMapTheme): FamilyColors {
179 + const sale = markerTokens(theme, "sale");
180 + const rent = markerTokens(theme, "rent");
181 + const valuation = markerTokens(theme, "valuation");
182 + const highlight = markerTokens(theme, "highlight");
183 +
184 + const isHighlight: ExpressionSpecification = ["==", ["get", "highlight"], 1];
185 + const isValuation: ExpressionSpecification = ["==", ["get", "kind"], "valuation"];
186 + const isRent: ExpressionSpecification = ["==", ["get", "listingType"], "rent"];
187 + const selected: ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
188 + const hovered: ExpressionSpecification = ["boolean", ["feature-state", "hovered"], false];
189 + const active: ExpressionSpecification = ["any", selected, hovered];
190 +
191 + const pick = (key: keyof typeof sale): ExpressionSpecification =>
192 + [
193 + "case",
194 + isHighlight,
195 + highlight[key],
196 + isValuation,
197 + valuation[key],
198 + isRent,
199 + rent[key],
200 + sale[key],
201 + ] as ExpressionSpecification;
202 +
203 + return {
204 + background: [
205 + "case",
206 + active,
207 + pick("selectedBackground"),
208 + pick("background"),
209 + ] as ExpressionSpecification,
210 + text: ["case", active, pick("selectedText"), pick("text")] as ExpressionSpecification,
211 + halo: pick("halo"),
212 + };
213 +}
214 +
215 +/** All property/cluster layer specifications for the given theme. */
216 +export function buildPropertyLayers(
217 + theme: KaMapTheme,
218 + sourceId: string = PROPERTY_SOURCE_ID,
219 +): (SymbolLayerSpecification | CircleLayerSpecification)[] {
220 + const colors = familyColorExpressions(theme);
221 + const dimmed: ExpressionSpecification = ["boolean", ["feature-state", "dimmed"], false];
222 + // « Vu » : annonce déjà consultée — atténuée mais lisible ; la sélection
223 + // et le survol reprennent toujours la pleine opacité.
224 + const seen: ExpressionSpecification = ["boolean", ["feature-state", "seen"], false];
225 + const activeState: ExpressionSpecification = [
226 + "any",
227 + ["boolean", ["feature-state", "selected"], false],
228 + ["boolean", ["feature-state", "hovered"], false],
229 + ];
230 + const stateOpacity: ExpressionSpecification = [
231 + "case",
232 + dimmed,
233 + 0.35,
234 + ["all", seen, ["!", activeState]],
235 + 0.62,
236 + 1,
237 + ] as ExpressionSpecification;
238 +
239 + const clusterCircle: CircleLayerSpecification = {
240 + id: LAYER_IDS.clusters,
241 + slot: "top",
242 + type: "circle",
243 + source: sourceId,
244 + filter: ["has", "point_count"],
245 + paint: {
246 + "circle-color": theme.cluster.background,
247 + "circle-stroke-color": theme.cluster.border,
248 + "circle-stroke-width": 2.5,
249 + "circle-radius": [
250 + "step",
251 + ["get", "point_count"],
252 + 16,
253 + 10,
254 + 20,
255 + 50,
256 + 25,
257 + 200,
258 + 31,
259 + ],
260 + "circle-opacity": 0.95,
261 + },
262 + };
263 +
264 + const clusterCount: SymbolLayerSpecification = {
265 + id: LAYER_IDS.clusterCount,
266 + slot: "top",
267 + type: "symbol",
268 + source: sourceId,
269 + filter: ["has", "point_count"],
270 + layout: {
271 + "text-field": ["get", "point_count_abbreviated"],
272 + "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
273 + "text-size": 13,
274 + "text-offset": [0, -0.32],
275 + "text-allow-overlap": true,
276 + },
277 + paint: { "text-color": theme.cluster.text },
278 + };
279 +
280 + // Second line inside the cluster bubble: indicative (mean) value.
281 + const clusterValue: SymbolLayerSpecification = {
282 + id: LAYER_IDS.clusterValue,
283 + slot: "top",
284 + type: "symbol",
285 + source: sourceId,
286 + filter: ["all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0]],
287 + layout: {
288 + "text-field": [
289 + "concat",
290 + "≈",
291 + compactPriceExpression([
292 + "/",
293 + ["to-number", ["get", "valueSum"]],
294 + ["max", 1, ["to-number", ["get", "valueCount"]]],
295 + ] as ExpressionSpecification),
296 + ],
297 + "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],
298 + "text-size": 10,
299 + "text-offset": [0, 0.75],
300 + "text-allow-overlap": true,
301 + },
302 + paint: { "text-color": theme.cluster.text, "text-opacity": 0.85 },
303 + };
304 +
305 + const pointDot: CircleLayerSpecification = {
306 + id: LAYER_IDS.pointDot,
307 + slot: "top",
308 + type: "circle",
309 + source: sourceId,
310 + filter: ["!", ["has", "point_count"]],
311 + paint: {
312 + "circle-color": colors.background,
313 + "circle-radius": [
314 + "case",
315 + ["boolean", ["feature-state", "selected"], false],
316 + 5,
317 + 3.5,
318 + ],
319 + "circle-stroke-color": colors.halo,
320 + "circle-stroke-width": 1.5,
321 + "circle-opacity": stateOpacity,
322 + "circle-stroke-opacity": stateOpacity,
323 + },
324 + };
325 +
326 + const pointPill: SymbolLayerSpecification = {
327 + id: LAYER_IDS.pointPill,
328 + slot: "top",
329 + type: "symbol",
330 + source: sourceId,
331 + filter: [
332 + "all",
333 + ["!", ["has", "point_count"]],
334 + ["!=", ["get", "labelValue"], null],
335 + ],
336 + layout: {
337 + "icon-image": pillIconExpression(null),
338 + "icon-text-fit": "both",
339 + "icon-text-fit-padding": [3, 9, 8, 9],
340 + "icon-anchor": "bottom",
341 + "icon-allow-overlap": false,
342 + "icon-optional": false,
343 + "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification),
344 + "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
345 + "text-size": ["interpolate", ["linear"], ["zoom"], 13, 11, 17, 13],
346 + "text-anchor": "bottom",
347 + "text-offset": [0, -0.9],
348 + "text-allow-overlap": false,
349 + "text-optional": false,
350 + // feature-state est interdit dans les propriétés layout : le tri se
351 + // fait par valeur (les plus chères gagnent les collisions de labels).
352 + "symbol-sort-key": [
353 + "-",
354 + 10000000,
355 + ["to-number", ["coalesce", ["get", "labelValue"], 0]],
356 + ],
357 + },
358 + paint: {
359 + "icon-opacity": stateOpacity,
360 + "text-color": colors.text,
361 + "text-opacity": stateOpacity,
362 + },
363 + };
364 +
365 + return [clusterCircle, clusterCount, clusterValue, pointDot, pointPill];
366 +}
367 +
368 +/**
369 + * Cluster aggregation: running sum/count of labelValue → indicative mean.
370 + * `clamp` bounds each point's contribution so a single junk price (a house
371 + * listed at 2 M$ in a rental category…) cannot poison a whole bubble.
372 + */
373 +export function buildClusterProperties(
374 + clamp?: [number, number],
375 +): Record<string, unknown> {
376 + const raw: unknown = ["to-number", ["coalesce", ["get", "labelValue"], 0]];
377 + const contribution = clamp
378 + ? ["min", clamp[1], ["max", clamp[0], raw]]
379 + : raw;
380 + return {
381 + valueSum: ["+", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],
382 + valueCount: ["+", ["case", ["!=", ["get", "labelValue"], null], 1, 0]],
383 + // Fourchette de prix du cluster (tooltip au survol). Les valeurs nulles
384 + // reçoivent une sentinelle neutre pour chaque agrégat ; dès qu'une vraie
385 + // valeur existe dans la bulle, min/max sont exacts.
386 + valueMin: ["min", ["case", ["!=", ["get", "labelValue"], null], contribution, 99999999]],
387 + valueMax: ["max", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],
388 + };
389 +}
390 +
391 +/** Default aggregation (no clamp) — kept for direct layer consumers. */
392 +export const CLUSTER_PROPERTIES: Record<string, unknown> = buildClusterProperties();
added src/layers/registry.ts +61 −0
@@ -0,0 +1,61 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Per-app layer registry. The framework defines the vocabulary of layers
7 + * (groups + ids); each app registers only what its data actually supports.
8 + * Unsupported layers stay declared-but-hidden so future datasets plug in
9 + * without touching the framework.
10 + */
11 +
12 +import type { KaLayerDefinition } from "../types/index.js";
13 +
14 +export const LAYER_GROUPS = {
15 + property: "Propriétés",
16 + market: "Marché",
17 + land: "Territoire",
18 + lifestyle: "Milieu de vie",
19 + investment: "Investissement",
20 +} as const;
21 +
22 +/** The full Ka Maps layer vocabulary. Apps pick from (or extend) this. */
23 +export const KNOWN_LAYERS: KaLayerDefinition[] = [
24 + { id: "for-sale", group: "property", label: "À vendre", supported: false },
25 + { id: "for-rent", group: "property", label: "À louer", supported: false },
26 + { id: "recently-sold", group: "property", label: "Vendues récemment", supported: false },
27 + { id: "estimated-values", group: "property", label: "Valeurs estimées", supported: false },
28 + { id: "median-price", group: "market", label: "Prix médian", supported: false },
29 + { id: "price-per-sqft", group: "market", label: "Prix / pi²", supported: false },
30 + { id: "yoy-change", group: "market", label: "Variation annuelle", supported: false },
31 + { id: "inventory", group: "market", label: "Inventaire", supported: false },
32 + { id: "days-on-market", group: "market", label: "Jours sur le marché", supported: false },
33 + { id: "price-cuts", group: "market", label: "Baisses de prix", supported: false },
34 + { id: "buildings", group: "land", label: "Bâtiments", supported: false },
35 + { id: "lots", group: "land", label: "Lots", supported: false },
36 + { id: "zoning", group: "land", label: "Zonage", supported: false },
37 + { id: "admin-boundaries", group: "land", label: "Limites administratives", supported: false },
38 + { id: "schools", group: "lifestyle", label: "Écoles", supported: false },
39 + { id: "transit", group: "lifestyle", label: "Transport en commun", supported: false },
40 + { id: "grocery", group: "lifestyle", label: "Épiceries", supported: false },
41 + { id: "parks", group: "lifestyle", label: "Parcs", supported: false },
42 + { id: "est-yield", group: "investment", label: "Rendement estimé", supported: false },
43 + { id: "est-rent", group: "investment", label: "Loyer estimé", supported: false },
44 + { id: "est-cashflow", group: "investment", label: "Flux de trésorerie estimé", supported: false },
45 +];
46 +
47 +/**
48 + * Build an app's registry: mark the layers it supports (and optionally
49 + * activates by default); everything else stays declared but unsupported.
50 + */
51 +export function buildLayerRegistry(
52 + supported: { id: string; defaultActive?: boolean }[],
53 +): KaLayerDefinition[] {
54 + const byId = new Map(supported.map((s) => [s.id, s]));
55 + return KNOWN_LAYERS.map((layer) => {
56 + const s = byId.get(layer.id);
57 + return s
58 + ? { ...layer, supported: true, defaultActive: s.defaultActive ?? false }
59 + : { ...layer };
60 + });
61 +}
added src/react/KaMapView.tsx +161 −0
@@ -0,0 +1,161 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * React bindings. <KaMapView> owns the KaMap lifecycle (StrictMode-safe),
7 + * exposes it through context, and renders app-provided overlays (previews,
8 + * legends, controls) as normal React children — no HTML-string popups.
9 + */
10 +
11 +import {
12 + createContext,
13 + useContext,
14 + useEffect,
15 + useRef,
16 + useState,
17 + type CSSProperties,
18 + type ReactElement,
19 + type ReactNode,
20 +} from "react";
21 +import { KaMap, type KaMapOptions } from "../core/KaMap.js";
22 +import type { KaMapMode } from "../theming/tokens.js";
23 +import type { MapProperty } from "../types/index.js";
24 +
25 +const KaMapContext = createContext<KaMap | null>(null);
26 +
27 +/** Access the KaMap engine from any child of <KaMapView>. */
28 +export function useKaMap(): KaMap | null {
29 + return useContext(KaMapContext);
30 +}
31 +
32 +export interface KaMapViewProps
33 + extends Omit<KaMapOptions, "container" | "mode"> {
34 + mode?: KaMapMode;
35 + className?: string;
36 + style?: CSSProperties;
37 + /** Static data path (no adapter): render these properties directly. */
38 + properties?: MapProperty[];
39 + /** App-owned filter payload forwarded to the adapter. */
40 + filters?: Record<string, unknown>;
41 + onSelect?: (property: MapProperty | null, origin: "map" | "app") => void;
42 + onHover?: (property: MapProperty | null) => void;
43 + onData?: (count: number, totalCount?: number) => void;
44 + onLoading?: (loading: boolean) => void;
45 + onSearchAreaDirty?: (dirty: boolean) => void;
46 + onMoveEnd?: (
47 + center: { lat: number; lng: number },
48 + zoom: number,
49 + byUser: boolean,
50 + ) => void;
51 + /** Outil de dessin : polygone posé/effacé, ou tracé en cours. */
52 + onDraw?: (polygon: [number, number][] | null, drawing: boolean) => void;
53 + /** Survol d'un cluster (fourchette de prix) — null à la sortie. */
54 + onClusterHover?: (
55 + info: { count: number; min: number | null; max: number | null; x: number; y: number } | null,
56 + ) => void;
57 + children?: ReactNode;
58 +}
59 +
60 +export function KaMapView(props: KaMapViewProps): ReactElement {
61 + const containerRef = useRef<HTMLDivElement | null>(null);
62 + const [engine, setEngine] = useState<KaMap | null>(null);
63 +
64 + // Latest callbacks in refs so the engine effect never re-runs for them.
65 + const callbacks = useRef(props);
66 + callbacks.current = props;
67 +
68 + useEffect(() => {
69 + const container = containerRef.current;
70 + if (!container) return;
71 +
72 + const p = callbacks.current;
73 + const map = new KaMap({
74 + container,
75 + theme: p.theme,
76 + mapboxToken: p.mapboxToken,
77 + mode: p.mode,
78 + adapter: p.adapter,
79 + basemap: p.basemap,
80 + center: p.center,
81 + zoom: p.zoom,
82 + pitch: p.pitch,
83 + minZoom: p.minZoom,
84 + maxZoom: p.maxZoom,
85 + searchMode: p.searchMode,
86 + query: p.query,
87 + cooperativeGestures: p.cooperativeGestures,
88 + cluster: p.cluster,
89 + });
90 +
91 + const offs = [
92 + map.events.on("select", ({ propertyId, origin }) => {
93 + callbacks.current.onSelect?.(
94 + propertyId ? map.getProperty(propertyId) ?? null : null,
95 + origin,
96 + );
97 + }),
98 + map.events.on("hover", ({ propertyId }) => {
99 + callbacks.current.onHover?.(
100 + propertyId ? map.getProperty(propertyId) ?? null : null,
101 + );
102 + }),
103 + map.events.on("data", ({ count, totalCount }) =>
104 + callbacks.current.onData?.(count, totalCount),
105 + ),
106 + map.events.on("loading", ({ loading }) =>
107 + callbacks.current.onLoading?.(loading),
108 + ),
109 + map.events.on("searchAreaDirty", ({ dirty }) =>
110 + callbacks.current.onSearchAreaDirty?.(dirty),
111 + ),
112 + map.events.on("moveend", ({ center, zoom, byUser }) =>
113 + callbacks.current.onMoveEnd?.(center, zoom, byUser),
114 + ),
115 + map.events.on("draw", ({ polygon, drawing }) =>
116 + callbacks.current.onDraw?.(polygon, drawing),
117 + ),
118 + map.events.on("clusterHover", ({ info }) =>
119 + callbacks.current.onClusterHover?.(info),
120 + ),
121 + ];
122 +
123 + setEngine(map);
124 + return () => {
125 + for (const off of offs) off();
126 + setEngine(null);
127 + map.destroy();
128 + };
129 + // The engine is created once per mount; theme/adapter swaps remount.
130 + // eslint-disable-next-line react-hooks/exhaustive-deps
131 + }, [props.theme.id, props.adapter?.id]);
132 +
133 + // Static data path.
134 + useEffect(() => {
135 + if (engine && props.properties) engine.setProperties(props.properties);
136 + }, [engine, props.properties]);
137 +
138 + // Filters → adapter refetch.
139 + const filtersKey = props.filters ? JSON.stringify(props.filters) : "";
140 + useEffect(() => {
141 + if (engine && props.adapter) engine.setFilters(props.filters);
142 + // eslint-disable-next-line react-hooks/exhaustive-deps
143 + }, [engine, filtersKey]);
144 +
145 + // Light/dark swaps restyle in place.
146 + useEffect(() => {
147 + if (engine && props.mode) engine.setMode(props.mode);
148 + }, [engine, props.mode]);
149 +
150 + return (
151 + <div
152 + className={`ka-map ${props.className ?? ""}`}
153 + style={{ position: "relative", width: "100%", height: "100%", ...props.style }}
154 + >
155 + <div ref={containerRef} className="ka-map-canvas" style={{ position: "absolute", inset: 0 }} />
156 + <KaMapContext.Provider value={engine}>
157 + {engine ? props.children : null}
158 + </KaMapContext.Provider>
159 + </div>
160 + );
161 +}
added src/react/KaSpotlightMap.tsx +116 −0
@@ -0,0 +1,116 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * <KaSpotlightMap> — la carte « fiche » : une seule propriété, caméra 3D
7 + * serrée sur l'adresse, et le bâtiment concerné mis en évidence (empreinte
8 + * 3D du featureset « buildings » de Mapbox Standard, coloré via
9 + * `buildingColor`). Fond réaliste par défaut (pleine couleur, repères 3D).
10 + * Gestes coopératifs : la fiche reste défilable au-dessus de la carte.
11 + */
12 +
13 +import { useEffect, useRef, type CSSProperties, type ReactElement, type ReactNode } from "react";
14 +import type { KaBasemapOptions } from "../styles/kaBaseStyle.js";
15 +import type { KaMapMode, KaMapTheme } from "../theming/tokens.js";
16 +import type { MapProperty } from "../types/index.js";
17 +import { KaMapView, useKaMap } from "./KaMapView.js";
18 +
19 +/** Rouge signal — l'immeuble de la fiche ressort du premier coup d'œil. */
20 +const DEFAULT_BUILDING_COLOR = "#e03131";
21 +
22 +const DEFAULT_ZOOM = 17.1;
23 +const DEFAULT_PITCH = 62;
24 +
25 +function SpotlightBridge({
26 + property,
27 + color,
28 + zoom,
29 + pitch,
30 +}: {
31 + property: MapProperty;
32 + color: string;
33 + zoom: number;
34 + pitch: number;
35 +}) {
36 + const map = useKaMap();
37 + const firstRun = useRef(true);
38 +
39 + useEffect(() => {
40 + if (!map) return;
41 + const at = { lng: property.longitude, lat: property.latitude };
42 + // Premier rendu : la caméra est déjà posée par le constructeur ;
43 + // navigations suivantes (fiche → fiche) : glissement doux.
44 + if (firstRun.current) firstRun.current = false;
45 + else map.flyTo(at, { zoom, pitch });
46 + map.focusBuilding(at, { color });
47 + return () => map.focusBuilding(null);
48 + // eslint-disable-next-line react-hooks/exhaustive-deps
49 + }, [map, property.id, property.longitude, property.latitude, color]);
50 +
51 + return null;
52 +}
53 +
54 +export interface KaSpotlightMapProps {
55 + theme: KaMapTheme;
56 + mapboxToken: string;
57 + /** La propriété de la fiche — pastille de prix + bâtiment en évidence. */
58 + property: MapProperty;
59 + mode?: KaMapMode;
60 + className?: string;
61 + style?: CSSProperties;
62 + /** Couleur de l'empreinte 3D du bâtiment. Défaut : rouge signal. */
63 + buildingColor?: string;
64 + zoom?: number;
65 + pitch?: number;
66 + /** Surcharge du fond (défaut spotlight : pleine couleur + repères 3D). */
67 + basemap?: KaBasemapOptions;
68 + children?: ReactNode;
69 +}
70 +
71 +export function KaSpotlightMap({
72 + theme,
73 + mapboxToken,
74 + property,
75 + mode,
76 + className,
77 + style,
78 + buildingColor = DEFAULT_BUILDING_COLOR,
79 + zoom = DEFAULT_ZOOM,
80 + pitch = DEFAULT_PITCH,
81 + basemap,
82 + children,
83 +}: KaSpotlightMapProps): ReactElement {
84 + const properties = useRef<MapProperty[]>([]);
85 + if (properties.current[0]?.id !== property.id) properties.current = [property];
86 +
87 + return (
88 + <KaMapView
89 + theme={theme}
90 + mapboxToken={mapboxToken}
91 + mode={mode}
92 + className={className}
93 + style={style}
94 + properties={properties.current}
95 + center={{ lat: property.latitude, lng: property.longitude }}
96 + zoom={zoom}
97 + pitch={pitch}
98 + minZoom={11}
99 + cooperativeGestures
100 + basemap={{
101 + theme: "default",
102 + showLandmarks: true,
103 + colorBuildingSelect: buildingColor,
104 + ...basemap,
105 + }}
106 + >
107 + <SpotlightBridge
108 + property={property}
109 + color={buildingColor}
110 + zoom={zoom}
111 + pitch={pitch}
112 + />
113 + {children}
114 + </KaMapView>
115 + );
116 +}
added src/react/PropertyPreview.tsx +76 −0
@@ -0,0 +1,76 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Selection preview shell: floating panel on desktop, bottom sheet on
7 + * mobile. The card CONTENT is app-owned (render prop) so each app keeps
8 + * its own design language; the framework owns placement, lifecycle,
9 + * dismissal and accessibility. React all the way — no setHTML, no XSS.
10 + */
11 +
12 +import { useEffect, useState, type ReactElement, type ReactNode } from "react";
13 +import type { MapProperty } from "../types/index.js";
14 +import { useKaMap } from "./KaMapView.js";
15 +
16 +export interface PropertyPreviewProps {
17 + /** App-owned card renderer for the selected property. */
18 + render: (property: MapProperty, close: () => void) => ReactNode;
19 + /** Viewport width (px) under which the bottom-sheet layout is used. */
20 + mobileBreakpoint?: number;
21 + closeLabel?: string;
22 +}
23 +
24 +export function PropertyPreview(props: PropertyPreviewProps): ReactElement | null {
25 + const map = useKaMap();
26 + const [property, setProperty] = useState<MapProperty | null>(null);
27 + const [mobile, setMobile] = useState(false);
28 + const breakpoint = props.mobileBreakpoint ?? 780;
29 +
30 + useEffect(() => {
31 + if (!map) return;
32 + return map.events.on("select", ({ propertyId }) => {
33 + setProperty(propertyId ? map.getProperty(propertyId) ?? null : null);
34 + });
35 + }, [map]);
36 +
37 + useEffect(() => {
38 + const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
39 + const update = () => setMobile(mq.matches);
40 + update();
41 + mq.addEventListener("change", update);
42 + return () => mq.removeEventListener("change", update);
43 + }, [breakpoint]);
44 +
45 + useEffect(() => {
46 + if (!property) return;
47 + const onKey = (e: KeyboardEvent) => {
48 + if (e.key === "Escape") close();
49 + };
50 + window.addEventListener("keydown", onKey);
51 + return () => window.removeEventListener("keydown", onKey);
52 + // eslint-disable-next-line react-hooks/exhaustive-deps
53 + }, [property]);
54 +
55 + if (!map || !property) return null;
56 +
57 + const close = () => map.select(null, "app");
58 +
59 + return (
60 + <div
61 + className={mobile ? "ka-preview ka-preview-sheet" : "ka-preview ka-preview-panel"}
62 + role="dialog"
63 + aria-label={property.address ?? "Propriété sélectionnée"}
64 + >
65 + <button
66 + type="button"
67 + className="ka-preview-close"
68 + onClick={close}
69 + aria-label={props.closeLabel ?? "Fermer"}
70 + >
71 + ×
72 + </button>
73 + {props.render(property, close)}
74 + </div>
75 + );
76 +}
added src/react/controls.tsx +254 −0
@@ -0,0 +1,254 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Framework-level React controls, themed by the app's KaMapTheme through
7 + * CSS custom properties (see ka-maps.css). Headless enough that each app
8 + * keeps its own visual identity via tokens, not forks.
9 + */
10 +
11 +import { useEffect, useState, type ReactElement, type ReactNode } from "react";
12 +import { useKaMap } from "./KaMapView.js";
13 +
14 +/**
15 + * "Search this area" — appears when the viewport diverges from the last
16 + * searched area (manual search mode), with an optional auto-search toggle.
17 + */
18 +export function SearchAreaControl(props: {
19 + label?: string;
20 + autoLabel?: string;
21 + showAutoToggle?: boolean;
22 +}): ReactElement | null {
23 + const map = useKaMap();
24 + const [dirty, setDirty] = useState(false);
25 + const [loading, setLoading] = useState(false);
26 + const [auto, setAuto] = useState(map?.getSearchMode() === "auto");
27 +
28 + useEffect(() => {
29 + if (!map) return;
30 + const offs = [
31 + map.events.on("searchAreaDirty", ({ dirty: d }) => setDirty(d)),
32 + map.events.on("loading", ({ loading: l }) => setLoading(l)),
33 + ];
34 + return () => offs.forEach((off) => off());
35 + }, [map]);
36 +
37 + if (!map) return null;
38 +
39 + const toggleAuto = () => {
40 + const next = !auto;
41 + setAuto(next);
42 + map.setSearchMode(next ? "auto" : "manual");
43 + };
44 +
45 + return (
46 + <div className="ka-search-area" role="group" aria-label="Recherche géographique">
47 + {dirty && !auto ? (
48 + <button
49 + type="button"
50 + className="ka-search-area-btn"
51 + onClick={() => map.searchThisArea()}
52 + disabled={loading}
53 + >
54 + {loading ? "Recherche…" : props.label ?? "Rechercher dans cette zone"}
55 + </button>
56 + ) : null}
57 + {props.showAutoToggle !== false ? (
58 + <label className="ka-search-area-auto">
59 + <input type="checkbox" checked={auto} onChange={toggleAuto} />
60 + <span>{props.autoLabel ?? "Rechercher en déplaçant la carte"}</span>
61 + </label>
62 + ) : null}
63 + </div>
64 + );
65 +}
66 +
67 +/** Subtle updating indicator — keeps previous results visible while loading. */
68 +export function LoadingIndicator(props: { label?: string }): ReactElement | null {
69 + const map = useKaMap();
70 + const [loading, setLoading] = useState(false);
71 +
72 + useEffect(() => {
73 + if (!map) return;
74 + return map.events.on("loading", ({ loading: l }) => setLoading(l));
75 + }, [map]);
76 +
77 + if (!loading) return null;
78 + return (
79 + <div className="ka-loading" role="status" aria-live="polite">
80 + <span className="ka-loading-dot" aria-hidden="true" />
81 + {props.label ?? "Mise à jour…"}
82 + </div>
83 + );
84 +}
85 +
86 +/** Count chip + empty state, French default. */
87 +export function ResultCount(props: {
88 + emptyTitle?: string;
89 + emptyHint?: string;
90 +}): ReactElement | null {
91 + const map = useKaMap();
92 + const [count, setCount] = useState<number | null>(null);
93 + const [total, setTotal] = useState<number | undefined>(undefined);
94 +
95 + useEffect(() => {
96 + if (!map) return;
97 + return map.events.on("data", ({ count: c, totalCount }) => {
98 + setCount(c);
99 + setTotal(totalCount);
100 + });
101 + }, [map]);
102 +
103 + if (count === null) return null;
104 + if (count === 0) {
105 + return (
106 + <div className="ka-empty" role="status">
107 + <strong>{props.emptyTitle ?? "Aucune propriété trouvée dans cette zone."}</strong>
108 + <span>{props.emptyHint ?? "Élargissez la carte ou modifiez vos filtres."}</span>
109 + </div>
110 + );
111 + }
112 + const hidden = total !== undefined && total > count ? total - count : 0;
113 + return (
114 + <div className="ka-count" role="status">
115 + {count.toLocaleString("fr-CA")} sur la carte
116 + {hidden > 0 ? ` · ${hidden.toLocaleString("fr-CA")} hors carte` : ""}
117 + </div>
118 + );
119 +}
120 +
121 +/**
122 + * Groupe Ka brand badge — every Ka Maps instance carries the family mark:
123 + * the app's map product name over the "Ka Maps · Groupe Ka" signature.
124 + * Complements (never replaces) the legally required OSM attribution.
125 + */
126 +export function KaBrandBadge(props: { subtitle?: string }): ReactElement | null {
127 + const map = useKaMap();
128 + if (!map) return null;
129 + const theme = map.getTheme();
130 + return (
131 + <div className="ka-brand" aria-hidden="true">
132 + <span className="ka-brand-name">{theme.productName}</span>
133 + <span className="ka-brand-sub">
134 + {props.subtitle ?? "Ka Maps · Groupe Ka"}
135 + </span>
136 + </div>
137 + );
138 +}
139 +
140 +/** 3D tilt toggle — buildings gain their real extruded volumes at street
141 + * zoom; this control tilts the camera to reveal them. Never the default. */
142 +export function Tilt3DControl(props: { label3d?: string; label2d?: string }): ReactElement | null {
143 + const map = useKaMap();
144 + const [tilted, setTilted] = useState(() => map?.isTilted() ?? false);
145 +
146 + useEffect(() => {
147 + if (!map) return;
148 + return map.events.on("moveend", () => setTilted(map.isTilted()));
149 + }, [map]);
150 +
151 + if (!map) return null;
152 + return (
153 + <button
154 + type="button"
155 + className={`ka-3d${tilted ? " on" : ""}`}
156 + onClick={() => map.setTilt(!tilted)}
157 + aria-pressed={tilted}
158 + aria-label="Basculer la vue 3D"
159 + >
160 + {tilted ? props.label2d ?? "2D" : props.label3d ?? "3D"}
161 + </button>
162 + );
163 +}
164 +
165 +/** "Locate me" — geolocation strictly on user action, graceful denial. */
166 +export function LocateControl(props: { label?: string }): ReactElement | null {
167 + const map = useKaMap();
168 + const [state, setState] = useState<"idle" | "busy" | "denied">("idle");
169 +
170 + if (!map) return null;
171 +
172 + const locate = () => {
173 + if (!("geolocation" in navigator)) {
174 + setState("denied");
175 + return;
176 + }
177 + setState("busy");
178 + navigator.geolocation.getCurrentPosition(
179 + (pos) => {
180 + setState("idle");
181 + map.map.easeTo({
182 + center: [pos.coords.longitude, pos.coords.latitude],
183 + zoom: Math.max(map.map.getZoom(), 14),
184 + duration: 600,
185 + });
186 + },
187 + () => setState("denied"),
188 + { enableHighAccuracy: true, timeout: 10_000 },
189 + );
190 + };
191 +
192 + return (
193 + <button
194 + type="button"
195 + className="ka-locate"
196 + onClick={locate}
197 + disabled={state === "busy"}
198 + aria-label={props.label ?? "Me localiser"}
199 + title={state === "denied" ? "Géolocalisation indisponible" : props.label ?? "Me localiser"}
200 + >
201 + ◎ {props.label ?? "Me localiser"}
202 + </button>
203 + );
204 +}
205 +
206 +/**
207 + * Outil « Dessiner une zone » — démarre/annule le tracé d'un polygone sur
208 + * la carte ; quand une zone est posée, le bouton devient « Effacer la zone ».
209 + * L'app écoute onDraw (KaMapView) pour transformer le polygone en filtre.
210 + */
211 +export function DrawControl(props: {
212 + labelStart?: string;
213 + labelDrawing?: string;
214 + labelClear?: string;
215 +}): ReactElement | null {
216 + const map = useKaMap();
217 + const [drawing, setDrawing] = useState(false);
218 + const [hasZone, setHasZone] = useState<boolean>(
219 + () => (map?.getDrawnPolygon() ?? null) !== null,
220 + );
221 +
222 + useEffect(() => {
223 + if (!map) return;
224 + setDrawing(map.isDrawing());
225 + setHasZone(map.getDrawnPolygon() !== null);
226 + return map.events.on("draw", ({ polygon, drawing: d }) => {
227 + setDrawing(d);
228 + setHasZone(polygon !== null);
229 + });
230 + }, [map]);
231 +
232 + if (!map) return null;
233 +
234 + const onClick = () => {
235 + if (drawing) map.cancelDraw();
236 + else if (hasZone) map.clearDrawnPolygon();
237 + else map.startDraw();
238 + };
239 +
240 + return (
241 + <button
242 + type="button"
243 + className={`ka-draw-btn${drawing ? " drawing" : ""}${hasZone ? " has-zone" : ""}`}
244 + onClick={onClick}
245 + aria-pressed={drawing || hasZone}
246 + >
247 + {drawing
248 + ? props.labelDrawing ?? "Cliquez pour tracer — Échap pour annuler"
249 + : hasZone
250 + ? props.labelClear ?? "Effacer la zone"
251 + : props.labelStart ?? "Dessiner une zone"}
252 + </button>
253 + );
254 +}
added src/react/index.ts +20 −0
@@ -0,0 +1,20 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * React entry point: import from "@groupe-ka/ka-maps/react".
7 + */
8 +
9 +export { KaMapView, useKaMap, type KaMapViewProps } from "./KaMapView.js";
10 +export { KaSpotlightMap, type KaSpotlightMapProps } from "./KaSpotlightMap.js";
11 +export {
12 + DrawControl,
13 + KaBrandBadge,
14 + LoadingIndicator,
15 + LocateControl,
16 + ResultCount,
17 + SearchAreaControl,
18 + Tilt3DControl,
19 +} from "./controls.js";
20 +export { PropertyPreview, type PropertyPreviewProps } from "./PropertyPreview.js";
added src/services/boundsQuery.ts +202 −0
@@ -0,0 +1,202 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Viewport → data pipeline shared by every app:
7 + * debounce panning, abort stale requests, cache identical queries,
8 + * never let an old response overwrite a newer one.
9 + */
10 +
11 +import type {
12 + BBox,
13 + BoundsQueryResult,
14 + KaDataAdapter,
15 +} from "../types/index.js";
16 +import { bboxToString } from "../utils/geo.js";
17 +
18 +export interface BoundsQueryOptions {
19 + /** Milliseconds to wait after the last moveend before querying. */
20 + debounceMs?: number;
21 + /** Max cached query results (LRU). */
22 + cacheSize?: number;
23 + /** Cache time-to-live in ms; expired entries refetch. */
24 + cacheTtlMs?: number;
25 +}
26 +
27 +export interface BoundsRequest {
28 + bbox: BBox;
29 + zoom: number;
30 + filters?: Record<string, unknown>;
31 +}
32 +
33 +type Listener = (result: BoundsQueryResult, request: BoundsRequest) => void;
34 +type ErrorListener = (error: unknown, request: BoundsRequest) => void;
35 +type LoadingListener = (loading: boolean) => void;
36 +
37 +interface CacheEntry {
38 + result: BoundsQueryResult;
39 + at: number;
40 +}
41 +
42 +/**
43 + * One instance per map. `request()` may be called on every moveend; the
44 + * scheduler collapses bursts, cancels in-flight fetches and guarantees
45 + * monotonic delivery (a response for request N never fires after N+1's).
46 + */
47 +export class BoundsQueryScheduler {
48 + private adapter: KaDataAdapter;
49 + private debounceMs: number;
50 + private cacheSize: number;
51 + private cacheTtlMs: number;
52 +
53 + private timer: ReturnType<typeof setTimeout> | null = null;
54 + private controller: AbortController | null = null;
55 + private seq = 0;
56 + private delivered = 0;
57 + private cache = new Map<string, CacheEntry>();
58 +
59 + private listeners = new Set<Listener>();
60 + private errorListeners = new Set<ErrorListener>();
61 + private loadingListeners = new Set<LoadingListener>();
62 +
63 + constructor(adapter: KaDataAdapter, options: BoundsQueryOptions = {}) {
64 + this.adapter = adapter;
65 + this.debounceMs = options.debounceMs ?? 250;
66 + this.cacheSize = options.cacheSize ?? 40;
67 + this.cacheTtlMs = options.cacheTtlMs ?? 60_000;
68 + }
69 +
70 + onResult(fn: Listener): () => void {
71 + this.listeners.add(fn);
72 + return () => this.listeners.delete(fn);
73 + }
74 +
75 + onError(fn: ErrorListener): () => void {
76 + this.errorListeners.add(fn);
77 + return () => this.errorListeners.delete(fn);
78 + }
79 +
80 + onLoading(fn: LoadingListener): () => void {
81 + this.loadingListeners.add(fn);
82 + return () => this.loadingListeners.delete(fn);
83 + }
84 +
85 + /** Debounced entry point — call freely on moveend. */
86 + request(req: BoundsRequest): void {
87 + if (this.timer !== null) clearTimeout(this.timer);
88 + this.timer = setTimeout(() => {
89 + this.timer = null;
90 + void this.execute(req);
91 + }, this.debounceMs);
92 + }
93 +
94 + /** Immediate entry point — "Search this area" button, initial load. */
95 + requestNow(req: BoundsRequest): void {
96 + if (this.timer !== null) {
97 + clearTimeout(this.timer);
98 + this.timer = null;
99 + }
100 + void this.execute(req);
101 + }
102 +
103 + /** Drop pending work and abort any in-flight request. */
104 + cancel(): void {
105 + if (this.timer !== null) {
106 + clearTimeout(this.timer);
107 + this.timer = null;
108 + }
109 + this.controller?.abort();
110 + this.controller = null;
111 + this.setLoading(false);
112 + }
113 +
114 + /** Clear the query cache (call when filters change server-side data). */
115 + invalidate(): void {
116 + this.cache.clear();
117 + }
118 +
119 + destroy(): void {
120 + this.cancel();
121 + this.listeners.clear();
122 + this.errorListeners.clear();
123 + this.loadingListeners.clear();
124 + this.cache.clear();
125 + }
126 +
127 + private cacheKey(req: BoundsRequest): string {
128 + const filterKey = req.filters ? stableStringify(req.filters) : "";
129 + // Zoom bucketed to integer: sub-integer zoom changes rarely change data.
130 + return `${bboxToString(req.bbox, 4)}|z${Math.round(req.zoom)}|${filterKey}`;
131 + }
132 +
133 + private async execute(req: BoundsRequest): Promise<void> {
134 + const key = this.cacheKey(req);
135 + const cached = this.cache.get(key);
136 + const mySeq = ++this.seq;
137 +
138 + if (cached && Date.now() - cached.at < this.cacheTtlMs) {
139 + // LRU refresh.
140 + this.cache.delete(key);
141 + this.cache.set(key, cached);
142 + this.delivered = mySeq;
143 + this.emit(cached.result, req);
144 + return;
145 + }
146 +
147 + this.controller?.abort();
148 + const controller = new AbortController();
149 + this.controller = controller;
150 + this.setLoading(true);
151 +
152 + try {
153 + const result = await this.adapter.fetchInBounds({
154 + bbox: req.bbox,
155 + zoom: req.zoom,
156 + filters: req.filters,
157 + signal: controller.signal,
158 + });
159 + if (mySeq <= this.delivered || controller.signal.aborted) return;
160 + this.delivered = mySeq;
161 +
162 + this.cache.set(key, { result, at: Date.now() });
163 + while (this.cache.size > this.cacheSize) {
164 + const oldest = this.cache.keys().next().value;
165 + if (oldest === undefined) break;
166 + this.cache.delete(oldest);
167 + }
168 + this.emit(result, req);
169 + } catch (error) {
170 + if (controller.signal.aborted) return; // stale by design, stay silent
171 + if (mySeq <= this.delivered) return;
172 + for (const fn of this.errorListeners) fn(error, req);
173 + } finally {
174 + if (this.controller === controller) {
175 + this.controller = null;
176 + this.setLoading(false);
177 + }
178 + }
179 + }
180 +
181 + private emit(result: BoundsQueryResult, req: BoundsRequest): void {
182 + for (const fn of this.listeners) fn(result, req);
183 + }
184 +
185 + private setLoading(loading: boolean): void {
186 + for (const fn of this.loadingListeners) fn(loading);
187 + }
188 +}
189 +
190 +/** JSON.stringify with sorted keys so filter objects hash consistently. */
191 +export function stableStringify(value: unknown): string {
192 + return JSON.stringify(value, (_k, v: unknown) => {
193 + if (v && typeof v === "object" && !Array.isArray(v)) {
194 + const sorted: Record<string, unknown> = {};
195 + for (const key of Object.keys(v as Record<string, unknown>).sort()) {
196 + sorted[key] = (v as Record<string, unknown>)[key];
197 + }
198 + return sorted;
199 + }
200 + return v;
201 + });
202 +}
added src/styles/ka-maps.css +381 −0
@@ -0,0 +1,381 @@
1 +/*
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Framework chrome styles, themed per app via CSS custom properties.
7 + * Apps set these on the .ka-map container (or an ancestor):
8 + * --ka-accent app accent color
9 + * --ka-on-accent text on accent
10 + * --ka-surface control surface
11 + * --ka-ink primary text
12 + * --ka-line border color
13 + * --ka-radius control radius
14 + * --ka-shadow control shadow
15 + * --ka-font UI font stack
16 + */
17 +
18 +.ka-map {
19 + --ka-accent: #141814;
20 + --ka-on-accent: #ffffff;
21 + --ka-surface: #ffffff;
22 + --ka-ink: #141814;
23 + --ka-line: rgba(20, 24, 20, 0.85);
24 + --ka-radius: 8px;
25 + --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.18);
26 + --ka-font: system-ui, sans-serif;
27 + font-family: var(--ka-font);
28 +}
29 +
30 +.ka-map-canvas {
31 + outline: none;
32 +}
33 +
34 +/* ---- Search this area ---- */
35 +.ka-search-area {
36 + position: absolute;
37 + top: 12px;
38 + left: 50%;
39 + transform: translateX(-50%);
40 + z-index: 10;
41 + display: flex;
42 + flex-direction: column;
43 + align-items: center;
44 + gap: 6px;
45 + pointer-events: none;
46 +}
47 +
48 +.ka-search-area-btn {
49 + pointer-events: auto;
50 + border: 1.5px solid var(--ka-line);
51 + background: var(--ka-accent);
52 + color: var(--ka-on-accent);
53 + font: 600 13px/1 var(--ka-font);
54 + padding: 10px 16px;
55 + border-radius: 999px;
56 + box-shadow: var(--ka-shadow);
57 + cursor: pointer;
58 + min-height: 40px;
59 +}
60 +
61 +.ka-search-area-btn:active {
62 + transform: translate(2px, 2px);
63 + box-shadow: none;
64 +}
65 +
66 +.ka-search-area-btn:disabled {
67 + opacity: 0.7;
68 + cursor: default;
69 +}
70 +
71 +.ka-search-area-auto {
72 + pointer-events: auto;
73 + display: inline-flex;
74 + align-items: center;
75 + gap: 6px;
76 + background: var(--ka-surface);
77 + color: var(--ka-ink);
78 + border: 1px solid var(--ka-line);
79 + border-radius: 999px;
80 + padding: 5px 10px;
81 + font: 500 11px/1 var(--ka-font);
82 + cursor: pointer;
83 + user-select: none;
84 +}
85 +
86 +.ka-search-area-auto input {
87 + accent-color: var(--ka-accent);
88 + margin: 0;
89 +}
90 +
91 +/* ---- Loading / count / empty ---- */
92 +.ka-loading {
93 + position: absolute;
94 + bottom: 14px;
95 + left: 50%;
96 + transform: translateX(-50%);
97 + z-index: 10;
98 + display: inline-flex;
99 + align-items: center;
100 + gap: 8px;
101 + background: var(--ka-surface);
102 + color: var(--ka-ink);
103 + border: 1.5px solid var(--ka-line);
104 + border-radius: 999px;
105 + padding: 7px 14px;
106 + font: 500 12px/1 var(--ka-font);
107 + box-shadow: var(--ka-shadow);
108 +}
109 +
110 +.ka-loading-dot {
111 + width: 8px;
112 + height: 8px;
113 + border-radius: 50%;
114 + background: var(--ka-accent);
115 + animation: ka-pulse 1.2s ease-in-out infinite;
116 +}
117 +
118 +@keyframes ka-pulse {
119 + 0%,
120 + 100% {
121 + opacity: 1;
122 + transform: scale(1);
123 + }
124 + 50% {
125 + opacity: 0.35;
126 + transform: scale(0.7);
127 + }
128 +}
129 +
130 +@media (prefers-reduced-motion: reduce) {
131 + .ka-loading-dot {
132 + animation: none;
133 + }
134 +}
135 +
136 +.ka-count {
137 + position: absolute;
138 + bottom: 14px;
139 + left: 14px;
140 + z-index: 9;
141 + background: var(--ka-surface);
142 + color: var(--ka-ink);
143 + border: 1px solid var(--ka-line);
144 + border-radius: 999px;
145 + padding: 6px 12px;
146 + font: 600 11px/1 var(--ka-font);
147 +}
148 +
149 +.ka-empty {
150 + position: absolute;
151 + top: 50%;
152 + left: 50%;
153 + transform: translate(-50%, -50%);
154 + z-index: 9;
155 + display: flex;
156 + flex-direction: column;
157 + gap: 4px;
158 + text-align: center;
159 + background: var(--ka-surface);
160 + color: var(--ka-ink);
161 + border: 1.5px solid var(--ka-line);
162 + border-radius: var(--ka-radius);
163 + padding: 18px 22px;
164 + font: 400 13px/1.5 var(--ka-font);
165 + box-shadow: var(--ka-shadow);
166 + max-width: 320px;
167 +}
168 +
169 +/* ---- Locate ---- */
170 +.ka-locate {
171 + position: absolute;
172 + top: 12px;
173 + right: 52px;
174 + z-index: 10;
175 + border: 1.5px solid var(--ka-line);
176 + background: var(--ka-surface);
177 + color: var(--ka-ink);
178 + font: 600 12px/1 var(--ka-font);
179 + padding: 9px 13px;
180 + border-radius: 999px;
181 + cursor: pointer;
182 + min-height: 40px;
183 +}
184 +
185 +/* ---- Preview: desktop panel / mobile bottom sheet ---- */
186 +.ka-preview {
187 + z-index: 20;
188 + background: var(--ka-surface);
189 + border: 1.5px solid var(--ka-line);
190 + box-shadow: var(--ka-shadow);
191 +}
192 +
193 +.ka-preview-panel {
194 + position: absolute;
195 + top: 14px;
196 + left: 14px;
197 + width: 290px;
198 + max-width: calc(100% - 28px);
199 + border-radius: var(--ka-radius);
200 + overflow: hidden;
201 +}
202 +
203 +.ka-preview-sheet {
204 + position: absolute;
205 + left: 0;
206 + right: 0;
207 + bottom: 0;
208 + border-radius: 14px 14px 0 0;
209 + border-bottom: none;
210 + padding-bottom: env(safe-area-inset-bottom);
211 + animation: ka-sheet-in 0.22s ease-out;
212 +}
213 +
214 +@keyframes ka-sheet-in {
215 + from {
216 + transform: translateY(24px);
217 + opacity: 0;
218 + }
219 + to {
220 + transform: translateY(0);
221 + opacity: 1;
222 + }
223 +}
224 +
225 +@media (prefers-reduced-motion: reduce) {
226 + .ka-preview-sheet {
227 + animation: none;
228 + }
229 +}
230 +
231 +.ka-preview-close {
232 + position: absolute;
233 + top: 6px;
234 + right: 6px;
235 + z-index: 2;
236 + width: 30px;
237 + height: 30px;
238 + border-radius: 50%;
239 + border: 1px solid var(--ka-line);
240 + background: var(--ka-surface);
241 + color: var(--ka-ink);
242 + font: 600 16px/1 var(--ka-font);
243 + cursor: pointer;
244 +}
245 +
246 +/* ---- MapLibre control skinning (no default-looking buttons) ---- */
247 +.ka-map .mapboxgl-ctrl-group {
248 + border-radius: var(--ka-radius);
249 + border: 1.5px solid var(--ka-line);
250 + box-shadow: var(--ka-shadow);
251 + overflow: hidden;
252 + background: var(--ka-surface);
253 +}
254 +
255 +.ka-map .mapboxgl-ctrl-group button {
256 + background: var(--ka-surface);
257 + width: 34px;
258 + height: 34px;
259 +}
260 +
261 +.ka-map .mapboxgl-ctrl-group button + button {
262 + border-top: 1px solid var(--ka-line);
263 +}
264 +
265 +.ka-map .mapboxgl-ctrl-attrib {
266 + font: 400 10px/1.4 var(--ka-font);
267 + background: color-mix(in srgb, var(--ka-surface) 85%, transparent);
268 + border-radius: 6px 0 0 0;
269 +}
270 +
271 +/* ---- Groupe Ka brand badge ---- */
272 +.ka-brand {
273 + position: absolute;
274 + bottom: 14px;
275 + right: 14px;
276 + z-index: 9;
277 + display: flex;
278 + flex-direction: column;
279 + align-items: flex-end;
280 + gap: 1px;
281 + background: var(--ka-surface);
282 + color: var(--ka-ink);
283 + border: 1.5px solid var(--ka-line);
284 + border-radius: var(--ka-radius);
285 + padding: 6px 10px;
286 + pointer-events: none;
287 + box-shadow: var(--ka-shadow);
288 +}
289 +
290 +.ka-brand-name {
291 + font: 700 11px/1.2 var(--ka-font);
292 + letter-spacing: 0.02em;
293 +}
294 +
295 +.ka-brand-sub {
296 + font: 600 8.5px/1.2 var(--ka-font);
297 + text-transform: uppercase;
298 + letter-spacing: 0.12em;
299 + opacity: 0.6;
300 +}
301 +
302 +/* attribution slides left of the badge so both stay readable */
303 +.ka-map .mapboxgl-ctrl-bottom-right {
304 + right: 0;
305 + bottom: 58px;
306 +}
307 +
308 +/* ---- 3D tilt toggle ---- */
309 +.ka-3d {
310 + position: absolute;
311 + top: 96px;
312 + right: 10px;
313 + z-index: 10;
314 + width: 38px;
315 + height: 38px;
316 + border: 1.5px solid var(--ka-line);
317 + border-radius: var(--ka-radius);
318 + background: var(--ka-surface);
319 + color: var(--ka-ink);
320 + font: 700 12px/1 var(--ka-font);
321 + cursor: pointer;
322 + box-shadow: var(--ka-shadow);
323 +}
324 +
325 +.ka-3d.on {
326 + background: var(--ka-accent);
327 + color: var(--ka-on-accent);
328 +}
329 +
330 +/* ---- Dessiner une zone (Ka Draw) ---- */
331 +.ka-draw-btn {
332 + position: absolute;
333 + top: 12px;
334 + left: 12px;
335 + z-index: 10;
336 + border: 1.5px solid var(--ka-line);
337 + background: var(--ka-surface);
338 + color: var(--ka-ink);
339 + font: 600 12.5px/1 var(--ka-font);
340 + padding: 9px 14px;
341 + border-radius: var(--ka-radius);
342 + box-shadow: var(--ka-shadow);
343 + cursor: pointer;
344 + min-height: 38px;
345 +}
346 +.ka-draw-btn.drawing {
347 + background: var(--ka-accent);
348 + color: var(--ka-on-accent);
349 +}
350 +.ka-draw-btn.has-zone {
351 + background: var(--ka-accent);
352 + color: var(--ka-on-accent);
353 +}
354 +@media (max-width: 780px) {
355 + .ka-draw-btn {
356 + top: auto;
357 + bottom: 68px;
358 + left: 12px;
359 + }
360 +}
361 +
362 +/* ---- Tooltip de cluster (fourchette de prix) ---- */
363 +.ka-cluster-tip {
364 + position: absolute;
365 + z-index: 11;
366 + transform: translate(-50%, calc(-100% - 14px));
367 + background: var(--ka-surface);
368 + color: var(--ka-ink);
369 + border: 1.5px solid var(--ka-line);
370 + border-radius: var(--ka-radius);
371 + box-shadow: var(--ka-shadow);
372 + font: 600 12px/1.35 var(--ka-font);
373 + padding: 7px 10px;
374 + pointer-events: none;
375 + white-space: nowrap;
376 +}
377 +.ka-cluster-tip small {
378 + display: block;
379 + font-weight: 500;
380 + opacity: 0.75;
381 +}
added src/styles/kaBaseStyle.ts +82 −0
@@ -0,0 +1,82 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Fond de carte Ka Maps : Mapbox Standard, configuré pour l'immobilier.
7 + * Le style Standard apporte les bâtiments 3D photoréalistes, les repères
8 + * et l'éclairage dynamique. Chaque produit choisit son niveau de réalisme
9 + * via KaBasemapOptions (thème pleine couleur ou « faded », préréglage de
10 + * lumière, repères 3D) — le jour/nuit bascule via lightPreset (Ka Light /
11 + * Ka Dark) sans recharger le style.
12 + */
13 +
14 +import type { Map as MapboxMap } from "mapbox-gl";
15 +import type { KaMapMode, KaMapTheme } from "../theming/tokens.js";
16 +
17 +/** Style Mapbox Standard — 3D natif, repères, éclairage. */
18 +export const KA_STYLE_URL = "mapbox://styles/mapbox/standard";
19 +
20 +export const KA_ATTRIBUTION = "© Mapbox © OpenStreetMap";
21 +
22 +/** Réglages produit du fond Mapbox Standard. */
23 +export interface KaBasemapOptions {
24 + /** "default" = pleine couleur réaliste ; "faded" = atténué (les prix
25 + * dominent) ; "monochrome" = neutre. Défaut : "faded". */
26 + theme?: "default" | "faded" | "monochrome";
27 + /** Préréglage lumière du mode clair (le mode sombre force "dusk").
28 + * "day" à midi, "dawn" pour une lumière rasante plus sculpturale. */
29 + lightPreset?: "dawn" | "day" | "dusk" | "night";
30 + /** Repères 3D emblématiques (stades, ponts, églises…). Défaut : true. */
31 + showLandmarks?: boolean;
32 + /** Étiquettes de commerces/POI. Défaut : false (la carte montre des
33 + * propriétés, pas des commerces). */
34 + showPointOfInterestLabels?: boolean;
35 + /** Couleur du bâtiment « sélectionné » (état select du featureset
36 + * buildings) — utilisée par KaMap.focusBuilding. */
37 + colorBuildingSelect?: string;
38 + /** Couleur du bâtiment « survolé » (état highlight). */
39 + colorBuildingHighlight?: string;
40 +}
41 +
42 +/**
43 + * Retourne l'URL de style pour un mode/thème. (Signature conservée : le
44 + * jour/nuit et les réglages passent par applyKaBasemapConfig, pas par un
45 + * style différent.)
46 + */
47 +export function buildKaStyle(_mode: KaMapMode, _theme: KaMapTheme): string {
48 + return KA_STYLE_URL;
49 +}
50 +
51 +/**
52 + * Applique les réglages du fond Standard — à appeler après style.load et à
53 + * chaque changement de mode. Les propriétés absentes d'une future version
54 + * du style sont ignorées (non bloquant).
55 + */
56 +export function applyKaBasemapConfig(
57 + map: MapboxMap,
58 + mode: KaMapMode,
59 + basemap?: KaBasemapOptions,
60 +): void {
61 + const set = (key: string, value: unknown) => {
62 + try {
63 + map.setConfigProperty("basemap", key, value);
64 + } catch {
65 + // propriété absente sur une future version du style : non bloquant
66 + }
67 + };
68 + set("theme", basemap?.theme ?? "faded");
69 + set(
70 + "lightPreset",
71 + mode === "dark" ? "dusk" : basemap?.lightPreset ?? "day",
72 + );
73 + set("showPointOfInterestLabels", basemap?.showPointOfInterestLabels ?? false);
74 + set("showTransitLabels", false);
75 + set("showPedestrianRoads", true);
76 + set("show3dObjects", true);
77 + set("showLandmarkIcons", basemap?.showLandmarks ?? true);
78 + if (basemap?.colorBuildingSelect)
79 + set("colorBuildingSelect", basemap.colorBuildingSelect);
80 + if (basemap?.colorBuildingHighlight)
81 + set("colorBuildingHighlight", basemap.colorBuildingHighlight);
82 +}
added src/theming/tokens.ts +151 −0
@@ -0,0 +1,151 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Theme contract. Ka Maps ships the base cartography and behavior; each
7 + * Groupe Ka app supplies one of these objects to make the map its own.
8 + */
9 +
10 +export type KaMapMode = "light" | "dark";
11 +
12 +/** Marker/label treatment for one item family (sale, rent, valuation…). */
13 +export interface MarkerStyleTokens {
14 + /** Pill background. */
15 + background: string;
16 + /** Pill text. */
17 + text: string;
18 + /** Halo/outline behind the pill for map contrast. */
19 + halo: string;
20 + /** Background when selected. */
21 + selectedBackground: string;
22 + /** Text when selected. */
23 + selectedText: string;
24 +}
25 +
26 +/** Basemap palette — one per mode, derived from the Ka base cartography. */
27 +export interface BasemapPalette {
28 + background: string;
29 + water: string;
30 + waterway: string;
31 + park: string;
32 + wood: string;
33 + residential: string;
34 + building: string;
35 + buildingOutline: string;
36 + road: string;
37 + roadCasing: string;
38 + roadSecondary: string;
39 + primary: string;
40 + primaryCasing: string;
41 + motorway: string;
42 + motorwayCasing: string;
43 + boundary: string;
44 + label: string;
45 + labelHalo: string;
46 + cityLabel: string;
47 + roadLabel: string;
48 + waterLabel: string;
49 +}
50 +
51 +export interface KaMapTheme {
52 + /** App identity: "lou-ka" | "immo-ka" | "vrai-prix" | future apps. */
53 + id: string;
54 + /** Product name shown in attribution/UI, e.g. "Lou-Ka Maps". */
55 + productName: string;
56 + /** Primary accent (controls, selection ring, active states). */
57 + accent: string;
58 + /** Text color readable on `accent`. */
59 + onAccent: string;
60 + /** UI font stack for controls/popups (CSS font-family). */
61 + fontFamily: string;
62 + /** Marker styles per item family. Absent families fall back to `sale`. */
63 + markers: {
64 + sale: MarkerStyleTokens;
65 + rent?: MarkerStyleTokens;
66 + valuation?: MarkerStyleTokens;
67 + /** Emphasized items (MapProperty.highlight), e.g. under-valuation deals. */
68 + highlight?: MarkerStyleTokens;
69 + };
70 + /** Cluster bubble styling. */
71 + cluster: {
72 + background: string;
73 + text: string;
74 + border: string;
75 + };
76 + /** Optional per-app overrides merged over the Ka base palettes. */
77 + basemapOverrides?: {
78 + light?: Partial<BasemapPalette>;
79 + dark?: Partial<BasemapPalette>;
80 + };
81 + /** Whether the app supports dark mode at all. */
82 + supportsDark: boolean;
83 +}
84 +
85 +/** Ka Light Map — restrained warm-paper cartography for property discovery. */
86 +export const KA_LIGHT_PALETTE: BasemapPalette = {
87 + background: "#f5f3ee",
88 + water: "#c9dbe4",
89 + waterway: "#c2d6e0",
90 + park: "#dbe7d2",
91 + wood: "#d3e2ca",
92 + residential: "#efece5",
93 + building: "#e6e0d5",
94 + buildingOutline: "#d9d2c4",
95 + road: "#ffffff",
96 + roadCasing: "#e0dacd",
97 + roadSecondary: "#fdf8ec",
98 + primary: "#f6d17c",
99 + primaryCasing: "#cfa14a",
100 + motorway: "#e0584c",
101 + motorwayCasing: "#ad342d",
102 + boundary: "#b3a794",
103 + label: "#4d473c",
104 + labelHalo: "rgba(245, 243, 238, 0.9)",
105 + cityLabel: "#38332a",
106 + roadLabel: "#7a7364",
107 + waterLabel: "#5d7f91",
108 +};
109 +
110 +/** Ka Dark Map — purpose-designed night cartography (not an inversion). */
111 +export const KA_DARK_PALETTE: BasemapPalette = {
112 + background: "#14161a",
113 + water: "#0b1218",
114 + waterway: "#0e1620",
115 + park: "#18211a",
116 + wood: "#161f18",
117 + residential: "#171a1f",
118 + building: "#1d2126",
119 + buildingOutline: "#242930",
120 + road: "#242931",
121 + roadCasing: "#0f1114",
122 + roadSecondary: "#2a2f37",
123 + primary: "#3c3a30",
124 + primaryCasing: "#23211b",
125 + motorway: "#7e352c",
126 + motorwayCasing: "#4c1f19",
127 + boundary: "#4a4f58",
128 + label: "#b9bec7",
129 + labelHalo: "rgba(20, 22, 26, 0.9)",
130 + cityLabel: "#dfe3e9",
131 + roadLabel: "#8a9099",
132 + waterLabel: "#5f7f93",
133 +};
134 +
135 +/** Merge app overrides over a base palette. */
136 +export function resolvePalette(
137 + mode: KaMapMode,
138 + theme: KaMapTheme,
139 +): BasemapPalette {
140 + const base = mode === "dark" ? KA_DARK_PALETTE : KA_LIGHT_PALETTE;
141 + const overrides = theme.basemapOverrides?.[mode];
142 + return overrides ? { ...base, ...overrides } : base;
143 +}
144 +
145 +/** Marker tokens for an item family, falling back to `sale`. */
146 +export function markerTokens(
147 + theme: KaMapTheme,
148 + family: "sale" | "rent" | "valuation" | "highlight",
149 +): MarkerStyleTokens {
150 + return theme.markers[family] ?? theme.markers.sale;
151 +}
added src/types/index.ts +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 + * Canonical types shared by every Groupe Ka application.
7 + * Each app maps its own domain model into these shapes at the adapter
8 + * boundary — no app-specific field ever lives here.
9 + */
10 +
11 +/** Which Groupe Ka application produced a map item. */
12 +export type KaAppSource = "lou-ka" | "immo-ka" | "vrai-prix";
13 +
14 +/** Canonical geographic representation of a property-like item. */
15 +export interface MapProperty {
16 + id: string;
17 + appSource: KaAppSource;
18 + latitude: number;
19 + longitude: number;
20 + kind: "listing" | "valuation" | "transaction";
21 + listingType?: "sale" | "rent";
22 + price?: number;
23 + estimatedValue?: number;
24 + /** 0–1 (or letter grade mapped to 0–1 by the adapter). */
25 + valuationConfidence?: number;
26 + propertyType?: string;
27 + bedrooms?: number;
28 + bathrooms?: number;
29 + address?: string;
30 + city?: string;
31 + region?: string;
32 + daysOnMarket?: number;
33 + /** Relative price change since listing, e.g. -0.05 for a 5 % cut. */
34 + priceChange?: number;
35 + thumbnailUrl?: string;
36 + originalUrl?: string;
37 + /** App-defined emphasis (e.g. Immo-Ka "sous l'estimation Vrai-Prix"). */
38 + highlight?: boolean;
39 + /** Opaque app-owned payload carried through to previews/cards
40 + * (availability, source, confidence…). Never read by the framework. */
41 + extra?: Record<string, unknown>;
42 +}
43 +
44 +/** Geographic bounding box, always [west, south, east, north]. */
45 +export interface BBox {
46 + west: number;
47 + south: number;
48 + east: number;
49 + north: number;
50 +}
51 +
52 +/** Serializable map camera + interaction state. */
53 +export interface KaMapState {
54 + center: { lat: number; lng: number };
55 + zoom: number;
56 + bearing: number;
57 + pitch: number;
58 + bounds: BBox | null;
59 + selectedPropertyId: string | null;
60 + hoveredPropertyId: string | null;
61 + activeLayers: string[];
62 + /** True when the viewport moved away from the last searched area. */
63 + searchAreaDirty: boolean;
64 + /** GeoJSON geometry drawn with Ka Lens, if any. */
65 + drawnGeometry: GeoJSON.Polygon | null;
66 +}
67 +
68 +/** Aggregated market statistics for an administrative geography. */
69 +export interface GeographicMarketSummary {
70 + geographyId: string;
71 + geographyType:
72 + | "province"
73 + | "region"
74 + | "municipality"
75 + | "borough"
76 + | "neighbourhood";
77 + name: string;
78 + listingCount: number;
79 + medianListingPrice?: number;
80 + medianSalePrice?: number;
81 + medianEstimatedValue?: number;
82 + medianRent?: number;
83 + medianDaysOnMarket?: number;
84 + change7d?: number;
85 + change30d?: number;
86 + change1y?: number;
87 +}
88 +
89 +/** Metrics accepted by the generic heatmap infrastructure. */
90 +export type HeatmapMetric =
91 + | "listing_density"
92 + | "median_price"
93 + | "estimated_value"
94 + | "price_per_sqft"
95 + | "yoy_change"
96 + | "days_on_market";
97 +
98 +/** Statistics computed by Ka Lens over a drawn or visible area. */
99 +export interface KaLensStats {
100 + count: number;
101 + medianPrice?: number;
102 + medianEstimatedValue?: number;
103 + medianPricePerSqft?: number;
104 + change30d?: number;
105 + change1y?: number;
106 + medianDaysOnMarket?: number;
107 + priceCutShare?: number;
108 + stale90dShare?: number;
109 + /** Property-type mix, e.g. { "Maison": 0.52, "Condo": 0.41 }. */
110 + typeMix?: Record<string, number>;
111 +}
112 +
113 +/** Query sent to an app's data adapter when the viewport settles. */
114 +export interface BoundsQuery {
115 + bbox: BBox;
116 + zoom: number;
117 + /** Opaque app-owned filter payload, forwarded verbatim to the adapter. */
118 + filters?: Record<string, unknown>;
119 + signal: AbortSignal;
120 +}
121 +
122 +/** Result returned by an app's data adapter. */
123 +export interface BoundsQueryResult {
124 + properties: MapProperty[];
125 + /** Total matching server-side (may exceed properties.length). */
126 + totalCount?: number;
127 +}
128 +
129 +/**
130 + * The single integration seam between an app and Ka Maps: how that app's
131 + * listings/valuations become MapProperty objects for a given viewport.
132 + */
133 +export interface KaDataAdapter {
134 + /** Stable id, used for cache keys and telemetry. */
135 + id: string;
136 + appSource: KaAppSource;
137 + fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult>;
138 +}
139 +
140 +/** Declaration of one toggleable map layer in the per-app registry. */
141 +export interface KaLayerDefinition {
142 + id: string;
143 + /** Display group, e.g. "property" | "market" | "land" | "lifestyle" | "investment". */
144 + group: string;
145 + label: string;
146 + /** Only layers with available data should be marked supported. */
147 + supported: boolean;
148 + defaultActive?: boolean;
149 +}
added src/utils/format.ts +73 −0
@@ -0,0 +1,73 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Canadian-French price/value formatting shared by all map labels.
7 + */
8 +
9 +/**
10 + * Compact price label for map markers, French-Canadian style.
11 + *
12 + * 289 000 $ → "289 k$"
13 + * 499 900 $ → "500 k$"
14 + * 1 250 000 $ → "1,25 M$"
15 + * 2 800 000 $ → "2,8 M$"
16 + * 950 $ (loyer) → "950 $"
17 + */
18 +export function formatCompactPrice(value: number): string {
19 + if (!Number.isFinite(value) || value < 0) return "";
20 + if (value < 10_000) {
21 + // Rents and small amounts: whole dollars, non-breaking thin space groups.
22 + return `${formatGroupedInt(Math.round(value))} $`;
23 + }
24 + if (value < 1_000_000) {
25 + const k = Math.round(value / 1000);
26 + if (k >= 1000) return formatMillions(value); // 999 500+ rounds into M$
27 + return `${k} k$`;
28 + }
29 + return formatMillions(value);
30 +}
31 +
32 +function formatMillions(value: number): string {
33 + const m = value / 1_000_000;
34 + // Two decimals under 10 M$, one is enough above; strip trailing zeros.
35 + const rounded = m < 10 ? Math.round(m * 100) / 100 : Math.round(m * 10) / 10;
36 + const text = rounded
37 + .toFixed(rounded < 10 ? 2 : 1)
38 + .replace(/\.?0+$/, "")
39 + .replace(".", ",");
40 + return `${text} M$`;
41 +}
42 +
43 +/** Full price, French-Canadian: 589 000 $ (narrow no-break group separators). */
44 +export function formatFullPrice(value: number): string {
45 + if (!Number.isFinite(value)) return "";
46 + return `${formatGroupedInt(Math.round(value))} $`;
47 +}
48 +
49 +/** Signed percentage, French style: +5,8 % / -1,2 %. */
50 +export function formatPercent(ratio: number, decimals = 1): string {
51 + if (!Number.isFinite(ratio)) return "";
52 + const pct = ratio * 100;
53 + const sign = pct > 0 ? "+" : "";
54 + return `${sign}${pct.toFixed(decimals).replace(".", ",")} %`;
55 +}
56 +
57 +/** Group an integer with narrow no-break spaces: 1250000 → "1 250 000". */
58 +function formatGroupedInt(n: number): string {
59 + const sign = n < 0 ? "-" : "";
60 + const digits = Math.abs(n).toString();
61 + const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
62 + return sign + grouped;
63 +}
64 +
65 +/** Median of a numeric array (returns undefined for empty input). */
66 +export function median(values: number[]): number | undefined {
67 + const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
68 + if (clean.length === 0) return undefined;
69 + const mid = Math.floor(clean.length / 2);
70 + return clean.length % 2 === 1
71 + ? clean[mid]
72 + : ((clean[mid - 1] as number) + (clean[mid] as number)) / 2;
73 +}
added src/utils/geo.ts +176 −0
@@ -0,0 +1,176 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * Geographic helpers: bbox math, coordinate validation, GeoJSON building.
7 + */
8 +
9 +import type { BBox, MapProperty } from "../types/index.js";
10 +
11 +/** Quebec-ish sanity envelope used to reject obviously bad coordinates. */
12 +const LAT_MIN = -90;
13 +const LAT_MAX = 90;
14 +const LNG_MIN = -180;
15 +const LNG_MAX = 180;
16 +
17 +/** True when a lat/lng pair is a plausible, finite coordinate. */
18 +export function isValidCoordinate(lat: unknown, lng: unknown): boolean {
19 + return (
20 + typeof lat === "number" &&
21 + typeof lng === "number" &&
22 + Number.isFinite(lat) &&
23 + Number.isFinite(lng) &&
24 + lat >= LAT_MIN &&
25 + lat <= LAT_MAX &&
26 + lng >= LNG_MIN &&
27 + lng <= LNG_MAX &&
28 + // (0, 0) is the classic failed-geocode sentinel — never a Quebec property.
29 + !(lat === 0 && lng === 0)
30 + );
31 +}
32 +
33 +/** Round a bbox for stable cache keys and short URLs. */
34 +export function roundBBox(bbox: BBox, decimals = 5): BBox {
35 + const f = 10 ** decimals;
36 + const r = (v: number) => Math.round(v * f) / f;
37 + return { west: r(bbox.west), south: r(bbox.south), east: r(bbox.east), north: r(bbox.north) };
38 +}
39 +
40 +/** Serialize as the canonical "west,south,east,north" API parameter. */
41 +export function bboxToString(bbox: BBox, decimals = 5): string {
42 + const r = roundBBox(bbox, decimals);
43 + return `${r.west},${r.south},${r.east},${r.north}`;
44 +}
45 +
46 +/** Parse "west,south,east,north"; returns null when malformed. */
47 +export function parseBBox(text: string): BBox | null {
48 + const parts = text.split(",").map(Number);
49 + if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p))) return null;
50 + const [west, south, east, north] = parts as [number, number, number, number];
51 + if (south > north || west > east) return null;
52 + if (!isValidCoordinate(south, west) || !isValidCoordinate(north, east)) return null;
53 + return { west, south, east, north };
54 +}
55 +
56 +/** Expand a bbox by a ratio (0.2 → 20 % margin) to prefetch around the viewport. */
57 +export function expandBBox(bbox: BBox, ratio: number): BBox {
58 + const dLng = (bbox.east - bbox.west) * ratio;
59 + const dLat = (bbox.north - bbox.south) * ratio;
60 + return {
61 + west: Math.max(LNG_MIN, bbox.west - dLng),
62 + south: Math.max(LAT_MIN, bbox.south - dLat),
63 + east: Math.min(LNG_MAX, bbox.east + dLng),
64 + north: Math.min(LAT_MAX, bbox.north + dLat),
65 + };
66 +}
67 +
68 +/** True when `inner` is fully contained in `outer`. */
69 +export function bboxContains(outer: BBox, inner: BBox): boolean {
70 + return (
71 + inner.west >= outer.west &&
72 + inner.east <= outer.east &&
73 + inner.south >= outer.south &&
74 + inner.north <= outer.north
75 + );
76 +}
77 +
78 +/** True when a point falls inside a bbox. */
79 +export function bboxContainsPoint(bbox: BBox, lat: number, lng: number): boolean {
80 + return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east;
81 +}
82 +
83 +/** Emprise englobant un ensemble de propriétés (fitBounds sur les
84 + * résultats) ; null si aucune coordonnée valide. Un point unique donne
85 + * une emprise dégénérée — prévoir un maxZoom au fitBounds. */
86 +export function bboxOfProperties(properties: MapProperty[]): BBox | null {
87 + let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity;
88 + let n = 0;
89 + for (const p of properties) {
90 + if (!isValidCoordinate(p.latitude, p.longitude)) continue;
91 + n++;
92 + if (p.longitude < west) west = p.longitude;
93 + if (p.longitude > east) east = p.longitude;
94 + if (p.latitude < south) south = p.latitude;
95 + if (p.latitude > north) north = p.latitude;
96 + }
97 + return n === 0 ? null : { west, south, east, north };
98 +}
99 +
100 +/** Test point-dans-polygone (ray casting) — anneau [lng, lat][]. */
101 +export function pointInPolygon(
102 + lng: number,
103 + lat: number,
104 + ring: [number, number][],
105 +): boolean {
106 + let inside = false;
107 + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
108 + const [xi, yi] = ring[i] as [number, number];
109 + const [xj, yj] = ring[j] as [number, number];
110 + if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
111 + inside = !inside;
112 + }
113 + }
114 + return inside;
115 +}
116 +
117 +/** Great-circle distance in metres (haversine). */
118 +export function haversineMeters(
119 + lat1: number,
120 + lng1: number,
121 + lat2: number,
122 + lng2: number,
123 +): number {
124 + const R = 6_371_000;
125 + const toRad = (d: number) => (d * Math.PI) / 180;
126 + const dLat = toRad(lat2 - lat1);
127 + const dLng = toRad(lng2 - lng1);
128 + const a =
129 + Math.sin(dLat / 2) ** 2 +
130 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
131 + return 2 * R * Math.asin(Math.sqrt(a));
132 +}
133 +
134 +/** Build the GeoJSON FeatureCollection fed to the MapLibre property source. */
135 +export function propertiesToGeoJSON(
136 + properties: MapProperty[],
137 +): GeoJSON.FeatureCollection<GeoJSON.Point> {
138 + const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
139 + for (const p of properties) {
140 + if (!isValidCoordinate(p.latitude, p.longitude)) continue;
141 + features.push({
142 + type: "Feature",
143 + id: hashId(p.id),
144 + geometry: { type: "Point", coordinates: [p.longitude, p.latitude] },
145 + properties: {
146 + id: p.id,
147 + kind: p.kind,
148 + listingType: p.listingType ?? null,
149 + // A single numeric "labelValue" drives labels & cluster medians:
150 + // asking price for listings, estimated value for valuations.
151 + labelValue: p.kind === "valuation" ? p.estimatedValue ?? null : p.price ?? null,
152 + price: p.price ?? null,
153 + estimatedValue: p.estimatedValue ?? null,
154 + propertyType: p.propertyType ?? null,
155 + bedrooms: p.bedrooms ?? null,
156 + priceChange: p.priceChange ?? null,
157 + highlight: p.highlight === true ? 1 : 0,
158 + },
159 + });
160 + }
161 + return { type: "FeatureCollection", features };
162 +}
163 +
164 +/**
165 + * MapLibre feature-state requires numeric/string feature ids; app ids are
166 + * strings, so we derive a stable 32-bit hash. Collisions are astronomically
167 + * unlikely within one viewport and only affect hover styling.
168 + */
169 +export function hashId(id: string): number {
170 + let h = 2166136261;
171 + for (let i = 0; i < id.length; i++) {
172 + h ^= id.charCodeAt(i);
173 + h = Math.imul(h, 16777619);
174 + }
175 + return h >>> 0;
176 +}
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 src/utils/url.ts +45 −0
@@ -0,0 +1,45 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * URL state serialization: /map?lat=46.8139&lng=-71.2080&zoom=13
7 + * Each app decides which extra parameters it exposes; Ka Maps only owns
8 + * the camera portion so URLs stay short and stable.
9 + */
10 +
11 +export interface CameraUrlState {
12 + lat: number;
13 + lng: number;
14 + zoom: number;
15 +}
16 +
17 +/** Serialize camera state into URLSearchParams (mutates a copy, returns it). */
18 +export function cameraToParams(
19 + state: CameraUrlState,
20 + base?: URLSearchParams,
21 +): URLSearchParams {
22 + const params = new URLSearchParams(base);
23 + params.set("lat", state.lat.toFixed(5));
24 + params.set("lng", state.lng.toFixed(5));
25 + params.set("zoom", trimZoom(state.zoom));
26 + return params;
27 +}
28 +
29 +/** Parse camera state from URLSearchParams; null when absent or malformed. */
30 +export function cameraFromParams(params: URLSearchParams): CameraUrlState | null {
31 + const rawLat = params.get("lat");
32 + const rawLng = params.get("lng");
33 + const rawZoom = params.get("zoom");
34 + if (rawLat === null || rawLng === null || rawZoom === null) return null;
35 + const lat = Number(rawLat);
36 + const lng = Number(rawLng);
37 + const zoom = Number(rawZoom);
38 + if (!Number.isFinite(lat) || !Number.isFinite(lng) || !Number.isFinite(zoom)) return null;
39 + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
40 + return { lat, lng, zoom: Math.min(22, Math.max(0, zoom)) };
41 +}
42 +
43 +function trimZoom(zoom: number): string {
44 + return (Math.round(zoom * 100) / 100).toString();
45 +}
added tests/boundsQuery.test.ts +134 −0
@@ -0,0 +1,134 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + */
6 +
7 +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8 +import {
9 + BoundsQueryScheduler,
10 + stableStringify,
11 +} from "../src/services/boundsQuery.js";
12 +import type {
13 + BoundsQuery,
14 + BoundsQueryResult,
15 + KaDataAdapter,
16 + MapProperty,
17 +} from "../src/types/index.js";
18 +
19 +const BOX = { west: -71.4, south: 46.7, east: -71.1, north: 46.9 };
20 +
21 +function prop(id: string): MapProperty {
22 + return {
23 + id,
24 + appSource: "lou-ka",
25 + latitude: 46.8,
26 + longitude: -71.2,
27 + kind: "listing",
28 + price: 1000,
29 + };
30 +}
31 +
32 +function makeAdapter(
33 + impl: (q: BoundsQuery) => Promise<BoundsQueryResult>,
34 +): KaDataAdapter {
35 + return { id: "test", appSource: "lou-ka", fetchInBounds: impl };
36 +}
37 +
38 +beforeEach(() => vi.useFakeTimers());
39 +afterEach(() => vi.useRealTimers());
40 +
41 +describe("BoundsQueryScheduler", () => {
42 + it("debounces bursts into one fetch", async () => {
43 + const calls: BoundsQuery[] = [];
44 + const s = new BoundsQueryScheduler(
45 + makeAdapter(async (q) => {
46 + calls.push(q);
47 + return { properties: [prop("a")] };
48 + }),
49 + { debounceMs: 100 },
50 + );
51 + s.request({ bbox: BOX, zoom: 12 });
52 + s.request({ bbox: BOX, zoom: 12.5 });
53 + s.request({ bbox: BOX, zoom: 13 });
54 + await vi.advanceTimersByTimeAsync(150);
55 + expect(calls).toHaveLength(1);
56 + expect(calls[0]!.zoom).toBe(13);
57 + s.destroy();
58 + });
59 +
60 + it("aborts the stale request and never delivers old results late", async () => {
61 + const results: string[] = [];
62 + let call = 0;
63 + const s = new BoundsQueryScheduler(
64 + makeAdapter((q) => {
65 + const mine = ++call;
66 + return new Promise((resolve, reject) => {
67 + q.signal.addEventListener("abort", () =>
68 + reject(new DOMException("aborted", "AbortError")),
69 + );
70 + // First call resolves slowly, second quickly.
71 + setTimeout(
72 + () => resolve({ properties: [prop(`r${mine}`)] }),
73 + mine === 1 ? 500 : 10,
74 + );
75 + });
76 + }),
77 + { debounceMs: 0, cacheTtlMs: 0 },
78 + );
79 + s.onResult((r) => results.push(r.properties[0]!.id));
80 +
81 + s.requestNow({ bbox: BOX, zoom: 10 });
82 + await vi.advanceTimersByTimeAsync(5);
83 + s.requestNow({ bbox: { ...BOX, north: 47.0 }, zoom: 11 });
84 + await vi.advanceTimersByTimeAsync(600);
85 +
86 + expect(results).toEqual(["r2"]);
87 + s.destroy();
88 + });
89 +
90 + it("serves identical queries from cache", async () => {
91 + let fetches = 0;
92 + const s = new BoundsQueryScheduler(
93 + makeAdapter(async () => {
94 + fetches++;
95 + return { properties: [prop("a")], totalCount: 1 };
96 + }),
97 + { debounceMs: 0 },
98 + );
99 + const seen: number[] = [];
100 + s.onResult((r) => seen.push(r.totalCount ?? 0));
101 +
102 + s.requestNow({ bbox: BOX, zoom: 12, filters: { city: "Québec" } });
103 + await vi.advanceTimersByTimeAsync(10);
104 + s.requestNow({ bbox: BOX, zoom: 12, filters: { city: "Québec" } });
105 + await vi.advanceTimersByTimeAsync(10);
106 +
107 + expect(fetches).toBe(1);
108 + expect(seen).toHaveLength(2);
109 + s.destroy();
110 + });
111 +
112 + it("reports errors only for the newest request", async () => {
113 + const errors: unknown[] = [];
114 + const s = new BoundsQueryScheduler(
115 + makeAdapter(async () => {
116 + throw new Error("boom");
117 + }),
118 + { debounceMs: 0 },
119 + );
120 + s.onError((e) => errors.push(e));
121 + s.requestNow({ bbox: BOX, zoom: 12 });
122 + await vi.advanceTimersByTimeAsync(10);
123 + expect(errors).toHaveLength(1);
124 + s.destroy();
125 + });
126 +});
127 +
128 +describe("stableStringify", () => {
129 + it("is key-order independent", () => {
130 + expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe(
131 + stableStringify({ a: { c: 3, d: 2 }, b: 1 }),
132 + );
133 + });
134 +});
added tests/drawGeo.test.ts +63 −0
@@ -0,0 +1,63 @@
1 +/**
2 + * Author: Simon-Pierre Boucher
3 + * Contact: contact@spboucher.ai
4 + * Project: Groupe Ka / Ka Maps
5 + *
6 + * bboxOfProperties + pointInPolygon — les briques géométriques de la
7 + * synchronisation liste ↔ carte (fitBounds sur les résultats, zone dessinée).
8 + */
9 +import { describe, expect, it } from "vitest";
10 +import { bboxOfProperties, pointInPolygon } from "../src/utils/geo.js";
11 +import type { MapProperty } from "../src/types/index.js";
12 +
13 +const prop = (lat: number, lng: number): MapProperty => ({
14 + id: `${lat},${lng}`,
15 + appSource: "lou-ka",
16 + latitude: lat,
17 + longitude: lng,
18 + kind: "listing",
19 +});
20 +
21 +describe("bboxOfProperties", () => {
22 + it("englobe tous les points", () => {
23 + const bbox = bboxOfProperties([prop(46.8, -71.2), prop(45.5, -73.6), prop(46.3, -72.5)]);
24 + expect(bbox).toEqual({ west: -73.6, south: 45.5, east: -71.2, north: 46.8 });
25 + });
26 +
27 + it("ignore les coordonnées invalides (0,0 sentinelle)", () => {
28 + const bbox = bboxOfProperties([prop(0, 0), prop(46.8, -71.2)]);
29 + expect(bbox).toEqual({ west: -71.2, south: 46.8, east: -71.2, north: 46.8 });
30 + });
31 +
32 + it("null quand rien n'est géolocalisé", () => {
33 + expect(bboxOfProperties([prop(0, 0)])).toBeNull();
34 + expect(bboxOfProperties([])).toBeNull();
35 + });
36 +});
37 +
38 +describe("pointInPolygon", () => {
39 + // triangle autour du Vieux-Québec
40 + const ring: [number, number][] = [
41 + [-71.24, 46.80],
42 + [-71.20, 46.82],
43 + [-71.24, 46.83],
44 + ];
45 +
46 + it("dedans", () => {
47 + expect(pointInPolygon(-71.228, 46.817, ring)).toBe(true);
48 + });
49 +
50 + it("dehors", () => {
51 + expect(pointInPolygon(-71.30, 46.81, ring)).toBe(false);
52 + expect(pointInPolygon(-71.21, 46.79, ring)).toBe(false);
53 + });
54 +
55 + it("polygone concave", () => {
56 + const u: [number, number][] = [
57 + [0, 0], [4, 0], [4, 4], [3, 4], [3, 1], [1, 1], [1, 4], [0, 4],
58 + ];
59 + expect(pointInPolygon(0.5, 2, u)).toBe(true); // branche gauche du U
60 + expect(pointInPolygon(2, 2, u)).toBe(false); // creux du U
61 + expect(pointInPolygon(3.5, 2, u)).toBe(true); // branche droite
62 + });
63 +});
added tests/format.test.ts +62 −0
@@ -0,0 +1,62 @@
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 + formatCompactPrice,
10 + formatFullPrice,
11 + formatPercent,
12 + median,
13 +} from "../src/utils/format.js";
14 +
15 +describe("formatCompactPrice", () => {
16 + it("formats the spec examples", () => {
17 + expect(formatCompactPrice(289_000)).toBe("289 k$");
18 + expect(formatCompactPrice(499_900)).toBe("500 k$");
19 + expect(formatCompactPrice(1_250_000)).toBe("1,25 M$");
20 + expect(formatCompactPrice(2_800_000)).toBe("2,8 M$");
21 + });
22 +
23 + it("keeps rents in whole dollars", () => {
24 + expect(formatCompactPrice(950)).toBe("950 $");
25 + // Group separator is U+202F (narrow no-break space), fr-CA convention.
26 + expect(formatCompactPrice(1450)).toBe("1 450 $");
27 + });
28 +
29 + it("rounds 999 500+ into millions", () => {
30 + expect(formatCompactPrice(999_600)).toBe("1 M$");
31 + });
32 +
33 + it("handles large and degenerate values", () => {
34 + expect(formatCompactPrice(12_300_000)).toBe("12,3 M$");
35 + expect(formatCompactPrice(Number.NaN)).toBe("");
36 + expect(formatCompactPrice(-5)).toBe("");
37 + });
38 +});
39 +
40 +describe("formatFullPrice", () => {
41 + it("groups with narrow no-break spaces", () => {
42 + expect(formatFullPrice(589_000)).toBe("589 000 $");
43 + expect(formatFullPrice(1_250_000)).toBe("1 250 000 $");
44 + });
45 +});
46 +
47 +describe("formatPercent", () => {
48 + it("signs and localizes", () => {
49 + expect(formatPercent(0.058)).toBe("+5,8 %");
50 + expect(formatPercent(-0.012)).toBe("-1,2 %");
51 + expect(formatPercent(0)).toBe("0,0 %");
52 + });
53 +});
54 +
55 +describe("median", () => {
56 + it("computes odd/even medians and ignores junk", () => {
57 + expect(median([3, 1, 2])).toBe(2);
58 + expect(median([4, 1, 2, 3])).toBe(2.5);
59 + expect(median([1, Number.NaN, 3])).toBe(2);
60 + expect(median([])).toBeUndefined();
61 + });
62 +});
added tests/geo.test.ts +114 −0
@@ -0,0 +1,114 @@
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 + bboxContains,
10 + bboxContainsPoint,
11 + bboxToString,
12 + expandBBox,
13 + hashId,
14 + haversineMeters,
15 + isValidCoordinate,
16 + parseBBox,
17 + propertiesToGeoJSON,
18 +} from "../src/utils/geo.js";
19 +import type { MapProperty } from "../src/types/index.js";
20 +
21 +const QC = { west: -71.4, south: 46.7, east: -71.1, north: 46.9 };
22 +
23 +describe("isValidCoordinate", () => {
24 + it("accepts Quebec coordinates", () => {
25 + expect(isValidCoordinate(46.8139, -71.208)).toBe(true);
26 + });
27 + it("rejects (0,0), NaN, strings and out-of-range", () => {
28 + expect(isValidCoordinate(0, 0)).toBe(false);
29 + expect(isValidCoordinate(Number.NaN, -71)).toBe(false);
30 + expect(isValidCoordinate("46" as unknown, -71)).toBe(false);
31 + expect(isValidCoordinate(95, -71)).toBe(false);
32 + expect(isValidCoordinate(46, -190)).toBe(false);
33 + });
34 +});
35 +
36 +describe("bbox round-trip", () => {
37 + it("serializes west,south,east,north and parses back", () => {
38 + const text = bboxToString(QC);
39 + expect(text).toBe("-71.4,46.7,-71.1,46.9");
40 + expect(parseBBox(text)).toEqual(QC);
41 + });
42 + it("rejects malformed strings", () => {
43 + expect(parseBBox("1,2,3")).toBeNull();
44 + expect(parseBBox("a,b,c,d")).toBeNull();
45 + expect(parseBBox("-71.1,46.9,-71.4,46.7")).toBeNull(); // inverted
46 + });
47 +});
48 +
49 +describe("bbox math", () => {
50 + it("expands symmetrically", () => {
51 + const e = expandBBox(QC, 0.5);
52 + expect(e.west).toBeCloseTo(-71.55);
53 + expect(e.east).toBeCloseTo(-70.95);
54 + expect(e.south).toBeCloseTo(46.6);
55 + expect(e.north).toBeCloseTo(47.0);
56 + });
57 + it("contains inner boxes and points", () => {
58 + expect(bboxContains(expandBBox(QC, 0.2), QC)).toBe(true);
59 + expect(bboxContains(QC, expandBBox(QC, 0.2))).toBe(false);
60 + expect(bboxContainsPoint(QC, 46.8, -71.2)).toBe(true);
61 + expect(bboxContainsPoint(QC, 45.5, -73.6)).toBe(false);
62 + });
63 +});
64 +
65 +describe("haversineMeters", () => {
66 + it("measures Québec→Montréal at ~233 km", () => {
67 + const d = haversineMeters(46.8139, -71.208, 45.5019, -73.5674);
68 + expect(d).toBeGreaterThan(220_000);
69 + expect(d).toBeLessThan(245_000);
70 + });
71 +});
72 +
73 +describe("propertiesToGeoJSON", () => {
74 + const base: MapProperty = {
75 + id: "lou:1",
76 + appSource: "lou-ka",
77 + latitude: 46.81,
78 + longitude: -71.21,
79 + kind: "listing",
80 + listingType: "rent",
81 + price: 1450,
82 + };
83 +
84 + it("builds point features with labelValue = price for listings", () => {
85 + const fc = propertiesToGeoJSON([base]);
86 + expect(fc.features).toHaveLength(1);
87 + const f = fc.features[0]!;
88 + expect(f.geometry.coordinates).toEqual([-71.21, 46.81]);
89 + expect(f.properties?.["labelValue"]).toBe(1450);
90 + expect(f.id).toBe(hashId("lou:1"));
91 + });
92 +
93 + it("uses estimatedValue as labelValue for valuations", () => {
94 + const fc = propertiesToGeoJSON([
95 + {
96 + ...base,
97 + id: "vp:9",
98 + appSource: "vrai-prix",
99 + kind: "valuation",
100 + price: undefined,
101 + estimatedValue: 512_000,
102 + },
103 + ]);
104 + expect(fc.features[0]!.properties?.["labelValue"]).toBe(512_000);
105 + });
106 +
107 + it("drops invalid coordinates silently", () => {
108 + const fc = propertiesToGeoJSON([
109 + { ...base, id: "bad", latitude: 0, longitude: 0 },
110 + base,
111 + ]);
112 + expect(fc.features).toHaveLength(1);
113 + });
114 +});
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 +});
added tests/url.test.ts +37 −0
@@ -0,0 +1,37 @@
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 { cameraFromParams, cameraToParams } from "../src/utils/url.js";
9 +
10 +describe("camera URL state", () => {
11 + it("round-trips lat/lng/zoom", () => {
12 + const params = cameraToParams({ lat: 46.8139, lng: -71.208, zoom: 13 });
13 + expect(params.get("lat")).toBe("46.81390");
14 + expect(params.get("lng")).toBe("-71.20800");
15 + expect(params.get("zoom")).toBe("13");
16 + expect(cameraFromParams(params)).toEqual({ lat: 46.8139, lng: -71.208, zoom: 13 });
17 + });
18 +
19 + it("preserves unrelated params", () => {
20 + const base = new URLSearchParams("city=Québec&view=carte");
21 + const params = cameraToParams({ lat: 46.8, lng: -71.2, zoom: 12.25 }, base);
22 + expect(params.get("city")).toBe("Québec");
23 + expect(params.get("zoom")).toBe("12.25");
24 + });
25 +
26 + it("returns null on malformed or missing values", () => {
27 + expect(cameraFromParams(new URLSearchParams(""))).toBeNull();
28 + expect(cameraFromParams(new URLSearchParams("lat=x&lng=1&zoom=2"))).toBeNull();
29 + expect(cameraFromParams(new URLSearchParams("lat=99&lng=1&zoom=2"))).toBeNull();
30 + });
31 +
32 + it("clamps zoom into [0,22]", () => {
33 + expect(
34 + cameraFromParams(new URLSearchParams("lat=46&lng=-71&zoom=40"))?.zoom,
35 + ).toBe(22);
36 + });
37 +});
added tsconfig.build.json +13 −0
@@ -0,0 +1,13 @@
1 +{
2 + "extends": "./tsconfig.json",
3 + "compilerOptions": {
4 + "noEmit": false,
5 + "declaration": true,
6 + "declarationMap": true,
7 + "sourceMap": true,
8 + "outDir": "dist",
9 + "rootDir": "src"
10 + },
11 + "include": ["src"],
12 + "exclude": ["tests", "**/*.test.ts", "**/*.test.tsx"]
13 +}
added tsconfig.json +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "module": "ESNext",
5 + "moduleResolution": "bundler",
6 + "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 + "jsx": "react-jsx",
8 + "strict": true,
9 + "noUncheckedIndexedAccess": true,
10 + "noImplicitOverride": true,
11 + "forceConsistentCasingInFileNames": true,
12 + "skipLibCheck": true,
13 + "noEmit": true,
14 + "esModuleInterop": true,
15 + "isolatedModules": true
16 + },
17 + "include": ["src", "tests"]
18 +}
19