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>
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 | ||