SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%

feat: ValoPlex — moteur d'évaluation spécialisé plex du Québec

393 867 plex (2+ logements), modèle hédonique en ratio prix/rôle avec
calibration conforme Mondrian par gabarit, ajustement par porte, pro forma
d'investisseur complet (hypothèque canadienne, projection 5 ans, point mort,
droits de mutation), rapports PDF. Identité orange #ff9f45.

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

Showing 68 changed files with +16,147 and −0

added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +# dépendances / build
2 +app/node_modules/
3 +app/.next/
4 +*.tsbuildinfo
5 +app/next-env.d.ts
6 +
7 +# données — jamais versionnées (base SQLite, modèles, estimations)
8 +app/data/
9 +data/
10 +models/
11 +
12 +# divers
13 +.DS_Store
14 +*.log
15 +app/test-results/
16 +app/playwright-report/
17 +__pycache__/
added README.md +108 −0
@@ -0,0 +1,108 @@
1 +# ValoPlex
2 +
3 +> **La vraie valeur de votre plex, porte par porte.**
4 +> Le moteur d'évaluation spécialisé plex du Québec — 393 867 immeubles de 2 logements et plus, 1 733 744 portes, un modèle hédonique en ratio à l'épreuve des échelles.
5 +
6 +![Plex](https://img.shields.io/badge/plex-393%C2%A0867-ff9f45?style=flat-square)
7 +![Portes](https://img.shields.io/badge/portes-1%C2%A0733%C2%A0744-ff9f45?style=flat-square)
8 +![Ventes](https://img.shields.io/badge/ventes_de_plex-91%C2%A0060-ff9f45?style=flat-square)
9 +![MdAPE](https://img.shields.io/badge/MdAPE-13,8%25-b25f16?style=flat-square)
10 +![Couverture](https://img.shields.io/badge/fourchette_calibr%C3%A9e-80,0%25-b25f16?style=flat-square)
11 +![Valeur totale](https://img.shields.io/badge/valeur_totale_plex_QC-400,6%C2%A0G%24-b25f16?style=flat-square)
12 +![Tests](https://img.shields.io/badge/tests-33%2F33-141814?style=flat-square)
13 +![Next.js](https://img.shields.io/badge/Next.js-16-141814?style=flat-square&logo=nextdotjs)
14 +![TypeScript](https://img.shields.io/badge/TypeScript-strict-141814?style=flat-square&logo=typescript)
15 +
16 +**En production : [www.valoplex.com](https://www.valoplex.com)** · petit frère spécialisé de [Vrai-Prix](https://www.vrai-prix.com)
17 +
18 +---
19 +
20 +## Pourquoi un moteur spécialisé plex ?
21 +
22 +Un plex est un **immeuble à revenus** : sa valeur se pense par porte, par revenu, par TGA — pas comme une unifamiliale. ValoPlex couvre **tous** les immeubles résidentiels multi-logements du Québec (CUBF 1000, 2 logements et plus — du duplex à la tour) avec un modèle et une interface dédiés à cette logique.
23 +
24 +| Fonctionnalité | Description |
25 +|---|---|
26 +| 🚪 Économie par porte | Valeur/porte, rôle/porte, aire/porte, terrain/porte + benchmark vs marché local |
27 +| 🧮 Pro forma investisseur | Loyer implicite, état des résultats complet, hypothèque canadienne, DSCR, cashflow/porte, projection 5 ans, point mort, sensibilité aux taux — interactif |
28 +| 💸 Frais « tout le kit » | Droits de mutation (barème QC + barème bonifié de Montréal), notaire, inspection, liquidités totales |
29 +| 🏢 Frise des portes | Une porte SVG dessinée par logement (signature) + badge DUPLEX/TRIPLEX/…/MULTI N PORTES |
30 +| 📍 Adresses officielles | Toutes les inscriptions d'adresses de l'unité (table des adresses du rôle) |
31 +| ⚖️ Ajustement portes | 4ᵉ ajustement des comparables : 50 % du prix/porte, plafonné ±30 % |
32 +| 🗺️ Plan des comparables | Carte plan d'arpenteur SVG (azimut + distance réels), état vide honnête si aucun plex comparable |
33 +| 📄 Rapports PDF | Standard 3 p. (avec pro forma), professionnel bancaire 6 p., parc, statistique provincial |
34 +| 📉 Statistiques | Les plex du Québec valent **400,6 G$** — répartition par gabarit et par municipalité |
35 +
36 +## Le modèle : hédonique en ratio, calibré par gabarit
37 +
38 +Trois idées font la différence :
39 +
40 +1. **Cible en ratio `log(prix / valeur au rôle)`** — pas le prix. Un modèle en prix brut, dominé par la masse des duplex, écrase les grands immeubles (nous avons mesuré une tour au rôle de 705 M$ prédite… 5,6 M$). En ratio, le duplex de 400 k$ et la tour de 700 M$ sont évalués avec la même justesse relative. Le modèle connaît en plus **l'ancienneté du rôle** (mois depuis la date des conditions du marché du dénominateur).
41 +2. **Rétrécissement hors domaine** : au-delà de 12 portes (données de ventes rares), l'estimation glisse vers l'ancre empirique de sa bande de gabarit (poids du modèle : 55 % → 20 %).
42 +3. **Calibration conforme Mondrian** : les fourchettes P10–P90 sont calibrées **par bande de portes** sur un ensemble de calibration dédié — couverture vérifiée de **80,0 %** (pas promise : mesurée).
43 +
44 +### Validation (norme IAAO, 13 617 ventes de test jamais vues)
45 +
46 +| Segment | MdAPE | Ratio médian |
47 +|---|---|---|
48 +| Tous les plex | **13,8 %** | 0,976 |
49 +| Duplex (2 portes) | 13,9 % | 0,970 |
50 +| Triplex (3 portes) | **12,6 %** | 0,983 |
51 +| 4–5 portes | 14,1 % | 0,971 |
52 +| 6–12 portes | 17,9 % | 0,955 |
53 +| Test temporel 2026 (9 172 ventes) | 13,8 % | 0,972 |
54 +| Couverture de la fourchette (calibrée) | **79,9 ≈ 80 %** | — |
55 +
56 +## Le pro forma (`app/src/lib/proforma.ts` — 20 tests)
57 +
58 +Approche revenu **inversée** : quelle structure de revenus la valeur estimée suppose-t-elle ?
59 +
60 +- Loyer implicite en forme fermée : `RB = (V·TGA + F) / (1 − vacance − %variables)`
61 +- Dépenses détaillées : taxes municipales et scolaire (dérivées du rôle), assurances, entretien, gestion, déneigement, énergie des communs, réserve
62 +- Hypothèque **canadienne** (composition semestrielle), cashflow/porte/mois, DSCR, cash-on-cash
63 +- Capital remboursé an 1, **projection 5 ans** (équité, gain total, multiple sur liquidités)
64 +- **Loyer de point mort** et **taux de point mort** (bissection), sensibilité taux ±1 %
65 +- Droits de mutation : barème provincial + barème bonifié de Montréal (jusqu'à 4 %)
66 +
67 +Cinq curseurs (taux, mise de fonds, amortissement, TGA, appréciation) — tout recalcule en direct.
68 +
69 +## Structure du dépôt
70 +
71 +```
72 +valoplex/
73 +├── app/ # application Next.js 16 (TypeScript strict, Tailwind 4)
74 +│ ├── src/lib/ # engine.ts (comparables), proforma.ts, report*.ts (pdfkit)
75 +│ ├── src/components/ # RadarMap, MetricViz, Proforma, DoorsRow…
76 +│ └── src/app/ # pages + API routes
77 +└── scripts/ # pipeline données/modèle (Python)
78 + ├── hedonic_plex.py # entraînement (ratio + Mondrian) et prédictions 2021-2026
79 + └── build_valoplex_db.py # base SQLite + stats provinciales
80 +```
81 +
82 +## Démarrage
83 +
84 +```bash
85 +cd app && npm install
86 +# placer la base dans app/data/valoplex.db (non versionnée)
87 +npm run dev
88 +npm run test # 33 tests (moteur + finance)
89 +```
90 +
91 +Pipeline (nécessite les rôles MAMH et les transactions enrichies, non versionnés) :
92 +
93 +```bash
94 +python3 scripts/hedonic_plex.py train && python3 scripts/hedonic_plex.py predict
95 +python3 scripts/build_valoplex_db.py
96 +```
97 +
98 +> **Note données** : base SQLite (165 Mo), modèles entraînés et parquets d'estimation non versionnés. Données sources : [rôles d'évaluation foncière du Québec](https://www.donneesquebec.ca/recherche/dataset/roles-d-evaluation-fonciere-du-quebec) (MAMH, licence ouverte).
99 +
100 +### Design
101 +
102 +Identité « éditorial sharp » : papier grainé `#f5f3ee`, encre `#141814`, **accent orange vif `#ff9f45`**, ocre brûlé `#b25f16` — sœur chromatique de Vrai-Prix (rouge).
103 +
104 +## Auteur
105 +
106 +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
107 +
108 +*Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ) ni un conseil financier. Voir [conditions](https://www.valoplex.com/conditions) et [méthodologie](https://www.valoplex.com/methodologie).*
added app/.gitignore +41 −0
@@ -0,0 +1,41 @@
1 +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 +
3 +# dependencies
4 +/node_modules
5 +/.pnp
6 +.pnp.*
7 +.yarn/*
8 +!.yarn/patches
9 +!.yarn/plugins
10 +!.yarn/releases
11 +!.yarn/versions
12 +
13 +# testing
14 +/coverage
15 +
16 +# next.js
17 +/.next/
18 +/out/
19 +
20 +# production
21 +/build
22 +
23 +# misc
24 +.DS_Store
25 +*.pem
26 +
27 +# debug
28 +npm-debug.log*
29 +yarn-debug.log*
30 +yarn-error.log*
31 +.pnpm-debug.log*
32 +
33 +# env files (can opt-in for committing if needed)
34 +.env*
35 +
36 +# vercel
37 +.vercel
38 +
39 +# typescript
40 +*.tsbuildinfo
41 +next-env.d.ts
added app/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added app/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added app/README.md +3 −0
@@ -0,0 +1,3 @@
1 +# ValoPlex — application
2 +
3 +Voir le [README principal](../README.md) à la racine du dépôt.
added app/assets/fonts/Inter-Regular.ttf +0 −0

Binary file not shown.

added app/assets/fonts/JetBrainsMono-Bold.ttf +0 −0

Binary file not shown.

added app/assets/fonts/JetBrainsMono-Regular.ttf +0 −0

Binary file not shown.

added app/assets/fonts/SpaceGrotesk-Bold.ttf +0 −0

Binary file not shown.

added app/assets/fonts/SpaceGrotesk-Medium.ttf +0 −0

Binary file not shown.

added app/eslint.config.mjs +18 −0
@@ -0,0 +1,18 @@
1 +import { defineConfig, globalIgnores } from "eslint/config";
2 +import nextVitals from "eslint-config-next/core-web-vitals";
3 +import nextTs from "eslint-config-next/typescript";
4 +
5 +const eslintConfig = defineConfig([
6 + ...nextVitals,
7 + ...nextTs,
8 + // Override default ignores of eslint-config-next.
9 + globalIgnores([
10 + // Default ignores of eslint-config-next:
11 + ".next/**",
12 + "out/**",
13 + "build/**",
14 + "next-env.d.ts",
15 + ]),
16 +]);
17 +
18 +export default eslintConfig;
added app/next.config.ts +8 −0
@@ -0,0 +1,8 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { NextConfig } from "next";
3 +
4 +const nextConfig: NextConfig = {
5 + serverExternalPackages: ["better-sqlite3", "pdfkit"],
6 +};
7 +
8 +export default nextConfig;
added app/package-lock.json +8233 −0
@@ -0,0 +1,8233 @@
1 +{
2 + "name": "valoplex",
3 + "version": "0.1.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "valoplex",
9 + "version": "0.1.0",
10 + "dependencies": {
11 + "better-sqlite3": "^13.0.3",
12 + "next": "16.3.0",
13 + "pdfkit": "^0.19.1",
14 + "react": "19.2.8",
15 + "react-dom": "19.2.8"
16 + },
17 + "devDependencies": {
18 + "@tailwindcss/postcss": "^4",
19 + "@types/better-sqlite3": "^9.6.0",
20 + "@types/node": "^20",
21 + "@types/pdfkit": "^0.17.6",
22 + "@types/react": "^19",
23 + "@types/react-dom": "^19",
24 + "eslint": "^9",
25 + "eslint-config-next": "16.3.0",
26 + "playwright": "^1.62.1",
27 + "tailwindcss": "^4",
28 + "typescript": "^5",
29 + "vitest": "^4.1.10"
30 + }
31 + },
32 + "node_modules/@alloc/quick-lru": {
33 + "version": "5.2.0",
34 + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
35 + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
36 + "dev": true,
37 + "license": "MIT",
38 + "engines": {
39 + "node": ">=10"
40 + },
41 + "funding": {
42 + "url": "https://github.com/sponsors/sindresorhus"
43 + }
44 + },
45 + "node_modules/@babel/code-frame": {
46 + "version": "7.29.7",
47 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
48 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
49 + "dev": true,
50 + "license": "MIT",
51 + "dependencies": {
52 + "@babel/helper-validator-identifier": "^7.29.7",
53 + "js-tokens": "^4.0.0",
54 + "picocolors": "^1.1.1"
55 + },
56 + "engines": {
57 + "node": ">=6.9.0"
58 + }
59 + },
60 + "node_modules/@babel/compat-data": {
61 + "version": "7.29.7",
62 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
63 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
64 + "dev": true,
65 + "license": "MIT",
66 + "engines": {
67 + "node": ">=6.9.0"
68 + }
69 + },
70 + "node_modules/@babel/core": {
71 + "version": "7.29.7",
72 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
73 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
74 + "dev": true,
75 + "license": "MIT",
76 + "dependencies": {
77 + "@babel/code-frame": "^7.29.7",
78 + "@babel/generator": "^7.29.7",
79 + "@babel/helper-compilation-targets": "^7.29.7",
80 + "@babel/helper-module-transforms": "^7.29.7",
81 + "@babel/helpers": "^7.29.7",
82 + "@babel/parser": "^7.29.7",
83 + "@babel/template": "^7.29.7",
84 + "@babel/traverse": "^7.29.7",
85 + "@babel/types": "^7.29.7",
86 + "@jridgewell/remapping": "^2.3.5",
87 + "convert-source-map": "^2.0.0",
88 + "debug": "^4.1.0",
89 + "gensync": "^1.0.0-beta.2",
90 + "json5": "^2.2.3",
91 + "semver": "^6.3.1"
92 + },
93 + "engines": {
94 + "node": ">=6.9.0"
95 + },
96 + "funding": {
97 + "type": "opencollective",
98 + "url": "https://opencollective.com/babel"
99 + }
100 + },
101 + "node_modules/@babel/generator": {
102 + "version": "7.29.8",
103 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
104 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
105 + "dev": true,
106 + "license": "MIT",
107 + "dependencies": {
108 + "@babel/parser": "^7.29.8",
109 + "@babel/types": "^7.29.8",
110 + "@jridgewell/gen-mapping": "^0.3.12",
111 + "@jridgewell/trace-mapping": "^0.3.28",
112 + "jsesc": "^3.0.2"
113 + },
114 + "engines": {
115 + "node": ">=6.9.0"
116 + }
117 + },
118 + "node_modules/@babel/helper-compilation-targets": {
119 + "version": "7.29.7",
120 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
121 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
122 + "dev": true,
123 + "license": "MIT",
124 + "dependencies": {
125 + "@babel/compat-data": "^7.29.7",
126 + "@babel/helper-validator-option": "^7.29.7",
127 + "browserslist": "^4.24.0",
128 + "lru-cache": "^5.1.1",
129 + "semver": "^6.3.1"
130 + },
131 + "engines": {
132 + "node": ">=6.9.0"
133 + }
134 + },
135 + "node_modules/@babel/helper-globals": {
136 + "version": "7.29.7",
137 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
138 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
139 + "dev": true,
140 + "license": "MIT",
141 + "engines": {
142 + "node": ">=6.9.0"
143 + }
144 + },
145 + "node_modules/@babel/helper-module-imports": {
146 + "version": "7.29.7",
147 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
148 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
149 + "dev": true,
150 + "license": "MIT",
151 + "dependencies": {
152 + "@babel/traverse": "^7.29.7",
153 + "@babel/types": "^7.29.7"
154 + },
155 + "engines": {
156 + "node": ">=6.9.0"
157 + }
158 + },
159 + "node_modules/@babel/helper-module-transforms": {
160 + "version": "7.29.7",
161 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
162 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
163 + "dev": true,
164 + "license": "MIT",
165 + "dependencies": {
166 + "@babel/helper-module-imports": "^7.29.7",
167 + "@babel/helper-validator-identifier": "^7.29.7",
168 + "@babel/traverse": "^7.29.7"
169 + },
170 + "engines": {
171 + "node": ">=6.9.0"
172 + },
173 + "peerDependencies": {
174 + "@babel/core": "^7.0.0"
175 + }
176 + },
177 + "node_modules/@babel/helper-string-parser": {
178 + "version": "7.29.7",
179 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
180 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
181 + "dev": true,
182 + "license": "MIT",
183 + "engines": {
184 + "node": ">=6.9.0"
185 + }
186 + },
187 + "node_modules/@babel/helper-validator-identifier": {
188 + "version": "7.29.7",
189 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
190 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
191 + "dev": true,
192 + "license": "MIT",
193 + "engines": {
194 + "node": ">=6.9.0"
195 + }
196 + },
197 + "node_modules/@babel/helper-validator-option": {
198 + "version": "7.29.7",
199 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
200 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
201 + "dev": true,
202 + "license": "MIT",
203 + "engines": {
204 + "node": ">=6.9.0"
205 + }
206 + },
207 + "node_modules/@babel/helpers": {
208 + "version": "7.29.7",
209 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
210 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
211 + "dev": true,
212 + "license": "MIT",
213 + "dependencies": {
214 + "@babel/template": "^7.29.7",
215 + "@babel/types": "^7.29.7"
216 + },
217 + "engines": {
218 + "node": ">=6.9.0"
219 + }
220 + },
221 + "node_modules/@babel/parser": {
222 + "version": "7.29.8",
223 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
224 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
225 + "dev": true,
226 + "license": "MIT",
227 + "dependencies": {
228 + "@babel/types": "^7.29.8"
229 + },
230 + "bin": {
231 + "parser": "bin/babel-parser.js"
232 + },
233 + "engines": {
234 + "node": ">=6.0.0"
235 + }
236 + },
237 + "node_modules/@babel/template": {
238 + "version": "7.29.7",
239 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
240 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
241 + "dev": true,
242 + "license": "MIT",
243 + "dependencies": {
244 + "@babel/code-frame": "^7.29.7",
245 + "@babel/parser": "^7.29.7",
246 + "@babel/types": "^7.29.7"
247 + },
248 + "engines": {
249 + "node": ">=6.9.0"
250 + }
251 + },
252 + "node_modules/@babel/traverse": {
253 + "version": "7.29.8",
254 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
255 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
256 + "dev": true,
257 + "license": "MIT",
258 + "dependencies": {
259 + "@babel/code-frame": "^7.29.7",
260 + "@babel/generator": "^7.29.8",
261 + "@babel/helper-globals": "^7.29.7",
262 + "@babel/parser": "^7.29.8",
263 + "@babel/template": "^7.29.7",
264 + "@babel/types": "^7.29.8",
265 + "debug": "^4.3.1"
266 + },
267 + "engines": {
268 + "node": ">=6.9.0"
269 + }
270 + },
271 + "node_modules/@babel/types": {
272 + "version": "7.29.8",
273 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
274 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
275 + "dev": true,
276 + "license": "MIT",
277 + "dependencies": {
278 + "@babel/helper-string-parser": "^7.29.7",
279 + "@babel/helper-validator-identifier": "^7.29.7"
280 + },
281 + "engines": {
282 + "node": ">=6.9.0"
283 + }
284 + },
285 + "node_modules/@emnapi/core": {
286 + "version": "1.10.0",
287 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
288 + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
289 + "dev": true,
290 + "license": "MIT",
291 + "optional": true,
292 + "dependencies": {
293 + "@emnapi/wasi-threads": "1.2.1",
294 + "tslib": "^2.4.0"
295 + }
296 + },
297 + "node_modules/@emnapi/runtime": {
298 + "version": "1.11.3",
299 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
300 + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
301 + "license": "MIT",
302 + "optional": true,
303 + "dependencies": {
304 + "tslib": "^2.4.0"
305 + }
306 + },
307 + "node_modules/@emnapi/wasi-threads": {
308 + "version": "1.2.1",
309 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
310 + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
311 + "dev": true,
312 + "license": "MIT",
313 + "optional": true,
314 + "dependencies": {
315 + "tslib": "^2.4.0"
316 + }
317 + },
318 + "node_modules/@eslint-community/eslint-utils": {
319 + "version": "4.10.1",
320 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
321 + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
322 + "dev": true,
323 + "license": "MIT",
324 + "dependencies": {
325 + "eslint-visitor-keys": "^3.4.3"
326 + },
327 + "engines": {
328 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
329 + },
330 + "funding": {
331 + "url": "https://opencollective.com/eslint"
332 + },
333 + "peerDependencies": {
334 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
335 + }
336 + },
337 + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
338 + "version": "3.4.3",
339 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
340 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
341 + "dev": true,
342 + "license": "Apache-2.0",
343 + "engines": {
344 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
345 + },
346 + "funding": {
347 + "url": "https://opencollective.com/eslint"
348 + }
349 + },
350 + "node_modules/@eslint-community/regexpp": {
351 + "version": "4.12.2",
352 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
353 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
354 + "dev": true,
355 + "license": "MIT",
356 + "engines": {
357 + "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
358 + }
359 + },
360 + "node_modules/@eslint/config-array": {
361 + "version": "0.21.2",
362 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
363 + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
364 + "dev": true,
365 + "license": "Apache-2.0",
366 + "dependencies": {
367 + "@eslint/object-schema": "^2.1.7",
368 + "debug": "^4.3.1",
369 + "minimatch": "^3.1.5"
370 + },
371 + "engines": {
372 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
373 + }
374 + },
375 + "node_modules/@eslint/config-helpers": {
376 + "version": "0.4.2",
377 + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
378 + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
379 + "dev": true,
380 + "license": "Apache-2.0",
381 + "dependencies": {
382 + "@eslint/core": "^0.17.0"
383 + },
384 + "engines": {
385 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
386 + }
387 + },
388 + "node_modules/@eslint/core": {
389 + "version": "0.17.0",
390 + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
391 + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
392 + "dev": true,
393 + "license": "Apache-2.0",
394 + "dependencies": {
395 + "@types/json-schema": "^7.0.15"
396 + },
397 + "engines": {
398 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
399 + }
400 + },
401 + "node_modules/@eslint/eslintrc": {
402 + "version": "3.3.6",
403 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
404 + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
405 + "dev": true,
406 + "license": "MIT",
407 + "dependencies": {
408 + "ajv": "^6.14.0",
409 + "debug": "^4.3.2",
410 + "espree": "^10.0.1",
411 + "globals": "^14.0.0",
412 + "ignore": "^5.2.0",
413 + "import-fresh": "^3.2.1",
414 + "js-yaml": "^4.3.0",
415 + "minimatch": "^3.1.5",
416 + "strip-json-comments": "^3.1.1"
417 + },
418 + "engines": {
419 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
420 + },
421 + "funding": {
422 + "url": "https://opencollective.com/eslint"
423 + }
424 + },
425 + "node_modules/@eslint/js": {
426 + "version": "9.39.5",
427 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
428 + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
429 + "dev": true,
430 + "license": "MIT",
431 + "engines": {
432 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
433 + },
434 + "funding": {
435 + "url": "https://eslint.org/donate"
436 + }
437 + },
438 + "node_modules/@eslint/object-schema": {
439 + "version": "2.1.7",
440 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
441 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
442 + "dev": true,
443 + "license": "Apache-2.0",
444 + "engines": {
445 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
446 + }
447 + },
448 + "node_modules/@eslint/plugin-kit": {
449 + "version": "0.4.1",
450 + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
451 + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
452 + "dev": true,
453 + "license": "Apache-2.0",
454 + "dependencies": {
455 + "@eslint/core": "^0.17.0",
456 + "levn": "^0.4.1"
457 + },
458 + "engines": {
459 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
460 + }
461 + },
462 + "node_modules/@humanfs/core": {
463 + "version": "0.19.2",
464 + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
465 + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
466 + "dev": true,
467 + "license": "Apache-2.0",
468 + "dependencies": {
469 + "@humanfs/types": "^0.15.0"
470 + },
471 + "engines": {
472 + "node": ">=18.18.0"
473 + }
474 + },
475 + "node_modules/@humanfs/node": {
476 + "version": "0.16.8",
477 + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
478 + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
479 + "dev": true,
480 + "license": "Apache-2.0",
481 + "dependencies": {
482 + "@humanfs/core": "^0.19.2",
483 + "@humanfs/types": "^0.15.0",
484 + "@humanwhocodes/retry": "^0.4.0"
485 + },
486 + "engines": {
487 + "node": ">=18.18.0"
488 + }
489 + },
490 + "node_modules/@humanfs/types": {
491 + "version": "0.15.0",
492 + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
493 + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
494 + "dev": true,
495 + "license": "Apache-2.0",
496 + "engines": {
497 + "node": ">=18.18.0"
498 + }
499 + },
500 + "node_modules/@humanwhocodes/module-importer": {
501 + "version": "1.0.1",
502 + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
503 + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
504 + "dev": true,
505 + "license": "Apache-2.0",
506 + "engines": {
507 + "node": ">=12.22"
508 + },
509 + "funding": {
510 + "type": "github",
511 + "url": "https://github.com/sponsors/nzakas"
512 + }
513 + },
514 + "node_modules/@humanwhocodes/retry": {
515 + "version": "0.4.3",
516 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
517 + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
518 + "dev": true,
519 + "license": "Apache-2.0",
520 + "engines": {
521 + "node": ">=18.18"
522 + },
523 + "funding": {
524 + "type": "github",
525 + "url": "https://github.com/sponsors/nzakas"
526 + }
527 + },
528 + "node_modules/@img/colour": {
529 + "version": "1.1.0",
530 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
531 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
532 + "license": "MIT",
533 + "optional": true,
534 + "engines": {
535 + "node": ">=18"
536 + }
537 + },
538 + "node_modules/@img/sharp-darwin-arm64": {
539 + "version": "0.35.3",
540 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
541 + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
542 + "cpu": [
543 + "arm64"
544 + ],
545 + "license": "Apache-2.0",
546 + "optional": true,
547 + "os": [
548 + "darwin"
549 + ],
550 + "engines": {
551 + "node": ">=20.9.0"
552 + },
553 + "funding": {
554 + "url": "https://opencollective.com/libvips"
555 + },
556 + "optionalDependencies": {
557 + "@img/sharp-libvips-darwin-arm64": "1.3.2"
558 + }
559 + },
560 + "node_modules/@img/sharp-darwin-x64": {
561 + "version": "0.35.3",
562 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
563 + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
564 + "cpu": [
565 + "x64"
566 + ],
567 + "license": "Apache-2.0",
568 + "optional": true,
569 + "os": [
570 + "darwin"
571 + ],
572 + "engines": {
573 + "node": ">=20.9.0"
574 + },
575 + "funding": {
576 + "url": "https://opencollective.com/libvips"
577 + },
578 + "optionalDependencies": {
579 + "@img/sharp-libvips-darwin-x64": "1.3.2"
580 + }
581 + },
582 + "node_modules/@img/sharp-freebsd-wasm32": {
583 + "version": "0.35.3",
584 + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
585 + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
586 + "license": "Apache-2.0",
587 + "optional": true,
588 + "os": [
589 + "freebsd"
590 + ],
591 + "dependencies": {
592 + "@img/sharp-wasm32": "0.35.3"
593 + },
594 + "engines": {
595 + "node": ">=20.9.0"
596 + },
597 + "funding": {
598 + "url": "https://opencollective.com/libvips"
599 + }
600 + },
601 + "node_modules/@img/sharp-libvips-darwin-arm64": {
602 + "version": "1.3.2",
603 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
604 + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
605 + "cpu": [
606 + "arm64"
607 + ],
608 + "license": "LGPL-3.0-or-later",
609 + "optional": true,
610 + "os": [
611 + "darwin"
612 + ],
613 + "funding": {
614 + "url": "https://opencollective.com/libvips"
615 + }
616 + },
617 + "node_modules/@img/sharp-libvips-darwin-x64": {
618 + "version": "1.3.2",
619 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
620 + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
621 + "cpu": [
622 + "x64"
623 + ],
624 + "license": "LGPL-3.0-or-later",
625 + "optional": true,
626 + "os": [
627 + "darwin"
628 + ],
629 + "funding": {
630 + "url": "https://opencollective.com/libvips"
631 + }
632 + },
633 + "node_modules/@img/sharp-libvips-linux-arm": {
634 + "version": "1.3.2",
635 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
636 + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
637 + "cpu": [
638 + "arm"
639 + ],
640 + "libc": [
641 + "glibc"
642 + ],
643 + "license": "LGPL-3.0-or-later",
644 + "optional": true,
645 + "os": [
646 + "linux"
647 + ],
648 + "funding": {
649 + "url": "https://opencollective.com/libvips"
650 + }
651 + },
652 + "node_modules/@img/sharp-libvips-linux-arm64": {
653 + "version": "1.3.2",
654 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
655 + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
656 + "cpu": [
657 + "arm64"
658 + ],
659 + "libc": [
660 + "glibc"
661 + ],
662 + "license": "LGPL-3.0-or-later",
663 + "optional": true,
664 + "os": [
665 + "linux"
666 + ],
667 + "funding": {
668 + "url": "https://opencollective.com/libvips"
669 + }
670 + },
671 + "node_modules/@img/sharp-libvips-linux-ppc64": {
672 + "version": "1.3.2",
673 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
674 + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
675 + "cpu": [
676 + "ppc64"
677 + ],
678 + "libc": [
679 + "glibc"
680 + ],
681 + "license": "LGPL-3.0-or-later",
682 + "optional": true,
683 + "os": [
684 + "linux"
685 + ],
686 + "funding": {
687 + "url": "https://opencollective.com/libvips"
688 + }
689 + },
690 + "node_modules/@img/sharp-libvips-linux-riscv64": {
691 + "version": "1.3.2",
692 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
693 + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
694 + "cpu": [
695 + "riscv64"
696 + ],
697 + "libc": [
698 + "glibc"
699 + ],
700 + "license": "LGPL-3.0-or-later",
701 + "optional": true,
702 + "os": [
703 + "linux"
704 + ],
705 + "funding": {
706 + "url": "https://opencollective.com/libvips"
707 + }
708 + },
709 + "node_modules/@img/sharp-libvips-linux-s390x": {
710 + "version": "1.3.2",
711 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
712 + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
713 + "cpu": [
714 + "s390x"
715 + ],
716 + "libc": [
717 + "glibc"
718 + ],
719 + "license": "LGPL-3.0-or-later",
720 + "optional": true,
721 + "os": [
722 + "linux"
723 + ],
724 + "funding": {
725 + "url": "https://opencollective.com/libvips"
726 + }
727 + },
728 + "node_modules/@img/sharp-libvips-linux-x64": {
729 + "version": "1.3.2",
730 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
731 + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
732 + "cpu": [
733 + "x64"
734 + ],
735 + "libc": [
736 + "glibc"
737 + ],
738 + "license": "LGPL-3.0-or-later",
739 + "optional": true,
740 + "os": [
741 + "linux"
742 + ],
743 + "funding": {
744 + "url": "https://opencollective.com/libvips"
745 + }
746 + },
747 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
748 + "version": "1.3.2",
749 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
750 + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
751 + "cpu": [
752 + "arm64"
753 + ],
754 + "libc": [
755 + "musl"
756 + ],
757 + "license": "LGPL-3.0-or-later",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "funding": {
763 + "url": "https://opencollective.com/libvips"
764 + }
765 + },
766 + "node_modules/@img/sharp-libvips-linuxmusl-x64": {
767 + "version": "1.3.2",
768 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
769 + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
770 + "cpu": [
771 + "x64"
772 + ],
773 + "libc": [
774 + "musl"
775 + ],
776 + "license": "LGPL-3.0-or-later",
777 + "optional": true,
778 + "os": [
779 + "linux"
780 + ],
781 + "funding": {
782 + "url": "https://opencollective.com/libvips"
783 + }
784 + },
785 + "node_modules/@img/sharp-linux-arm": {
786 + "version": "0.35.3",
787 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
788 + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
789 + "cpu": [
790 + "arm"
791 + ],
792 + "libc": [
793 + "glibc"
794 + ],
795 + "license": "Apache-2.0",
796 + "optional": true,
797 + "os": [
798 + "linux"
799 + ],
800 + "engines": {
801 + "node": ">=20.9.0"
802 + },
803 + "funding": {
804 + "url": "https://opencollective.com/libvips"
805 + },
806 + "optionalDependencies": {
807 + "@img/sharp-libvips-linux-arm": "1.3.2"
808 + }
809 + },
810 + "node_modules/@img/sharp-linux-arm64": {
811 + "version": "0.35.3",
812 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
813 + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "libc": [
818 + "glibc"
819 + ],
820 + "license": "Apache-2.0",
821 + "optional": true,
822 + "os": [
823 + "linux"
824 + ],
825 + "engines": {
826 + "node": ">=20.9.0"
827 + },
828 + "funding": {
829 + "url": "https://opencollective.com/libvips"
830 + },
831 + "optionalDependencies": {
832 + "@img/sharp-libvips-linux-arm64": "1.3.2"
833 + }
834 + },
835 + "node_modules/@img/sharp-linux-ppc64": {
836 + "version": "0.35.3",
837 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
838 + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
839 + "cpu": [
840 + "ppc64"
841 + ],
842 + "libc": [
843 + "glibc"
844 + ],
845 + "license": "Apache-2.0",
846 + "optional": true,
847 + "os": [
848 + "linux"
849 + ],
850 + "engines": {
851 + "node": ">=20.9.0"
852 + },
853 + "funding": {
854 + "url": "https://opencollective.com/libvips"
855 + },
856 + "optionalDependencies": {
857 + "@img/sharp-libvips-linux-ppc64": "1.3.2"
858 + }
859 + },
860 + "node_modules/@img/sharp-linux-riscv64": {
861 + "version": "0.35.3",
862 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
863 + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
864 + "cpu": [
865 + "riscv64"
866 + ],
867 + "libc": [
868 + "glibc"
869 + ],
870 + "license": "Apache-2.0",
871 + "optional": true,
872 + "os": [
873 + "linux"
874 + ],
875 + "engines": {
876 + "node": ">=20.9.0"
877 + },
878 + "funding": {
879 + "url": "https://opencollective.com/libvips"
880 + },
881 + "optionalDependencies": {
882 + "@img/sharp-libvips-linux-riscv64": "1.3.2"
883 + }
884 + },
885 + "node_modules/@img/sharp-linux-s390x": {
886 + "version": "0.35.3",
887 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
888 + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
889 + "cpu": [
890 + "s390x"
891 + ],
892 + "libc": [
893 + "glibc"
894 + ],
895 + "license": "Apache-2.0",
896 + "optional": true,
897 + "os": [
898 + "linux"
899 + ],
900 + "engines": {
901 + "node": ">=20.9.0"
902 + },
903 + "funding": {
904 + "url": "https://opencollective.com/libvips"
905 + },
906 + "optionalDependencies": {
907 + "@img/sharp-libvips-linux-s390x": "1.3.2"
908 + }
909 + },
910 + "node_modules/@img/sharp-linux-x64": {
911 + "version": "0.35.3",
912 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
913 + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
914 + "cpu": [
915 + "x64"
916 + ],
917 + "libc": [
918 + "glibc"
919 + ],
920 + "license": "Apache-2.0",
921 + "optional": true,
922 + "os": [
923 + "linux"
924 + ],
925 + "engines": {
926 + "node": ">=20.9.0"
927 + },
928 + "funding": {
929 + "url": "https://opencollective.com/libvips"
930 + },
931 + "optionalDependencies": {
932 + "@img/sharp-libvips-linux-x64": "1.3.2"
933 + }
934 + },
935 + "node_modules/@img/sharp-linuxmusl-arm64": {
936 + "version": "0.35.3",
937 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
938 + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
939 + "cpu": [
940 + "arm64"
941 + ],
942 + "libc": [
943 + "musl"
944 + ],
945 + "license": "Apache-2.0",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ],
950 + "engines": {
951 + "node": ">=20.9.0"
952 + },
953 + "funding": {
954 + "url": "https://opencollective.com/libvips"
955 + },
956 + "optionalDependencies": {
957 + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
958 + }
959 + },
960 + "node_modules/@img/sharp-linuxmusl-x64": {
961 + "version": "0.35.3",
962 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
963 + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
964 + "cpu": [
965 + "x64"
966 + ],
967 + "libc": [
968 + "musl"
969 + ],
970 + "license": "Apache-2.0",
971 + "optional": true,
972 + "os": [
973 + "linux"
974 + ],
975 + "engines": {
976 + "node": ">=20.9.0"
977 + },
978 + "funding": {
979 + "url": "https://opencollective.com/libvips"
980 + },
981 + "optionalDependencies": {
982 + "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
983 + }
984 + },
985 + "node_modules/@img/sharp-wasm32": {
986 + "version": "0.35.3",
987 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
988 + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
989 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
990 + "optional": true,
991 + "dependencies": {
992 + "@emnapi/runtime": "^1.11.1"
993 + },
994 + "engines": {
995 + "node": ">=20.9.0"
996 + },
997 + "funding": {
998 + "url": "https://opencollective.com/libvips"
999 + }
1000 + },
1001 + "node_modules/@img/sharp-webcontainers-wasm32": {
1002 + "version": "0.35.3",
1003 + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
1004 + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
1005 + "cpu": [
1006 + "wasm32"
1007 + ],
1008 + "license": "Apache-2.0",
1009 + "optional": true,
1010 + "dependencies": {
1011 + "@img/sharp-wasm32": "0.35.3"
1012 + },
1013 + "engines": {
1014 + "node": ">=20.9.0"
1015 + },
1016 + "funding": {
1017 + "url": "https://opencollective.com/libvips"
1018 + }
1019 + },
1020 + "node_modules/@img/sharp-win32-arm64": {
1021 + "version": "0.35.3",
1022 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
1023 + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
1024 + "cpu": [
1025 + "arm64"
1026 + ],
1027 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
1028 + "optional": true,
1029 + "os": [
1030 + "win32"
1031 + ],
1032 + "engines": {
1033 + "node": ">=20.9.0"
1034 + },
1035 + "funding": {
1036 + "url": "https://opencollective.com/libvips"
1037 + }
1038 + },
1039 + "node_modules/@img/sharp-win32-ia32": {
1040 + "version": "0.35.3",
1041 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
1042 + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
1043 + "cpu": [
1044 + "ia32"
1045 + ],
1046 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
1047 + "optional": true,
1048 + "os": [
1049 + "win32"
1050 + ],
1051 + "engines": {
1052 + "node": "^20.9.0"
1053 + },
1054 + "funding": {
1055 + "url": "https://opencollective.com/libvips"
1056 + }
1057 + },
1058 + "node_modules/@img/sharp-win32-x64": {
1059 + "version": "0.35.3",
1060 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
1061 + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
1062 + "cpu": [
1063 + "x64"
1064 + ],
1065 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
1066 + "optional": true,
1067 + "os": [
1068 + "win32"
1069 + ],
1070 + "engines": {
1071 + "node": ">=20.9.0"
1072 + },
1073 + "funding": {
1074 + "url": "https://opencollective.com/libvips"
1075 + }
1076 + },
1077 + "node_modules/@jridgewell/gen-mapping": {
1078 + "version": "0.3.13",
1079 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
1080 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
1081 + "dev": true,
1082 + "license": "MIT",
1083 + "dependencies": {
1084 + "@jridgewell/sourcemap-codec": "^1.5.0",
1085 + "@jridgewell/trace-mapping": "^0.3.24"
1086 + }
1087 + },
1088 + "node_modules/@jridgewell/remapping": {
1089 + "version": "2.3.5",
1090 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
1091 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
1092 + "dev": true,
1093 + "license": "MIT",
1094 + "dependencies": {
1095 + "@jridgewell/gen-mapping": "^0.3.5",
1096 + "@jridgewell/trace-mapping": "^0.3.24"
1097 + }
1098 + },
1099 + "node_modules/@jridgewell/resolve-uri": {
1100 + "version": "3.1.2",
1101 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1102 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
1103 + "dev": true,
1104 + "license": "MIT",
1105 + "engines": {
1106 + "node": ">=6.0.0"
1107 + }
1108 + },
1109 + "node_modules/@jridgewell/sourcemap-codec": {
1110 + "version": "1.5.5",
1111 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1112 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
1113 + "dev": true,
1114 + "license": "MIT"
1115 + },
1116 + "node_modules/@jridgewell/trace-mapping": {
1117 + "version": "0.3.31",
1118 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
1119 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
1120 + "dev": true,
1121 + "license": "MIT",
1122 + "dependencies": {
1123 + "@jridgewell/resolve-uri": "^3.1.0",
1124 + "@jridgewell/sourcemap-codec": "^1.4.14"
1125 + }
1126 + },
1127 + "node_modules/@napi-rs/wasm-runtime": {
1128 + "version": "1.2.2",
1129 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
1130 + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
1131 + "dev": true,
1132 + "license": "MIT",
1133 + "optional": true,
1134 + "dependencies": {
1135 + "@tybys/wasm-util": "^0.10.3"
1136 + },
1137 + "engines": {
1138 + "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
1139 + },
1140 + "funding": {
1141 + "type": "github",
1142 + "url": "https://github.com/sponsors/Brooooooklyn"
1143 + },
1144 + "peerDependencies": {
1145 + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
1146 + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
1147 + }
1148 + },
1149 + "node_modules/@next/env": {
1150 + "version": "16.3.0",
1151 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
1152 + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
1153 + "license": "MIT"
1154 + },
1155 + "node_modules/@next/eslint-plugin-next": {
1156 + "version": "16.3.0",
1157 + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz",
1158 + "integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==",
1159 + "dev": true,
1160 + "license": "MIT",
1161 + "dependencies": {
1162 + "@eslint-community/eslint-utils": "4.9.1",
1163 + "fast-glob": "3.3.1"
1164 + }
1165 + },
1166 + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": {
1167 + "version": "4.9.1",
1168 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
1169 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
1170 + "dev": true,
1171 + "license": "MIT",
1172 + "dependencies": {
1173 + "eslint-visitor-keys": "^3.4.3"
1174 + },
1175 + "engines": {
1176 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1177 + },
1178 + "funding": {
1179 + "url": "https://opencollective.com/eslint"
1180 + },
1181 + "peerDependencies": {
1182 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
1183 + }
1184 + },
1185 + "node_modules/@next/eslint-plugin-next/node_modules/eslint-visitor-keys": {
1186 + "version": "3.4.3",
1187 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
1188 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
1189 + "dev": true,
1190 + "license": "Apache-2.0",
1191 + "engines": {
1192 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1193 + },
1194 + "funding": {
1195 + "url": "https://opencollective.com/eslint"
1196 + }
1197 + },
1198 + "node_modules/@next/swc-darwin-arm64": {
1199 + "version": "16.3.0",
1200 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
1201 + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
1202 + "cpu": [
1203 + "arm64"
1204 + ],
1205 + "license": "MIT",
1206 + "optional": true,
1207 + "os": [
1208 + "darwin"
1209 + ],
1210 + "engines": {
1211 + "node": ">= 10"
1212 + }
1213 + },
1214 + "node_modules/@next/swc-darwin-x64": {
1215 + "version": "16.3.0",
1216 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
1217 + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
1218 + "cpu": [
1219 + "x64"
1220 + ],
1221 + "license": "MIT",
1222 + "optional": true,
1223 + "os": [
1224 + "darwin"
1225 + ],
1226 + "engines": {
1227 + "node": ">= 10"
1228 + }
1229 + },
1230 + "node_modules/@next/swc-linux-arm64-gnu": {
1231 + "version": "16.3.0",
1232 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
1233 + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
1234 + "cpu": [
1235 + "arm64"
1236 + ],
1237 + "libc": [
1238 + "glibc"
1239 + ],
1240 + "license": "MIT",
1241 + "optional": true,
1242 + "os": [
1243 + "linux"
1244 + ],
1245 + "engines": {
1246 + "node": ">= 10"
1247 + }
1248 + },
1249 + "node_modules/@next/swc-linux-arm64-musl": {
1250 + "version": "16.3.0",
1251 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
1252 + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
1253 + "cpu": [
1254 + "arm64"
1255 + ],
1256 + "libc": [
1257 + "musl"
1258 + ],
1259 + "license": "MIT",
1260 + "optional": true,
1261 + "os": [
1262 + "linux"
1263 + ],
1264 + "engines": {
1265 + "node": ">= 10"
1266 + }
1267 + },
1268 + "node_modules/@next/swc-linux-x64-gnu": {
1269 + "version": "16.3.0",
1270 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
1271 + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
1272 + "cpu": [
1273 + "x64"
1274 + ],
1275 + "libc": [
1276 + "glibc"
1277 + ],
1278 + "license": "MIT",
1279 + "optional": true,
1280 + "os": [
1281 + "linux"
1282 + ],
1283 + "engines": {
1284 + "node": ">= 10"
1285 + }
1286 + },
1287 + "node_modules/@next/swc-linux-x64-musl": {
1288 + "version": "16.3.0",
1289 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
1290 + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
1291 + "cpu": [
1292 + "x64"
1293 + ],
1294 + "libc": [
1295 + "musl"
1296 + ],
1297 + "license": "MIT",
1298 + "optional": true,
1299 + "os": [
1300 + "linux"
1301 + ],
1302 + "engines": {
1303 + "node": ">= 10"
1304 + }
1305 + },
1306 + "node_modules/@next/swc-win32-arm64-msvc": {
1307 + "version": "16.3.0",
1308 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
1309 + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
1310 + "cpu": [
1311 + "arm64"
1312 + ],
1313 + "license": "MIT",
1314 + "optional": true,
1315 + "os": [
1316 + "win32"
1317 + ],
1318 + "engines": {
1319 + "node": ">= 10"
1320 + }
1321 + },
1322 + "node_modules/@next/swc-win32-x64-msvc": {
1323 + "version": "16.3.0",
1324 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
1325 + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
1326 + "cpu": [
1327 + "x64"
1328 + ],
1329 + "license": "MIT",
1330 + "optional": true,
1331 + "os": [
1332 + "win32"
1333 + ],
1334 + "engines": {
1335 + "node": ">= 10"
1336 + }
1337 + },
1338 + "node_modules/@noble/ciphers": {
1339 + "version": "1.3.0",
1340 + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
1341 + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
1342 + "license": "MIT",
1343 + "engines": {
1344 + "node": "^14.21.3 || >=16"
1345 + },
1346 + "funding": {
1347 + "url": "https://paulmillr.com/funding/"
1348 + }
1349 + },
1350 + "node_modules/@noble/hashes": {
1351 + "version": "1.8.0",
1352 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
1353 + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
1354 + "license": "MIT",
1355 + "engines": {
1356 + "node": "^14.21.3 || >=16"
1357 + },
1358 + "funding": {
1359 + "url": "https://paulmillr.com/funding/"
1360 + }
1361 + },
1362 + "node_modules/@nodelib/fs.scandir": {
1363 + "version": "2.1.5",
1364 + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
1365 + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
1366 + "dev": true,
1367 + "license": "MIT",
1368 + "dependencies": {
1369 + "@nodelib/fs.stat": "2.0.5",
1370 + "run-parallel": "^1.1.9"
1371 + },
1372 + "engines": {
1373 + "node": ">= 8"
1374 + }
1375 + },
1376 + "node_modules/@nodelib/fs.stat": {
1377 + "version": "2.0.5",
1378 + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
1379 + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
1380 + "dev": true,
1381 + "license": "MIT",
1382 + "engines": {
1383 + "node": ">= 8"
1384 + }
1385 + },
1386 + "node_modules/@nodelib/fs.walk": {
1387 + "version": "1.2.8",
1388 + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
1389 + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
1390 + "dev": true,
1391 + "license": "MIT",
1392 + "dependencies": {
1393 + "@nodelib/fs.scandir": "2.1.5",
1394 + "fastq": "^1.6.0"
1395 + },
1396 + "engines": {
1397 + "node": ">= 8"
1398 + }
1399 + },
1400 + "node_modules/@nolyfill/is-core-module": {
1401 + "version": "1.0.39",
1402 + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
1403 + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
1404 + "dev": true,
1405 + "license": "MIT",
1406 + "engines": {
1407 + "node": ">=12.4.0"
1408 + }
1409 + },
1410 + "node_modules/@oxc-project/types": {
1411 + "version": "0.143.0",
1412 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
1413 + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
1414 + "dev": true,
1415 + "license": "MIT",
1416 + "funding": {
1417 + "url": "https://github.com/sponsors/Boshen"
1418 + }
1419 + },
1420 + "node_modules/@rolldown/binding-android-arm64": {
1421 + "version": "1.2.3",
1422 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
1423 + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
1424 + "cpu": [
1425 + "arm64"
1426 + ],
1427 + "dev": true,
1428 + "license": "MIT",
1429 + "optional": true,
1430 + "os": [
1431 + "android"
1432 + ],
1433 + "engines": {
1434 + "node": "^20.19.0 || >=22.12.0"
1435 + }
1436 + },
1437 + "node_modules/@rolldown/binding-darwin-arm64": {
1438 + "version": "1.2.3",
1439 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
1440 + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
1441 + "cpu": [
1442 + "arm64"
1443 + ],
1444 + "dev": true,
1445 + "license": "MIT",
1446 + "optional": true,
1447 + "os": [
1448 + "darwin"
1449 + ],
1450 + "engines": {
1451 + "node": "^20.19.0 || >=22.12.0"
1452 + }
1453 + },
1454 + "node_modules/@rolldown/binding-darwin-x64": {
1455 + "version": "1.2.3",
1456 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
1457 + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
1458 + "cpu": [
1459 + "x64"
1460 + ],
1461 + "dev": true,
1462 + "license": "MIT",
1463 + "optional": true,
1464 + "os": [
1465 + "darwin"
1466 + ],
1467 + "engines": {
1468 + "node": "^20.19.0 || >=22.12.0"
1469 + }
1470 + },
1471 + "node_modules/@rolldown/binding-freebsd-x64": {
1472 + "version": "1.2.3",
1473 + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
1474 + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
1475 + "cpu": [
1476 + "x64"
1477 + ],
1478 + "dev": true,
1479 + "license": "MIT",
1480 + "optional": true,
1481 + "os": [
1482 + "freebsd"
1483 + ],
1484 + "engines": {
1485 + "node": "^20.19.0 || >=22.12.0"
1486 + }
1487 + },
1488 + "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
1489 + "version": "1.2.3",
1490 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
1491 + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
1492 + "cpu": [
1493 + "arm"
1494 + ],
1495 + "dev": true,
1496 + "license": "MIT",
1497 + "optional": true,
1498 + "os": [
1499 + "linux"
1500 + ],
1501 + "engines": {
1502 + "node": "^20.19.0 || >=22.12.0"
1503 + }
1504 + },
1505 + "node_modules/@rolldown/binding-linux-arm64-gnu": {
1506 + "version": "1.2.3",
1507 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
1508 + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
1509 + "cpu": [
1510 + "arm64"
1511 + ],
1512 + "dev": true,
1513 + "libc": [
1514 + "glibc"
1515 + ],
1516 + "license": "MIT",
1517 + "optional": true,
1518 + "os": [
1519 + "linux"
1520 + ],
1521 + "engines": {
1522 + "node": "^20.19.0 || >=22.12.0"
1523 + }
1524 + },
1525 + "node_modules/@rolldown/binding-linux-arm64-musl": {
1526 + "version": "1.2.3",
1527 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
1528 + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
1529 + "cpu": [
1530 + "arm64"
1531 + ],
1532 + "dev": true,
1533 + "libc": [
1534 + "musl"
1535 + ],
1536 + "license": "MIT",
1537 + "optional": true,
1538 + "os": [
1539 + "linux"
1540 + ],
1541 + "engines": {
1542 + "node": "^20.19.0 || >=22.12.0"
1543 + }
1544 + },
1545 + "node_modules/@rolldown/binding-linux-ppc64-gnu": {
1546 + "version": "1.2.3",
1547 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
1548 + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
1549 + "cpu": [
1550 + "ppc64"
1551 + ],
1552 + "dev": true,
1553 + "libc": [
1554 + "glibc"
1555 + ],
1556 + "license": "MIT",
1557 + "optional": true,
1558 + "os": [
1559 + "linux"
1560 + ],
1561 + "engines": {
1562 + "node": "^20.19.0 || >=22.12.0"
1563 + }
1564 + },
1565 + "node_modules/@rolldown/binding-linux-s390x-gnu": {
1566 + "version": "1.2.3",
1567 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
1568 + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
1569 + "cpu": [
1570 + "s390x"
1571 + ],
1572 + "dev": true,
1573 + "libc": [
1574 + "glibc"
1575 + ],
1576 + "license": "MIT",
1577 + "optional": true,
1578 + "os": [
1579 + "linux"
1580 + ],
1581 + "engines": {
1582 + "node": "^20.19.0 || >=22.12.0"
1583 + }
1584 + },
1585 + "node_modules/@rolldown/binding-linux-x64-gnu": {
1586 + "version": "1.2.3",
1587 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
1588 + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
1589 + "cpu": [
1590 + "x64"
1591 + ],
1592 + "dev": true,
1593 + "libc": [
1594 + "glibc"
1595 + ],
1596 + "license": "MIT",
1597 + "optional": true,
1598 + "os": [
1599 + "linux"
1600 + ],
1601 + "engines": {
1602 + "node": "^20.19.0 || >=22.12.0"
1603 + }
1604 + },
1605 + "node_modules/@rolldown/binding-linux-x64-musl": {
1606 + "version": "1.2.3",
1607 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
1608 + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
1609 + "cpu": [
1610 + "x64"
1611 + ],
1612 + "dev": true,
1613 + "libc": [
1614 + "musl"
1615 + ],
1616 + "license": "MIT",
1617 + "optional": true,
1618 + "os": [
1619 + "linux"
1620 + ],
1621 + "engines": {
1622 + "node": "^20.19.0 || >=22.12.0"
1623 + }
1624 + },
1625 + "node_modules/@rolldown/binding-openharmony-arm64": {
1626 + "version": "1.2.3",
1627 + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
1628 + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
1629 + "cpu": [
1630 + "arm64"
1631 + ],
1632 + "dev": true,
1633 + "license": "MIT",
1634 + "optional": true,
1635 + "os": [
1636 + "openharmony"
1637 + ],
1638 + "engines": {
1639 + "node": "^20.19.0 || >=22.12.0"
1640 + }
1641 + },
1642 + "node_modules/@rolldown/binding-win32-arm64-msvc": {
1643 + "version": "1.2.3",
1644 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
1645 + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
1646 + "cpu": [
1647 + "arm64"
1648 + ],
1649 + "dev": true,
1650 + "license": "MIT",
1651 + "optional": true,
1652 + "os": [
1653 + "win32"
1654 + ],
1655 + "engines": {
1656 + "node": "^20.19.0 || >=22.12.0"
1657 + }
1658 + },
1659 + "node_modules/@rolldown/binding-win32-x64-msvc": {
1660 + "version": "1.2.3",
1661 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
1662 + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
1663 + "cpu": [
1664 + "x64"
1665 + ],
1666 + "dev": true,
1667 + "license": "MIT",
1668 + "optional": true,
1669 + "os": [
1670 + "win32"
1671 + ],
1672 + "engines": {
1673 + "node": "^20.19.0 || >=22.12.0"
1674 + }
1675 + },
1676 + "node_modules/@rolldown/pluginutils": {
1677 + "version": "1.0.1",
1678 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
1679 + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
1680 + "dev": true,
1681 + "license": "MIT"
1682 + },
1683 + "node_modules/@rtsao/scc": {
1684 + "version": "1.1.0",
1685 + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
1686 + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
1687 + "dev": true,
1688 + "license": "MIT"
1689 + },
1690 + "node_modules/@standard-schema/spec": {
1691 + "version": "1.1.0",
1692 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1693 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1694 + "dev": true,
1695 + "license": "MIT"
1696 + },
1697 + "node_modules/@swc/helpers": {
1698 + "version": "0.5.15",
1699 + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
1700 + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
1701 + "license": "Apache-2.0",
1702 + "dependencies": {
1703 + "tslib": "^2.8.0"
1704 + }
1705 + },
1706 + "node_modules/@tailwindcss/node": {
1707 + "version": "4.3.3",
1708 + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
1709 + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
1710 + "dev": true,
1711 + "license": "MIT",
1712 + "dependencies": {
1713 + "@jridgewell/remapping": "^2.3.5",
1714 + "enhanced-resolve": "^5.24.1",
1715 + "jiti": "^2.7.0",
1716 + "lightningcss": "1.32.0",
1717 + "magic-string": "^0.30.21",
1718 + "source-map-js": "^1.2.1",
1719 + "tailwindcss": "4.3.3"
1720 + }
1721 + },
1722 + "node_modules/@tailwindcss/oxide": {
1723 + "version": "4.3.3",
1724 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
1725 + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
1726 + "dev": true,
1727 + "license": "MIT",
1728 + "engines": {
1729 + "node": ">= 20"
1730 + },
1731 + "optionalDependencies": {
1732 + "@tailwindcss/oxide-android-arm64": "4.3.3",
1733 + "@tailwindcss/oxide-darwin-arm64": "4.3.3",
1734 + "@tailwindcss/oxide-darwin-x64": "4.3.3",
1735 + "@tailwindcss/oxide-freebsd-x64": "4.3.3",
1736 + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
1737 + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
1738 + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
1739 + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
1740 + "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
1741 + "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
1742 + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
1743 + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
1744 + }
1745 + },
1746 + "node_modules/@tailwindcss/oxide-android-arm64": {
1747 + "version": "4.3.3",
1748 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
1749 + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
1750 + "cpu": [
1751 + "arm64"
1752 + ],
1753 + "dev": true,
1754 + "license": "MIT",
1755 + "optional": true,
1756 + "os": [
1757 + "android"
1758 + ],
1759 + "engines": {
1760 + "node": ">= 20"
1761 + }
1762 + },
1763 + "node_modules/@tailwindcss/oxide-darwin-arm64": {
1764 + "version": "4.3.3",
1765 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
1766 + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
1767 + "cpu": [
1768 + "arm64"
1769 + ],
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "optional": true,
1773 + "os": [
1774 + "darwin"
1775 + ],
1776 + "engines": {
1777 + "node": ">= 20"
1778 + }
1779 + },
1780 + "node_modules/@tailwindcss/oxide-darwin-x64": {
1781 + "version": "4.3.3",
1782 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
1783 + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
1784 + "cpu": [
1785 + "x64"
1786 + ],
1787 + "dev": true,
1788 + "license": "MIT",
1789 + "optional": true,
1790 + "os": [
1791 + "darwin"
1792 + ],
1793 + "engines": {
1794 + "node": ">= 20"
1795 + }
1796 + },
1797 + "node_modules/@tailwindcss/oxide-freebsd-x64": {
1798 + "version": "4.3.3",
1799 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
1800 + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
1801 + "cpu": [
1802 + "x64"
1803 + ],
1804 + "dev": true,
1805 + "license": "MIT",
1806 + "optional": true,
1807 + "os": [
1808 + "freebsd"
1809 + ],
1810 + "engines": {
1811 + "node": ">= 20"
1812 + }
1813 + },
1814 + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
1815 + "version": "4.3.3",
1816 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
1817 + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
1818 + "cpu": [
1819 + "arm"
1820 + ],
1821 + "dev": true,
1822 + "license": "MIT",
1823 + "optional": true,
1824 + "os": [
1825 + "linux"
1826 + ],
1827 + "engines": {
1828 + "node": ">= 20"
1829 + }
1830 + },
1831 + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
1832 + "version": "4.3.3",
1833 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
1834 + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
1835 + "cpu": [
1836 + "arm64"
1837 + ],
1838 + "dev": true,
1839 + "libc": [
1840 + "glibc"
1841 + ],
1842 + "license": "MIT",
1843 + "optional": true,
1844 + "os": [
1845 + "linux"
1846 + ],
1847 + "engines": {
1848 + "node": ">= 20"
1849 + }
1850 + },
1851 + "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
1852 + "version": "4.3.3",
1853 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
1854 + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
1855 + "cpu": [
1856 + "arm64"
1857 + ],
1858 + "dev": true,
1859 + "libc": [
1860 + "musl"
1861 + ],
1862 + "license": "MIT",
1863 + "optional": true,
1864 + "os": [
1865 + "linux"
1866 + ],
1867 + "engines": {
1868 + "node": ">= 20"
1869 + }
1870 + },
1871 + "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
1872 + "version": "4.3.3",
1873 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
1874 + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
1875 + "cpu": [
1876 + "x64"
1877 + ],
1878 + "dev": true,
1879 + "libc": [
1880 + "glibc"
1881 + ],
1882 + "license": "MIT",
1883 + "optional": true,
1884 + "os": [
1885 + "linux"
1886 + ],
1887 + "engines": {
1888 + "node": ">= 20"
1889 + }
1890 + },
1891 + "node_modules/@tailwindcss/oxide-linux-x64-musl": {
1892 + "version": "4.3.3",
1893 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
1894 + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
1895 + "cpu": [
1896 + "x64"
1897 + ],
1898 + "dev": true,
1899 + "libc": [
1900 + "musl"
1901 + ],
1902 + "license": "MIT",
1903 + "optional": true,
1904 + "os": [
1905 + "linux"
1906 + ],
1907 + "engines": {
1908 + "node": ">= 20"
1909 + }
1910 + },
1911 + "node_modules/@tailwindcss/oxide-wasm32-wasi": {
1912 + "version": "4.3.3",
1913 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
1914 + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
1915 + "bundleDependencies": [
1916 + "@napi-rs/wasm-runtime",
1917 + "@emnapi/core",
1918 + "@emnapi/runtime",
1919 + "@tybys/wasm-util",
1920 + "@emnapi/wasi-threads",
1921 + "tslib"
1922 + ],
1923 + "cpu": [
1924 + "wasm32"
1925 + ],
1926 + "dev": true,
1927 + "license": "MIT",
1928 + "optional": true,
1929 + "dependencies": {
1930 + "@emnapi/core": "^1.11.1",
1931 + "@emnapi/runtime": "^1.11.1",
1932 + "@emnapi/wasi-threads": "^1.2.2",
1933 + "@napi-rs/wasm-runtime": "^1.1.4",
1934 + "@tybys/wasm-util": "^0.10.2",
1935 + "tslib": "^2.8.1"
1936 + },
1937 + "engines": {
1938 + "node": ">=14.0.0"
1939 + }
1940 + },
1941 + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
1942 + "version": "4.3.3",
1943 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
1944 + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
1945 + "cpu": [
1946 + "arm64"
1947 + ],
1948 + "dev": true,
1949 + "license": "MIT",
1950 + "optional": true,
1951 + "os": [
1952 + "win32"
1953 + ],
1954 + "engines": {
1955 + "node": ">= 20"
1956 + }
1957 + },
1958 + "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
1959 + "version": "4.3.3",
1960 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
1961 + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
1962 + "cpu": [
1963 + "x64"
1964 + ],
1965 + "dev": true,
1966 + "license": "MIT",
1967 + "optional": true,
1968 + "os": [
1969 + "win32"
1970 + ],
1971 + "engines": {
1972 + "node": ">= 20"
1973 + }
1974 + },
1975 + "node_modules/@tailwindcss/postcss": {
1976 + "version": "4.3.3",
1977 + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
1978 + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
1979 + "dev": true,
1980 + "license": "MIT",
1981 + "dependencies": {
1982 + "@alloc/quick-lru": "^5.2.0",
1983 + "@tailwindcss/node": "4.3.3",
1984 + "@tailwindcss/oxide": "4.3.3",
1985 + "postcss": "^8.5.16",
1986 + "tailwindcss": "4.3.3"
1987 + }
1988 + },
1989 + "node_modules/@tybys/wasm-util": {
1990 + "version": "0.10.3",
1991 + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
1992 + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
1993 + "dev": true,
1994 + "license": "MIT",
1995 + "optional": true,
1996 + "dependencies": {
1997 + "tslib": "^2.4.0"
1998 + }
1999 + },
2000 + "node_modules/@types/better-sqlite3": {
2001 + "version": "9.6.0",
2002 + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
2003 + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==",
2004 + "dev": true,
2005 + "license": "MIT",
2006 + "dependencies": {
2007 + "@types/node": "*"
2008 + }
2009 + },
2010 + "node_modules/@types/chai": {
2011 + "version": "5.2.3",
2012 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
2013 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
2014 + "dev": true,
2015 + "license": "MIT",
2016 + "dependencies": {
2017 + "@types/deep-eql": "*",
2018 + "assertion-error": "^2.0.1"
2019 + }
2020 + },
2021 + "node_modules/@types/deep-eql": {
2022 + "version": "4.0.2",
2023 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
2024 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
2025 + "dev": true,
2026 + "license": "MIT"
2027 + },
2028 + "node_modules/@types/estree": {
2029 + "version": "1.0.9",
2030 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
2031 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
2032 + "dev": true,
2033 + "license": "MIT"
2034 + },
2035 + "node_modules/@types/json-schema": {
2036 + "version": "7.0.15",
2037 + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
2038 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
2039 + "dev": true,
2040 + "license": "MIT"
2041 + },
2042 + "node_modules/@types/json5": {
2043 + "version": "0.0.29",
2044 + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
2045 + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
2046 + "dev": true,
2047 + "license": "MIT"
2048 + },
2049 + "node_modules/@types/node": {
2050 + "version": "20.19.43",
2051 + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
2052 + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
2053 + "dev": true,
2054 + "license": "MIT",
2055 + "dependencies": {
2056 + "undici-types": "~6.21.0"
2057 + }
2058 + },
2059 + "node_modules/@types/pdfkit": {
2060 + "version": "0.17.6",
2061 + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
2062 + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
2063 + "dev": true,
2064 + "license": "MIT",
2065 + "dependencies": {
2066 + "@types/node": "*"
2067 + }
2068 + },
2069 + "node_modules/@types/react": {
2070 + "version": "19.2.18",
2071 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
2072 + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
2073 + "dev": true,
2074 + "license": "MIT",
2075 + "dependencies": {
2076 + "csstype": "^3.2.2"
2077 + }
2078 + },
2079 + "node_modules/@types/react-dom": {
2080 + "version": "19.2.4",
2081 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
2082 + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
2083 + "dev": true,
2084 + "license": "MIT",
2085 + "peerDependencies": {
2086 + "@types/react": "^19.2.0"
2087 + }
2088 + },
2089 + "node_modules/@typescript-eslint/eslint-plugin": {
2090 + "version": "8.66.0",
2091 + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
2092 + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
2093 + "dev": true,
2094 + "license": "MIT",
2095 + "dependencies": {
2096 + "@eslint-community/regexpp": "^4.12.2",
2097 + "@typescript-eslint/scope-manager": "8.66.0",
2098 + "@typescript-eslint/type-utils": "8.66.0",
2099 + "@typescript-eslint/utils": "8.66.0",
2100 + "@typescript-eslint/visitor-keys": "8.66.0",
2101 + "ignore": "^7.0.5",
2102 + "natural-compare": "^1.4.0",
2103 + "ts-api-utils": "^2.5.0"
2104 + },
2105 + "engines": {
2106 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2107 + },
2108 + "funding": {
2109 + "type": "opencollective",
2110 + "url": "https://opencollective.com/typescript-eslint"
2111 + },
2112 + "peerDependencies": {
2113 + "@typescript-eslint/parser": "^8.66.0",
2114 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
2115 + "typescript": ">=4.8.4 <6.1.0"
2116 + }
2117 + },
2118 + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
2119 + "version": "7.0.6",
2120 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
2121 + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
2122 + "dev": true,
2123 + "license": "MIT",
2124 + "engines": {
2125 + "node": ">= 4"
2126 + }
2127 + },
2128 + "node_modules/@typescript-eslint/parser": {
2129 + "version": "8.66.0",
2130 + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
2131 + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
2132 + "dev": true,
2133 + "license": "MIT",
2134 + "dependencies": {
2135 + "@typescript-eslint/scope-manager": "8.66.0",
2136 + "@typescript-eslint/types": "8.66.0",
2137 + "@typescript-eslint/typescript-estree": "8.66.0",
2138 + "@typescript-eslint/visitor-keys": "8.66.0",
2139 + "debug": "^4.4.3"
2140 + },
2141 + "engines": {
2142 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2143 + },
2144 + "funding": {
2145 + "type": "opencollective",
2146 + "url": "https://opencollective.com/typescript-eslint"
2147 + },
2148 + "peerDependencies": {
2149 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
2150 + "typescript": ">=4.8.4 <6.1.0"
2151 + }
2152 + },
2153 + "node_modules/@typescript-eslint/project-service": {
2154 + "version": "8.66.0",
2155 + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
2156 + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
2157 + "dev": true,
2158 + "license": "MIT",
2159 + "dependencies": {
2160 + "@typescript-eslint/tsconfig-utils": "^8.66.0",
2161 + "@typescript-eslint/types": "^8.66.0",
2162 + "debug": "^4.4.3"
2163 + },
2164 + "engines": {
2165 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2166 + },
2167 + "funding": {
2168 + "type": "opencollective",
2169 + "url": "https://opencollective.com/typescript-eslint"
2170 + },
2171 + "peerDependencies": {
2172 + "typescript": ">=4.8.4 <6.1.0"
2173 + }
2174 + },
2175 + "node_modules/@typescript-eslint/scope-manager": {
2176 + "version": "8.66.0",
2177 + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
2178 + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
2179 + "dev": true,
2180 + "license": "MIT",
2181 + "dependencies": {
2182 + "@typescript-eslint/types": "8.66.0",
2183 + "@typescript-eslint/visitor-keys": "8.66.0"
2184 + },
2185 + "engines": {
2186 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2187 + },
2188 + "funding": {
2189 + "type": "opencollective",
2190 + "url": "https://opencollective.com/typescript-eslint"
2191 + }
2192 + },
2193 + "node_modules/@typescript-eslint/tsconfig-utils": {
2194 + "version": "8.66.0",
2195 + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
2196 + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
2197 + "dev": true,
2198 + "license": "MIT",
2199 + "engines": {
2200 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2201 + },
2202 + "funding": {
2203 + "type": "opencollective",
2204 + "url": "https://opencollective.com/typescript-eslint"
2205 + },
2206 + "peerDependencies": {
2207 + "typescript": ">=4.8.4 <6.1.0"
2208 + }
2209 + },
2210 + "node_modules/@typescript-eslint/type-utils": {
2211 + "version": "8.66.0",
2212 + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
2213 + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
2214 + "dev": true,
2215 + "license": "MIT",
2216 + "dependencies": {
2217 + "@typescript-eslint/types": "8.66.0",
2218 + "@typescript-eslint/typescript-estree": "8.66.0",
2219 + "@typescript-eslint/utils": "8.66.0",
2220 + "debug": "^4.4.3",
2221 + "ts-api-utils": "^2.5.0"
2222 + },
2223 + "engines": {
2224 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2225 + },
2226 + "funding": {
2227 + "type": "opencollective",
2228 + "url": "https://opencollective.com/typescript-eslint"
2229 + },
2230 + "peerDependencies": {
2231 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
2232 + "typescript": ">=4.8.4 <6.1.0"
2233 + }
2234 + },
2235 + "node_modules/@typescript-eslint/types": {
2236 + "version": "8.66.0",
2237 + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
2238 + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
2239 + "dev": true,
2240 + "license": "MIT",
2241 + "engines": {
2242 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2243 + },
2244 + "funding": {
2245 + "type": "opencollective",
2246 + "url": "https://opencollective.com/typescript-eslint"
2247 + }
2248 + },
2249 + "node_modules/@typescript-eslint/typescript-estree": {
2250 + "version": "8.66.0",
2251 + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
2252 + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
2253 + "dev": true,
2254 + "license": "MIT",
2255 + "dependencies": {
2256 + "@typescript-eslint/project-service": "8.66.0",
2257 + "@typescript-eslint/tsconfig-utils": "8.66.0",
2258 + "@typescript-eslint/types": "8.66.0",
2259 + "@typescript-eslint/visitor-keys": "8.66.0",
2260 + "debug": "^4.4.3",
2261 + "minimatch": "^10.2.2",
2262 + "semver": "^7.7.3",
2263 + "tinyglobby": "^0.2.15",
2264 + "ts-api-utils": "^2.5.0"
2265 + },
2266 + "engines": {
2267 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2268 + },
2269 + "funding": {
2270 + "type": "opencollective",
2271 + "url": "https://opencollective.com/typescript-eslint"
2272 + },
2273 + "peerDependencies": {
2274 + "typescript": ">=4.8.4 <6.1.0"
2275 + }
2276 + },
2277 + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
2278 + "version": "4.0.4",
2279 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
2280 + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
2281 + "dev": true,
2282 + "license": "MIT",
2283 + "engines": {
2284 + "node": "18 || 20 || >=22"
2285 + }
2286 + },
2287 + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
2288 + "version": "5.0.9",
2289 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
2290 + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
2291 + "dev": true,
2292 + "license": "MIT",
2293 + "dependencies": {
2294 + "balanced-match": "^4.0.2"
2295 + },
2296 + "engines": {
2297 + "node": "20 || >=22"
2298 + }
2299 + },
2300 + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
2301 + "version": "10.2.6",
2302 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
2303 + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
2304 + "dev": true,
2305 + "license": "BlueOak-1.0.0",
2306 + "dependencies": {
2307 + "brace-expansion": "^5.0.8"
2308 + },
2309 + "engines": {
2310 + "node": "18 || 20 || >=22"
2311 + },
2312 + "funding": {
2313 + "url": "https://github.com/sponsors/isaacs"
2314 + }
2315 + },
2316 + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
2317 + "version": "7.8.5",
2318 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
2319 + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
2320 + "dev": true,
2321 + "license": "ISC",
2322 + "bin": {
2323 + "semver": "bin/semver.js"
2324 + },
2325 + "engines": {
2326 + "node": ">=10"
2327 + }
2328 + },
2329 + "node_modules/@typescript-eslint/utils": {
2330 + "version": "8.66.0",
2331 + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
2332 + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
2333 + "dev": true,
2334 + "license": "MIT",
2335 + "dependencies": {
2336 + "@eslint-community/eslint-utils": "^4.9.1",
2337 + "@typescript-eslint/scope-manager": "8.66.0",
2338 + "@typescript-eslint/types": "8.66.0",
2339 + "@typescript-eslint/typescript-estree": "8.66.0"
2340 + },
2341 + "engines": {
2342 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2343 + },
2344 + "funding": {
2345 + "type": "opencollective",
2346 + "url": "https://opencollective.com/typescript-eslint"
2347 + },
2348 + "peerDependencies": {
2349 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
2350 + "typescript": ">=4.8.4 <6.1.0"
2351 + }
2352 + },
2353 + "node_modules/@typescript-eslint/visitor-keys": {
2354 + "version": "8.66.0",
2355 + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
2356 + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
2357 + "dev": true,
2358 + "license": "MIT",
2359 + "dependencies": {
2360 + "@typescript-eslint/types": "8.66.0",
2361 + "eslint-visitor-keys": "^5.0.0"
2362 + },
2363 + "engines": {
2364 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2365 + },
2366 + "funding": {
2367 + "type": "opencollective",
2368 + "url": "https://opencollective.com/typescript-eslint"
2369 + }
2370 + },
2371 + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
2372 + "version": "5.0.1",
2373 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
2374 + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
2375 + "dev": true,
2376 + "license": "Apache-2.0",
2377 + "engines": {
2378 + "node": "^20.19.0 || ^22.13.0 || >=24"
2379 + },
2380 + "funding": {
2381 + "url": "https://opencollective.com/eslint"
2382 + }
2383 + },
2384 + "node_modules/@unrs/resolver-binding-android-arm-eabi": {
2385 + "version": "1.12.2",
2386 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
2387 + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
2388 + "cpu": [
2389 + "arm"
2390 + ],
2391 + "dev": true,
2392 + "license": "MIT",
2393 + "optional": true,
2394 + "os": [
2395 + "android"
2396 + ]
2397 + },
2398 + "node_modules/@unrs/resolver-binding-android-arm64": {
2399 + "version": "1.12.2",
2400 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
2401 + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
2402 + "cpu": [
2403 + "arm64"
2404 + ],
2405 + "dev": true,
2406 + "license": "MIT",
2407 + "optional": true,
2408 + "os": [
2409 + "android"
2410 + ]
2411 + },
2412 + "node_modules/@unrs/resolver-binding-darwin-arm64": {
2413 + "version": "1.12.2",
2414 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
2415 + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
2416 + "cpu": [
2417 + "arm64"
2418 + ],
2419 + "dev": true,
2420 + "license": "MIT",
2421 + "optional": true,
2422 + "os": [
2423 + "darwin"
2424 + ]
2425 + },
2426 + "node_modules/@unrs/resolver-binding-darwin-x64": {
2427 + "version": "1.12.2",
2428 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
2429 + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
2430 + "cpu": [
2431 + "x64"
2432 + ],
2433 + "dev": true,
2434 + "license": "MIT",
2435 + "optional": true,
2436 + "os": [
2437 + "darwin"
2438 + ]
2439 + },
2440 + "node_modules/@unrs/resolver-binding-freebsd-x64": {
2441 + "version": "1.12.2",
2442 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
2443 + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
2444 + "cpu": [
2445 + "x64"
2446 + ],
2447 + "dev": true,
2448 + "license": "MIT",
2449 + "optional": true,
2450 + "os": [
2451 + "freebsd"
2452 + ]
2453 + },
2454 + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
2455 + "version": "1.12.2",
2456 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
2457 + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
2458 + "cpu": [
2459 + "arm"
2460 + ],
2461 + "dev": true,
2462 + "license": "MIT",
2463 + "optional": true,
2464 + "os": [
2465 + "linux"
2466 + ]
2467 + },
2468 + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
2469 + "version": "1.12.2",
2470 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
2471 + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
2472 + "cpu": [
2473 + "arm"
2474 + ],
2475 + "dev": true,
2476 + "license": "MIT",
2477 + "optional": true,
2478 + "os": [
2479 + "linux"
2480 + ]
2481 + },
2482 + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
2483 + "version": "1.12.2",
2484 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
2485 + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
2486 + "cpu": [
2487 + "arm64"
2488 + ],
2489 + "dev": true,
2490 + "libc": [
2491 + "glibc"
2492 + ],
2493 + "license": "MIT",
2494 + "optional": true,
2495 + "os": [
2496 + "linux"
2497 + ]
2498 + },
2499 + "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
2500 + "version": "1.12.2",
2501 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
2502 + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
2503 + "cpu": [
2504 + "arm64"
2505 + ],
2506 + "dev": true,
2507 + "libc": [
2508 + "musl"
2509 + ],
2510 + "license": "MIT",
2511 + "optional": true,
2512 + "os": [
2513 + "linux"
2514 + ]
2515 + },
2516 + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
2517 + "version": "1.12.2",
2518 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
2519 + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
2520 + "cpu": [
2521 + "loong64"
2522 + ],
2523 + "dev": true,
2524 + "libc": [
2525 + "glibc"
2526 + ],
2527 + "license": "MIT",
2528 + "optional": true,
2529 + "os": [
2530 + "linux"
2531 + ]
2532 + },
2533 + "node_modules/@unrs/resolver-binding-linux-loong64-musl": {
2534 + "version": "1.12.2",
2535 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
2536 + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
2537 + "cpu": [
2538 + "loong64"
2539 + ],
2540 + "dev": true,
2541 + "libc": [
2542 + "musl"
2543 + ],
2544 + "license": "MIT",
2545 + "optional": true,
2546 + "os": [
2547 + "linux"
2548 + ]
2549 + },
2550 + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
2551 + "version": "1.12.2",
2552 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
2553 + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
2554 + "cpu": [
2555 + "ppc64"
2556 + ],
2557 + "dev": true,
2558 + "libc": [
2559 + "glibc"
2560 + ],
2561 + "license": "MIT",
2562 + "optional": true,
2563 + "os": [
2564 + "linux"
2565 + ]
2566 + },
2567 + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
2568 + "version": "1.12.2",
2569 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
2570 + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
2571 + "cpu": [
2572 + "riscv64"
2573 + ],
2574 + "dev": true,
2575 + "libc": [
2576 + "glibc"
2577 + ],
2578 + "license": "MIT",
2579 + "optional": true,
2580 + "os": [
2581 + "linux"
2582 + ]
2583 + },
2584 + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
2585 + "version": "1.12.2",
2586 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
2587 + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
2588 + "cpu": [
2589 + "riscv64"
2590 + ],
2591 + "dev": true,
2592 + "libc": [
2593 + "musl"
2594 + ],
2595 + "license": "MIT",
2596 + "optional": true,
2597 + "os": [
2598 + "linux"
2599 + ]
2600 + },
2601 + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
2602 + "version": "1.12.2",
2603 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
2604 + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
2605 + "cpu": [
2606 + "s390x"
2607 + ],
2608 + "dev": true,
2609 + "libc": [
2610 + "glibc"
2611 + ],
2612 + "license": "MIT",
2613 + "optional": true,
2614 + "os": [
2615 + "linux"
2616 + ]
2617 + },
2618 + "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
2619 + "version": "1.12.2",
2620 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
2621 + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
2622 + "cpu": [
2623 + "x64"
2624 + ],
2625 + "dev": true,
2626 + "libc": [
2627 + "glibc"
2628 + ],
2629 + "license": "MIT",
2630 + "optional": true,
2631 + "os": [
2632 + "linux"
2633 + ]
2634 + },
2635 + "node_modules/@unrs/resolver-binding-linux-x64-musl": {
2636 + "version": "1.12.2",
2637 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
2638 + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
2639 + "cpu": [
2640 + "x64"
2641 + ],
2642 + "dev": true,
2643 + "libc": [
2644 + "musl"
2645 + ],
2646 + "license": "MIT",
2647 + "optional": true,
2648 + "os": [
2649 + "linux"
2650 + ]
2651 + },
2652 + "node_modules/@unrs/resolver-binding-openharmony-arm64": {
2653 + "version": "1.12.2",
2654 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
2655 + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
2656 + "cpu": [
2657 + "arm64"
2658 + ],
2659 + "dev": true,
2660 + "license": "MIT",
2661 + "optional": true,
2662 + "os": [
2663 + "openharmony"
2664 + ]
2665 + },
2666 + "node_modules/@unrs/resolver-binding-wasm32-wasi": {
2667 + "version": "1.12.2",
2668 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
2669 + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
2670 + "cpu": [
2671 + "wasm32"
2672 + ],
2673 + "dev": true,
2674 + "license": "MIT",
2675 + "optional": true,
2676 + "dependencies": {
2677 + "@emnapi/core": "1.10.0",
2678 + "@emnapi/runtime": "1.10.0",
2679 + "@napi-rs/wasm-runtime": "^1.1.4"
2680 + },
2681 + "engines": {
2682 + "node": ">=14.0.0"
2683 + }
2684 + },
2685 + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
2686 + "version": "1.10.0",
2687 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
2688 + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
2689 + "dev": true,
2690 + "license": "MIT",
2691 + "optional": true,
2692 + "dependencies": {
2693 + "tslib": "^2.4.0"
2694 + }
2695 + },
2696 + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
2697 + "version": "1.12.2",
2698 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
2699 + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
2700 + "cpu": [
2701 + "arm64"
2702 + ],
2703 + "dev": true,
2704 + "license": "MIT",
2705 + "optional": true,
2706 + "os": [
2707 + "win32"
2708 + ]
2709 + },
2710 + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
2711 + "version": "1.12.2",
2712 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
2713 + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
2714 + "cpu": [
2715 + "ia32"
2716 + ],
2717 + "dev": true,
2718 + "license": "MIT",
2719 + "optional": true,
2720 + "os": [
2721 + "win32"
2722 + ]
2723 + },
2724 + "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
2725 + "version": "1.12.2",
2726 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
2727 + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
2728 + "cpu": [
2729 + "x64"
2730 + ],
2731 + "dev": true,
2732 + "license": "MIT",
2733 + "optional": true,
2734 + "os": [
2735 + "win32"
2736 + ]
2737 + },
2738 + "node_modules/@vitest/expect": {
2739 + "version": "4.1.10",
2740 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
2741 + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
2742 + "dev": true,
2743 + "license": "MIT",
2744 + "dependencies": {
2745 + "@standard-schema/spec": "^1.1.0",
2746 + "@types/chai": "^5.2.2",
2747 + "@vitest/spy": "4.1.10",
2748 + "@vitest/utils": "4.1.10",
2749 + "chai": "^6.2.2",
2750 + "tinyrainbow": "^3.1.0"
2751 + },
2752 + "funding": {
2753 + "url": "https://opencollective.com/vitest"
2754 + }
2755 + },
2756 + "node_modules/@vitest/mocker": {
2757 + "version": "4.1.10",
2758 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
2759 + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
2760 + "dev": true,
2761 + "license": "MIT",
2762 + "dependencies": {
2763 + "@vitest/spy": "4.1.10",
2764 + "estree-walker": "^3.0.3",
2765 + "magic-string": "^0.30.21"
2766 + },
2767 + "funding": {
2768 + "url": "https://opencollective.com/vitest"
2769 + },
2770 + "peerDependencies": {
2771 + "msw": "^2.4.9",
2772 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
2773 + },
2774 + "peerDependenciesMeta": {
2775 + "msw": {
2776 + "optional": true
2777 + },
2778 + "vite": {
2779 + "optional": true
2780 + }
2781 + }
2782 + },
2783 + "node_modules/@vitest/pretty-format": {
2784 + "version": "4.1.10",
2785 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
2786 + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
2787 + "dev": true,
2788 + "license": "MIT",
2789 + "dependencies": {
2790 + "tinyrainbow": "^3.1.0"
2791 + },
2792 + "funding": {
2793 + "url": "https://opencollective.com/vitest"
2794 + }
2795 + },
2796 + "node_modules/@vitest/runner": {
2797 + "version": "4.1.10",
2798 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
2799 + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
2800 + "dev": true,
2801 + "license": "MIT",
2802 + "dependencies": {
2803 + "@vitest/utils": "4.1.10",
2804 + "pathe": "^2.0.3"
2805 + },
2806 + "funding": {
2807 + "url": "https://opencollective.com/vitest"
2808 + }
2809 + },
2810 + "node_modules/@vitest/snapshot": {
2811 + "version": "4.1.10",
2812 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
2813 + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
2814 + "dev": true,
2815 + "license": "MIT",
2816 + "dependencies": {
2817 + "@vitest/pretty-format": "4.1.10",
2818 + "@vitest/utils": "4.1.10",
2819 + "magic-string": "^0.30.21",
2820 + "pathe": "^2.0.3"
2821 + },
2822 + "funding": {
2823 + "url": "https://opencollective.com/vitest"
2824 + }
2825 + },
2826 + "node_modules/@vitest/spy": {
2827 + "version": "4.1.10",
2828 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
2829 + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
2830 + "dev": true,
2831 + "license": "MIT",
2832 + "funding": {
2833 + "url": "https://opencollective.com/vitest"
2834 + }
2835 + },
2836 + "node_modules/@vitest/utils": {
2837 + "version": "4.1.10",
2838 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
2839 + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
2840 + "dev": true,
2841 + "license": "MIT",
2842 + "dependencies": {
2843 + "@vitest/pretty-format": "4.1.10",
2844 + "convert-source-map": "^2.0.0",
2845 + "tinyrainbow": "^3.1.0"
2846 + },
2847 + "funding": {
2848 + "url": "https://opencollective.com/vitest"
2849 + }
2850 + },
2851 + "node_modules/acorn": {
2852 + "version": "8.18.0",
2853 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
2854 + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
2855 + "dev": true,
2856 + "license": "MIT",
2857 + "bin": {
2858 + "acorn": "bin/acorn"
2859 + },
2860 + "engines": {
2861 + "node": ">=0.4.0"
2862 + }
2863 + },
2864 + "node_modules/acorn-jsx": {
2865 + "version": "5.3.2",
2866 + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
2867 + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
2868 + "dev": true,
2869 + "license": "MIT",
2870 + "peerDependencies": {
2871 + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
2872 + }
2873 + },
2874 + "node_modules/ajv": {
2875 + "version": "6.15.0",
2876 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
2877 + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
2878 + "dev": true,
2879 + "license": "MIT",
2880 + "dependencies": {
2881 + "fast-deep-equal": "^3.1.1",
2882 + "fast-json-stable-stringify": "^2.0.0",
2883 + "json-schema-traverse": "^0.4.1",
2884 + "uri-js": "^4.2.2"
2885 + },
2886 + "funding": {
2887 + "type": "github",
2888 + "url": "https://github.com/sponsors/epoberezkin"
2889 + }
2890 + },
2891 + "node_modules/ansi-styles": {
2892 + "version": "4.3.0",
2893 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
2894 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
2895 + "dev": true,
2896 + "license": "MIT",
2897 + "dependencies": {
2898 + "color-convert": "^2.0.1"
2899 + },
2900 + "engines": {
2901 + "node": ">=8"
2902 + },
2903 + "funding": {
2904 + "url": "https://github.com/chalk/ansi-styles?sponsor=1"
2905 + }
2906 + },
2907 + "node_modules/argparse": {
2908 + "version": "2.0.1",
2909 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
2910 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
2911 + "dev": true,
2912 + "license": "Python-2.0"
2913 + },
2914 + "node_modules/aria-query": {
2915 + "version": "5.3.2",
2916 + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
2917 + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
2918 + "dev": true,
2919 + "license": "Apache-2.0",
2920 + "engines": {
2921 + "node": ">= 0.4"
2922 + }
2923 + },
2924 + "node_modules/array-buffer-byte-length": {
2925 + "version": "1.0.2",
2926 + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
2927 + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
2928 + "dev": true,
2929 + "license": "MIT",
2930 + "dependencies": {
2931 + "call-bound": "^1.0.3",
2932 + "is-array-buffer": "^3.0.5"
2933 + },
2934 + "engines": {
2935 + "node": ">= 0.4"
2936 + },
2937 + "funding": {
2938 + "url": "https://github.com/sponsors/ljharb"
2939 + }
2940 + },
2941 + "node_modules/array-includes": {
2942 + "version": "3.1.9",
2943 + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
2944 + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
2945 + "dev": true,
2946 + "license": "MIT",
2947 + "dependencies": {
2948 + "call-bind": "^1.0.8",
2949 + "call-bound": "^1.0.4",
2950 + "define-properties": "^1.2.1",
2951 + "es-abstract": "^1.24.0",
2952 + "es-object-atoms": "^1.1.1",
2953 + "get-intrinsic": "^1.3.0",
2954 + "is-string": "^1.1.1",
2955 + "math-intrinsics": "^1.1.0"
2956 + },
2957 + "engines": {
2958 + "node": ">= 0.4"
2959 + },
2960 + "funding": {
2961 + "url": "https://github.com/sponsors/ljharb"
2962 + }
2963 + },
2964 + "node_modules/array.prototype.findlast": {
2965 + "version": "1.2.5",
2966 + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
2967 + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
2968 + "dev": true,
2969 + "license": "MIT",
2970 + "dependencies": {
2971 + "call-bind": "^1.0.7",
2972 + "define-properties": "^1.2.1",
2973 + "es-abstract": "^1.23.2",
2974 + "es-errors": "^1.3.0",
2975 + "es-object-atoms": "^1.0.0",
2976 + "es-shim-unscopables": "^1.0.2"
2977 + },
2978 + "engines": {
2979 + "node": ">= 0.4"
2980 + },
2981 + "funding": {
2982 + "url": "https://github.com/sponsors/ljharb"
2983 + }
2984 + },
2985 + "node_modules/array.prototype.findlastindex": {
2986 + "version": "1.2.6",
2987 + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
2988 + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
2989 + "dev": true,
2990 + "license": "MIT",
2991 + "dependencies": {
2992 + "call-bind": "^1.0.8",
2993 + "call-bound": "^1.0.4",
2994 + "define-properties": "^1.2.1",
2995 + "es-abstract": "^1.23.9",
2996 + "es-errors": "^1.3.0",
2997 + "es-object-atoms": "^1.1.1",
2998 + "es-shim-unscopables": "^1.1.0"
2999 + },
3000 + "engines": {
3001 + "node": ">= 0.4"
3002 + },
3003 + "funding": {
3004 + "url": "https://github.com/sponsors/ljharb"
3005 + }
3006 + },
3007 + "node_modules/array.prototype.flat": {
3008 + "version": "1.3.3",
3009 + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
3010 + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
3011 + "dev": true,
3012 + "license": "MIT",
3013 + "dependencies": {
3014 + "call-bind": "^1.0.8",
3015 + "define-properties": "^1.2.1",
3016 + "es-abstract": "^1.23.5",
3017 + "es-shim-unscopables": "^1.0.2"
3018 + },
3019 + "engines": {
3020 + "node": ">= 0.4"
3021 + },
3022 + "funding": {
3023 + "url": "https://github.com/sponsors/ljharb"
3024 + }
3025 + },
3026 + "node_modules/array.prototype.flatmap": {
3027 + "version": "1.3.3",
3028 + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
3029 + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
3030 + "dev": true,
3031 + "license": "MIT",
3032 + "dependencies": {
3033 + "call-bind": "^1.0.8",
3034 + "define-properties": "^1.2.1",
3035 + "es-abstract": "^1.23.5",
3036 + "es-shim-unscopables": "^1.0.2"
3037 + },
3038 + "engines": {
3039 + "node": ">= 0.4"
3040 + },
3041 + "funding": {
3042 + "url": "https://github.com/sponsors/ljharb"
3043 + }
3044 + },
3045 + "node_modules/array.prototype.tosorted": {
3046 + "version": "1.1.4",
3047 + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
3048 + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
3049 + "dev": true,
3050 + "license": "MIT",
3051 + "dependencies": {
3052 + "call-bind": "^1.0.7",
3053 + "define-properties": "^1.2.1",
3054 + "es-abstract": "^1.23.3",
3055 + "es-errors": "^1.3.0",
3056 + "es-shim-unscopables": "^1.0.2"
3057 + },
3058 + "engines": {
3059 + "node": ">= 0.4"
3060 + }
3061 + },
3062 + "node_modules/arraybuffer.prototype.slice": {
3063 + "version": "1.0.4",
3064 + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
3065 + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
3066 + "dev": true,
3067 + "license": "MIT",
3068 + "dependencies": {
3069 + "array-buffer-byte-length": "^1.0.1",
3070 + "call-bind": "^1.0.8",
3071 + "define-properties": "^1.2.1",
3072 + "es-abstract": "^1.23.5",
3073 + "es-errors": "^1.3.0",
3074 + "get-intrinsic": "^1.2.6",
3075 + "is-array-buffer": "^3.0.4"
3076 + },
3077 + "engines": {
3078 + "node": ">= 0.4"
3079 + },
3080 + "funding": {
3081 + "url": "https://github.com/sponsors/ljharb"
3082 + }
3083 + },
3084 + "node_modules/assertion-error": {
3085 + "version": "2.0.1",
3086 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
3087 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
3088 + "dev": true,
3089 + "license": "MIT",
3090 + "engines": {
3091 + "node": ">=12"
3092 + }
3093 + },
3094 + "node_modules/ast-types-flow": {
3095 + "version": "0.0.8",
3096 + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
3097 + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
3098 + "dev": true,
3099 + "license": "MIT"
3100 + },
3101 + "node_modules/async-function": {
3102 + "version": "1.0.0",
3103 + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
3104 + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
3105 + "dev": true,
3106 + "license": "MIT",
3107 + "engines": {
3108 + "node": ">= 0.4"
3109 + }
3110 + },
3111 + "node_modules/available-typed-arrays": {
3112 + "version": "1.0.7",
3113 + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
3114 + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
3115 + "dev": true,
3116 + "license": "MIT",
3117 + "dependencies": {
3118 + "possible-typed-array-names": "^1.0.0"
3119 + },
3120 + "engines": {
3121 + "node": ">= 0.4"
3122 + },
3123 + "funding": {
3124 + "url": "https://github.com/sponsors/ljharb"
3125 + }
3126 + },
3127 + "node_modules/axe-core": {
3128 + "version": "4.13.0",
3129 + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
3130 + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
3131 + "dev": true,
3132 + "license": "MPL-2.0",
3133 + "engines": {
3134 + "node": ">=4"
3135 + }
3136 + },
3137 + "node_modules/axobject-query": {
3138 + "version": "4.1.0",
3139 + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
3140 + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
3141 + "dev": true,
3142 + "license": "Apache-2.0",
3143 + "engines": {
3144 + "node": ">= 0.4"
3145 + }
3146 + },
3147 + "node_modules/balanced-match": {
3148 + "version": "1.0.2",
3149 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
3150 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
3151 + "dev": true,
3152 + "license": "MIT"
3153 + },
3154 + "node_modules/base64-js": {
3155 + "version": "1.5.1",
3156 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
3157 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
3158 + "funding": [
3159 + {
3160 + "type": "github",
3161 + "url": "https://github.com/sponsors/feross"
3162 + },
3163 + {
3164 + "type": "patreon",
3165 + "url": "https://www.patreon.com/feross"
3166 + },
3167 + {
3168 + "type": "consulting",
3169 + "url": "https://feross.org/support"
3170 + }
3171 + ],
3172 + "license": "MIT"
3173 + },
3174 + "node_modules/baseline-browser-mapping": {
3175 + "version": "2.11.13",
3176 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
3177 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
3178 + "license": "Apache-2.0",
3179 + "bin": {
3180 + "baseline-browser-mapping": "dist/cli.cjs"
3181 + },
3182 + "engines": {
3183 + "node": ">=6.0.0"
3184 + }
3185 + },
3186 + "node_modules/better-sqlite3": {
3187 + "version": "13.0.3",
3188 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
3189 + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
3190 + "hasInstallScript": true,
3191 + "license": "MIT",
3192 + "dependencies": {
3193 + "node-addon-api": "^8.0.0"
3194 + },
3195 + "engines": {
3196 + "node": ">=22"
3197 + }
3198 + },
3199 + "node_modules/brace-expansion": {
3200 + "version": "1.1.18",
3201 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
3202 + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
3203 + "dev": true,
3204 + "license": "MIT",
3205 + "dependencies": {
3206 + "balanced-match": "^1.0.0",
3207 + "concat-map": "0.0.1"
3208 + }
3209 + },
3210 + "node_modules/braces": {
3211 + "version": "3.0.3",
3212 + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
3213 + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
3214 + "dev": true,
3215 + "license": "MIT",
3216 + "dependencies": {
3217 + "fill-range": "^7.1.1"
3218 + },
3219 + "engines": {
3220 + "node": ">=8"
3221 + }
3222 + },
3223 + "node_modules/brotli": {
3224 + "version": "1.3.3",
3225 + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
3226 + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
3227 + "license": "MIT",
3228 + "dependencies": {
3229 + "base64-js": "^1.1.2"
3230 + }
3231 + },
3232 + "node_modules/browserify-zlib": {
3233 + "version": "0.2.0",
3234 + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
3235 + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
3236 + "license": "MIT",
3237 + "dependencies": {
3238 + "pako": "~1.0.5"
3239 + }
3240 + },
3241 + "node_modules/browserslist": {
3242 + "version": "4.28.8",
3243 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
3244 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
3245 + "dev": true,
3246 + "funding": [
3247 + {
3248 + "type": "opencollective",
3249 + "url": "https://opencollective.com/browserslist"
3250 + },
3251 + {
3252 + "type": "tidelift",
3253 + "url": "https://tidelift.com/funding/github/npm/browserslist"
3254 + },
3255 + {
3256 + "type": "github",
3257 + "url": "https://github.com/sponsors/ai"
3258 + }
3259 + ],
3260 + "license": "MIT",
3261 + "dependencies": {
3262 + "baseline-browser-mapping": "^2.11.12",
3263 + "caniuse-lite": "^1.0.30001809",
3264 + "electron-to-chromium": "^1.5.402",
3265 + "node-releases": "^2.0.53",
3266 + "update-browserslist-db": "^1.3.0"
3267 + },
3268 + "bin": {
3269 + "browserslist": "cli.js"
3270 + },
3271 + "engines": {
3272 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
3273 + }
3274 + },
3275 + "node_modules/call-bind": {
3276 + "version": "1.0.9",
3277 + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
3278 + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
3279 + "dev": true,
3280 + "license": "MIT",
3281 + "dependencies": {
3282 + "call-bind-apply-helpers": "^1.0.2",
3283 + "es-define-property": "^1.0.1",
3284 + "get-intrinsic": "^1.3.0",
3285 + "set-function-length": "^1.2.2"
3286 + },
3287 + "engines": {
3288 + "node": ">= 0.4"
3289 + },
3290 + "funding": {
3291 + "url": "https://github.com/sponsors/ljharb"
3292 + }
3293 + },
3294 + "node_modules/call-bind-apply-helpers": {
3295 + "version": "1.0.2",
3296 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
3297 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
3298 + "dev": true,
3299 + "license": "MIT",
3300 + "dependencies": {
3301 + "es-errors": "^1.3.0",
3302 + "function-bind": "^1.1.2"
3303 + },
3304 + "engines": {
3305 + "node": ">= 0.4"
3306 + }
3307 + },
3308 + "node_modules/call-bound": {
3309 + "version": "1.0.4",
3310 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
3311 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
3312 + "dev": true,
3313 + "license": "MIT",
3314 + "dependencies": {
3315 + "call-bind-apply-helpers": "^1.0.2",
3316 + "get-intrinsic": "^1.3.0"
3317 + },
3318 + "engines": {
3319 + "node": ">= 0.4"
3320 + },
3321 + "funding": {
3322 + "url": "https://github.com/sponsors/ljharb"
3323 + }
3324 + },
3325 + "node_modules/callsites": {
3326 + "version": "3.1.0",
3327 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
3328 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
3329 + "dev": true,
3330 + "license": "MIT",
3331 + "engines": {
3332 + "node": ">=6"
3333 + }
3334 + },
3335 + "node_modules/caniuse-lite": {
3336 + "version": "1.0.30001809",
3337 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
3338 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
3339 + "funding": [
3340 + {
3341 + "type": "opencollective",
3342 + "url": "https://opencollective.com/browserslist"
3343 + },
3344 + {
3345 + "type": "tidelift",
3346 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
3347 + },
3348 + {
3349 + "type": "github",
3350 + "url": "https://github.com/sponsors/ai"
3351 + }
3352 + ],
3353 + "license": "CC-BY-4.0"
3354 + },
3355 + "node_modules/chai": {
3356 + "version": "6.2.2",
3357 + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
3358 + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
3359 + "dev": true,
3360 + "license": "MIT",
3361 + "engines": {
3362 + "node": ">=18"
3363 + }
3364 + },
3365 + "node_modules/chalk": {
3366 + "version": "4.1.2",
3367 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
3368 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
3369 + "dev": true,
3370 + "license": "MIT",
3371 + "dependencies": {
3372 + "ansi-styles": "^4.1.0",
3373 + "supports-color": "^7.1.0"
3374 + },
3375 + "engines": {
3376 + "node": ">=10"
3377 + },
3378 + "funding": {
3379 + "url": "https://github.com/chalk/chalk?sponsor=1"
3380 + }
3381 + },
3382 + "node_modules/client-only": {
3383 + "version": "0.0.1",
3384 + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
3385 + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
3386 + "license": "MIT"
3387 + },
3388 + "node_modules/clone": {
3389 + "version": "2.1.2",
3390 + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
3391 + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
3392 + "license": "MIT",
3393 + "engines": {
3394 + "node": ">=0.8"
3395 + }
3396 + },
3397 + "node_modules/color-convert": {
3398 + "version": "2.0.1",
3399 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
3400 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
3401 + "dev": true,
3402 + "license": "MIT",
3403 + "dependencies": {
3404 + "color-name": "~1.1.4"
3405 + },
3406 + "engines": {
3407 + "node": ">=7.0.0"
3408 + }
3409 + },
3410 + "node_modules/color-name": {
3411 + "version": "1.1.4",
3412 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
3413 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
3414 + "dev": true,
3415 + "license": "MIT"
3416 + },
3417 + "node_modules/concat-map": {
3418 + "version": "0.0.1",
3419 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
3420 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
3421 + "dev": true,
3422 + "license": "MIT"
3423 + },
3424 + "node_modules/convert-source-map": {
3425 + "version": "2.0.0",
3426 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
3427 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
3428 + "dev": true,
3429 + "license": "MIT"
3430 + },
3431 + "node_modules/cross-spawn": {
3432 + "version": "7.0.6",
3433 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
3434 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
3435 + "dev": true,
3436 + "license": "MIT",
3437 + "dependencies": {
3438 + "path-key": "^3.1.0",
3439 + "shebang-command": "^2.0.0",
3440 + "which": "^2.0.1"
3441 + },
3442 + "engines": {
3443 + "node": ">= 8"
3444 + }
3445 + },
3446 + "node_modules/csstype": {
3447 + "version": "3.2.3",
3448 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
3449 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
3450 + "dev": true,
3451 + "license": "MIT"
3452 + },
3453 + "node_modules/damerau-levenshtein": {
3454 + "version": "1.0.8",
3455 + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
3456 + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
3457 + "dev": true,
3458 + "license": "BSD-2-Clause"
3459 + },
3460 + "node_modules/data-view-buffer": {
3461 + "version": "1.0.2",
3462 + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
3463 + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
3464 + "dev": true,
3465 + "license": "MIT",
3466 + "dependencies": {
3467 + "call-bound": "^1.0.3",
3468 + "es-errors": "^1.3.0",
3469 + "is-data-view": "^1.0.2"
3470 + },
3471 + "engines": {
3472 + "node": ">= 0.4"
3473 + },
3474 + "funding": {
3475 + "url": "https://github.com/sponsors/ljharb"
3476 + }
3477 + },
3478 + "node_modules/data-view-byte-length": {
3479 + "version": "1.0.2",
3480 + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
3481 + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
3482 + "dev": true,
3483 + "license": "MIT",
3484 + "dependencies": {
3485 + "call-bound": "^1.0.3",
3486 + "es-errors": "^1.3.0",
3487 + "is-data-view": "^1.0.2"
3488 + },
3489 + "engines": {
3490 + "node": ">= 0.4"
3491 + },
3492 + "funding": {
3493 + "url": "https://github.com/sponsors/inspect-js"
3494 + }
3495 + },
3496 + "node_modules/data-view-byte-offset": {
3497 + "version": "1.0.1",
3498 + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
3499 + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
3500 + "dev": true,
3501 + "license": "MIT",
3502 + "dependencies": {
3503 + "call-bound": "^1.0.2",
3504 + "es-errors": "^1.3.0",
3505 + "is-data-view": "^1.0.1"
3506 + },
3507 + "engines": {
3508 + "node": ">= 0.4"
3509 + },
3510 + "funding": {
3511 + "url": "https://github.com/sponsors/ljharb"
3512 + }
3513 + },
3514 + "node_modules/debug": {
3515 + "version": "4.4.3",
3516 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
3517 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
3518 + "dev": true,
3519 + "license": "MIT",
3520 + "dependencies": {
3521 + "ms": "^2.1.3"
3522 + },
3523 + "engines": {
3524 + "node": ">=6.0"
3525 + },
3526 + "peerDependenciesMeta": {
3527 + "supports-color": {
3528 + "optional": true
3529 + }
3530 + }
3531 + },
3532 + "node_modules/deep-is": {
3533 + "version": "0.1.4",
3534 + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
3535 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
3536 + "dev": true,
3537 + "license": "MIT"
3538 + },
3539 + "node_modules/define-data-property": {
3540 + "version": "1.1.4",
3541 + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
3542 + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
3543 + "dev": true,
3544 + "license": "MIT",
3545 + "dependencies": {
3546 + "es-define-property": "^1.0.0",
3547 + "es-errors": "^1.3.0",
3548 + "gopd": "^1.0.1"
3549 + },
3550 + "engines": {
3551 + "node": ">= 0.4"
3552 + },
3553 + "funding": {
3554 + "url": "https://github.com/sponsors/ljharb"
3555 + }
3556 + },
3557 + "node_modules/define-properties": {
3558 + "version": "1.2.1",
3559 + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
3560 + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
3561 + "dev": true,
3562 + "license": "MIT",
3563 + "dependencies": {
3564 + "define-data-property": "^1.0.1",
3565 + "has-property-descriptors": "^1.0.0",
3566 + "object-keys": "^1.1.1"
3567 + },
3568 + "engines": {
3569 + "node": ">= 0.4"
3570 + },
3571 + "funding": {
3572 + "url": "https://github.com/sponsors/ljharb"
3573 + }
3574 + },
3575 + "node_modules/detect-libc": {
3576 + "version": "2.1.2",
3577 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
3578 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
3579 + "devOptional": true,
3580 + "license": "Apache-2.0",
3581 + "engines": {
3582 + "node": ">=8"
3583 + }
3584 + },
3585 + "node_modules/dfa": {
3586 + "version": "1.2.0",
3587 + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
3588 + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
3589 + "license": "MIT"
3590 + },
3591 + "node_modules/doctrine": {
3592 + "version": "2.1.0",
3593 + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
3594 + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
3595 + "dev": true,
3596 + "license": "Apache-2.0",
3597 + "dependencies": {
3598 + "esutils": "^2.0.2"
3599 + },
3600 + "engines": {
3601 + "node": ">=0.10.0"
3602 + }
3603 + },
3604 + "node_modules/dunder-proto": {
3605 + "version": "1.0.1",
3606 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
3607 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
3608 + "dev": true,
3609 + "license": "MIT",
3610 + "dependencies": {
3611 + "call-bind-apply-helpers": "^1.0.1",
3612 + "es-errors": "^1.3.0",
3613 + "gopd": "^1.2.0"
3614 + },
3615 + "engines": {
3616 + "node": ">= 0.4"
3617 + }
3618 + },
3619 + "node_modules/electron-to-chromium": {
3620 + "version": "1.5.402",
3621 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
3622 + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==",
3623 + "dev": true,
3624 + "license": "ISC"
3625 + },
3626 + "node_modules/emoji-regex": {
3627 + "version": "9.2.2",
3628 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
3629 + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
3630 + "dev": true,
3631 + "license": "MIT"
3632 + },
3633 + "node_modules/enhanced-resolve": {
3634 + "version": "5.24.5",
3635 + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
3636 + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
3637 + "dev": true,
3638 + "license": "MIT",
3639 + "dependencies": {
3640 + "graceful-fs": "^4.2.4",
3641 + "tapable": "^2.3.3"
3642 + },
3643 + "engines": {
3644 + "node": ">=10.13.0"
3645 + }
3646 + },
3647 + "node_modules/es-abstract": {
3648 + "version": "1.24.2",
3649 + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
3650 + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
3651 + "dev": true,
3652 + "license": "MIT",
3653 + "dependencies": {
3654 + "array-buffer-byte-length": "^1.0.2",
3655 + "arraybuffer.prototype.slice": "^1.0.4",
3656 + "available-typed-arrays": "^1.0.7",
3657 + "call-bind": "^1.0.8",
3658 + "call-bound": "^1.0.4",
3659 + "data-view-buffer": "^1.0.2",
3660 + "data-view-byte-length": "^1.0.2",
3661 + "data-view-byte-offset": "^1.0.1",
3662 + "es-define-property": "^1.0.1",
3663 + "es-errors": "^1.3.0",
3664 + "es-object-atoms": "^1.1.1",
3665 + "es-set-tostringtag": "^2.1.0",
3666 + "es-to-primitive": "^1.3.0",
3667 + "function.prototype.name": "^1.1.8",
3668 + "get-intrinsic": "^1.3.0",
3669 + "get-proto": "^1.0.1",
3670 + "get-symbol-description": "^1.1.0",
3671 + "globalthis": "^1.0.4",
3672 + "gopd": "^1.2.0",
3673 + "has-property-descriptors": "^1.0.2",
3674 + "has-proto": "^1.2.0",
3675 + "has-symbols": "^1.1.0",
3676 + "hasown": "^2.0.2",
3677 + "internal-slot": "^1.1.0",
3678 + "is-array-buffer": "^3.0.5",
3679 + "is-callable": "^1.2.7",
3680 + "is-data-view": "^1.0.2",
3681 + "is-negative-zero": "^2.0.3",
3682 + "is-regex": "^1.2.1",
3683 + "is-set": "^2.0.3",
3684 + "is-shared-array-buffer": "^1.0.4",
3685 + "is-string": "^1.1.1",
3686 + "is-typed-array": "^1.1.15",
3687 + "is-weakref": "^1.1.1",
3688 + "math-intrinsics": "^1.1.0",
3689 + "object-inspect": "^1.13.4",
3690 + "object-keys": "^1.1.1",
3691 + "object.assign": "^4.1.7",
3692 + "own-keys": "^1.0.1",
3693 + "regexp.prototype.flags": "^1.5.4",
3694 + "safe-array-concat": "^1.1.3",
3695 + "safe-push-apply": "^1.0.0",
3696 + "safe-regex-test": "^1.1.0",
3697 + "set-proto": "^1.0.0",
3698 + "stop-iteration-iterator": "^1.1.0",
3699 + "string.prototype.trim": "^1.2.10",
3700 + "string.prototype.trimend": "^1.0.9",
3701 + "string.prototype.trimstart": "^1.0.8",
3702 + "typed-array-buffer": "^1.0.3",
3703 + "typed-array-byte-length": "^1.0.3",
3704 + "typed-array-byte-offset": "^1.0.4",
3705 + "typed-array-length": "^1.0.7",
3706 + "unbox-primitive": "^1.1.0",
3707 + "which-typed-array": "^1.1.19"
3708 + },
3709 + "engines": {
3710 + "node": ">= 0.4"
3711 + },
3712 + "funding": {
3713 + "url": "https://github.com/sponsors/ljharb"
3714 + }
3715 + },
3716 + "node_modules/es-abstract-get": {
3717 + "version": "1.0.0",
3718 + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
3719 + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
3720 + "dev": true,
3721 + "license": "MIT",
3722 + "dependencies": {
3723 + "es-errors": "^1.3.0",
3724 + "es-object-atoms": "^1.1.2",
3725 + "is-callable": "^1.2.7",
3726 + "object-inspect": "^1.13.4"
3727 + },
3728 + "engines": {
3729 + "node": ">= 0.4"
3730 + },
3731 + "funding": {
3732 + "url": "https://github.com/sponsors/ljharb"
3733 + }
3734 + },
3735 + "node_modules/es-define-property": {
3736 + "version": "1.0.1",
3737 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
3738 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
3739 + "dev": true,
3740 + "license": "MIT",
3741 + "engines": {
3742 + "node": ">= 0.4"
3743 + }
3744 + },
3745 + "node_modules/es-errors": {
3746 + "version": "1.3.0",
3747 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
3748 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
3749 + "dev": true,
3750 + "license": "MIT",
3751 + "engines": {
3752 + "node": ">= 0.4"
3753 + }
3754 + },
3755 + "node_modules/es-iterator-helpers": {
3756 + "version": "1.4.0",
3757 + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz",
3758 + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==",
3759 + "dev": true,
3760 + "license": "MIT",
3761 + "dependencies": {
3762 + "call-bind": "^1.0.9",
3763 + "call-bound": "^1.0.4",
3764 + "define-properties": "^1.2.1",
3765 + "es-abstract": "^1.24.2",
3766 + "es-errors": "^1.3.0",
3767 + "es-set-tostringtag": "^2.1.0",
3768 + "function-bind": "^1.1.2",
3769 + "get-intrinsic": "^1.3.0",
3770 + "globalthis": "^1.0.4",
3771 + "gopd": "^1.2.0",
3772 + "has-property-descriptors": "^1.0.2",
3773 + "has-proto": "^1.2.0",
3774 + "has-symbols": "^1.1.0",
3775 + "internal-slot": "^1.1.0",
3776 + "iterator.prototype": "^1.1.5",
3777 + "math-intrinsics": "^1.1.0"
3778 + },
3779 + "engines": {
3780 + "node": ">= 0.4"
3781 + }
3782 + },
3783 + "node_modules/es-module-lexer": {
3784 + "version": "2.3.1",
3785 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
3786 + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
3787 + "dev": true,
3788 + "license": "MIT"
3789 + },
3790 + "node_modules/es-object-atoms": {
3791 + "version": "1.1.2",
3792 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
3793 + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
3794 + "dev": true,
3795 + "license": "MIT",
3796 + "dependencies": {
3797 + "es-errors": "^1.3.0"
3798 + },
3799 + "engines": {
3800 + "node": ">= 0.4"
3801 + }
3802 + },
3803 + "node_modules/es-set-tostringtag": {
3804 + "version": "2.1.0",
3805 + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
3806 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
3807 + "dev": true,
3808 + "license": "MIT",
3809 + "dependencies": {
3810 + "es-errors": "^1.3.0",
3811 + "get-intrinsic": "^1.2.6",
3812 + "has-tostringtag": "^1.0.2",
3813 + "hasown": "^2.0.2"
3814 + },
3815 + "engines": {
3816 + "node": ">= 0.4"
3817 + }
3818 + },
3819 + "node_modules/es-shim-unscopables": {
3820 + "version": "1.1.0",
3821 + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
3822 + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
3823 + "dev": true,
3824 + "license": "MIT",
3825 + "dependencies": {
3826 + "hasown": "^2.0.2"
3827 + },
3828 + "engines": {
3829 + "node": ">= 0.4"
3830 + }
3831 + },
3832 + "node_modules/es-to-primitive": {
3833 + "version": "1.3.4",
3834 + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
3835 + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
3836 + "dev": true,
3837 + "license": "MIT",
3838 + "dependencies": {
3839 + "es-abstract-get": "^1.0.0",
3840 + "es-define-property": "^1.0.1",
3841 + "es-errors": "^1.3.0",
3842 + "is-callable": "^1.2.7",
3843 + "is-date-object": "^1.1.0",
3844 + "is-symbol": "^1.1.1"
3845 + },
3846 + "engines": {
3847 + "node": ">= 0.4"
3848 + },
3849 + "funding": {
3850 + "url": "https://github.com/sponsors/ljharb"
3851 + }
3852 + },
3853 + "node_modules/escalade": {
3854 + "version": "3.2.0",
3855 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
3856 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
3857 + "dev": true,
3858 + "license": "MIT",
3859 + "engines": {
3860 + "node": ">=6"
3861 + }
3862 + },
3863 + "node_modules/escape-string-regexp": {
3864 + "version": "4.0.0",
3865 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
3866 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
3867 + "dev": true,
3868 + "license": "MIT",
3869 + "engines": {
3870 + "node": ">=10"
3871 + },
3872 + "funding": {
3873 + "url": "https://github.com/sponsors/sindresorhus"
3874 + }
3875 + },
3876 + "node_modules/eslint": {
3877 + "version": "9.39.5",
3878 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
3879 + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
3880 + "dev": true,
3881 + "license": "MIT",
3882 + "dependencies": {
3883 + "@eslint-community/eslint-utils": "^4.8.0",
3884 + "@eslint-community/regexpp": "^4.12.1",
3885 + "@eslint/config-array": "^0.21.2",
3886 + "@eslint/config-helpers": "^0.4.2",
3887 + "@eslint/core": "^0.17.0",
3888 + "@eslint/eslintrc": "^3.3.6",
3889 + "@eslint/js": "9.39.5",
3890 + "@eslint/plugin-kit": "^0.4.1",
3891 + "@humanfs/node": "^0.16.6",
3892 + "@humanwhocodes/module-importer": "^1.0.1",
3893 + "@humanwhocodes/retry": "^0.4.2",
3894 + "@types/estree": "^1.0.6",
3895 + "ajv": "^6.14.0",
3896 + "chalk": "^4.0.0",
3897 + "cross-spawn": "^7.0.6",
3898 + "debug": "^4.3.2",
3899 + "escape-string-regexp": "^4.0.0",
3900 + "eslint-scope": "^8.4.0",
3901 + "eslint-visitor-keys": "^4.2.1",
3902 + "espree": "^10.4.0",
3903 + "esquery": "^1.5.0",
3904 + "esutils": "^2.0.2",
3905 + "fast-deep-equal": "^3.1.3",
3906 + "file-entry-cache": "^8.0.0",
3907 + "find-up": "^5.0.0",
3908 + "glob-parent": "^6.0.2",
3909 + "ignore": "^5.2.0",
3910 + "imurmurhash": "^0.1.4",
3911 + "is-glob": "^4.0.0",
3912 + "json-stable-stringify-without-jsonify": "^1.0.1",
3913 + "lodash.merge": "^4.6.2",
3914 + "minimatch": "^3.1.5",
3915 + "natural-compare": "^1.4.0",
3916 + "optionator": "^0.9.3"
3917 + },
3918 + "bin": {
3919 + "eslint": "bin/eslint.js"
3920 + },
3921 + "engines": {
3922 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3923 + },
3924 + "funding": {
3925 + "url": "https://eslint.org/donate"
3926 + },
3927 + "peerDependencies": {
3928 + "jiti": "*"
3929 + },
3930 + "peerDependenciesMeta": {
3931 + "jiti": {
3932 + "optional": true
3933 + }
3934 + }
3935 + },
3936 + "node_modules/eslint-config-next": {
3937 + "version": "16.3.0",
3938 + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz",
3939 + "integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==",
3940 + "dev": true,
3941 + "license": "MIT",
3942 + "dependencies": {
3943 + "@next/eslint-plugin-next": "16.3.0",
3944 + "eslint-import-resolver-node": "^0.3.6",
3945 + "eslint-import-resolver-typescript": "^3.5.2",
3946 + "eslint-plugin-import": "^2.32.0",
3947 + "eslint-plugin-jsx-a11y": "^6.10.0",
3948 + "eslint-plugin-react": "^7.37.0",
3949 + "eslint-plugin-react-hooks": "^7.0.0",
3950 + "globals": "16.4.0",
3951 + "typescript-eslint": "^8.46.0"
3952 + },
3953 + "peerDependencies": {
3954 + "eslint": ">=9.0.0",
3955 + "typescript": ">=3.3.1"
3956 + },
3957 + "peerDependenciesMeta": {
3958 + "typescript": {
3959 + "optional": true
3960 + }
3961 + }
3962 + },
3963 + "node_modules/eslint-config-next/node_modules/globals": {
3964 + "version": "16.4.0",
3965 + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
3966 + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
3967 + "dev": true,
3968 + "license": "MIT",
3969 + "engines": {
3970 + "node": ">=18"
3971 + },
3972 + "funding": {
3973 + "url": "https://github.com/sponsors/sindresorhus"
3974 + }
3975 + },
3976 + "node_modules/eslint-import-resolver-node": {
3977 + "version": "0.3.10",
3978 + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
3979 + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==",
3980 + "dev": true,
3981 + "license": "MIT",
3982 + "dependencies": {
3983 + "debug": "^3.2.7",
3984 + "is-core-module": "^2.16.1",
3985 + "resolve": "^2.0.0-next.6"
3986 + }
3987 + },
3988 + "node_modules/eslint-import-resolver-node/node_modules/debug": {
3989 + "version": "3.2.7",
3990 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
3991 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
3992 + "dev": true,
3993 + "license": "MIT",
3994 + "dependencies": {
3995 + "ms": "^2.1.1"
3996 + }
3997 + },
3998 + "node_modules/eslint-import-resolver-typescript": {
3999 + "version": "3.10.1",
4000 + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
4001 + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
4002 + "dev": true,
4003 + "license": "ISC",
4004 + "dependencies": {
4005 + "@nolyfill/is-core-module": "1.0.39",
4006 + "debug": "^4.4.0",
4007 + "get-tsconfig": "^4.10.0",
4008 + "is-bun-module": "^2.0.0",
4009 + "stable-hash": "^0.0.5",
4010 + "tinyglobby": "^0.2.13",
4011 + "unrs-resolver": "^1.6.2"
4012 + },
4013 + "engines": {
4014 + "node": "^14.18.0 || >=16.0.0"
4015 + },
4016 + "funding": {
4017 + "url": "https://opencollective.com/eslint-import-resolver-typescript"
4018 + },
4019 + "peerDependencies": {
4020 + "eslint": "*",
4021 + "eslint-plugin-import": "*",
4022 + "eslint-plugin-import-x": "*"
4023 + },
4024 + "peerDependenciesMeta": {
4025 + "eslint-plugin-import": {
4026 + "optional": true
4027 + },
4028 + "eslint-plugin-import-x": {
4029 + "optional": true
4030 + }
4031 + }
4032 + },
4033 + "node_modules/eslint-module-utils": {
4034 + "version": "2.14.0",
4035 + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz",
4036 + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==",
4037 + "dev": true,
4038 + "license": "MIT",
4039 + "dependencies": {
4040 + "debug": "^3.2.7"
4041 + },
4042 + "engines": {
4043 + "node": ">=4"
4044 + },
4045 + "peerDependenciesMeta": {
4046 + "eslint": {
4047 + "optional": true
4048 + }
4049 + }
4050 + },
4051 + "node_modules/eslint-module-utils/node_modules/debug": {
4052 + "version": "3.2.7",
4053 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
4054 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
4055 + "dev": true,
4056 + "license": "MIT",
4057 + "dependencies": {
4058 + "ms": "^2.1.1"
4059 + }
4060 + },
4061 + "node_modules/eslint-plugin-import": {
4062 + "version": "2.32.0",
4063 + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
4064 + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
4065 + "dev": true,
4066 + "license": "MIT",
4067 + "dependencies": {
4068 + "@rtsao/scc": "^1.1.0",
4069 + "array-includes": "^3.1.9",
4070 + "array.prototype.findlastindex": "^1.2.6",
4071 + "array.prototype.flat": "^1.3.3",
4072 + "array.prototype.flatmap": "^1.3.3",
4073 + "debug": "^3.2.7",
4074 + "doctrine": "^2.1.0",
4075 + "eslint-import-resolver-node": "^0.3.9",
4076 + "eslint-module-utils": "^2.12.1",
4077 + "hasown": "^2.0.2",
4078 + "is-core-module": "^2.16.1",
4079 + "is-glob": "^4.0.3",
4080 + "minimatch": "^3.1.2",
4081 + "object.fromentries": "^2.0.8",
4082 + "object.groupby": "^1.0.3",
4083 + "object.values": "^1.2.1",
4084 + "semver": "^6.3.1",
4085 + "string.prototype.trimend": "^1.0.9",
4086 + "tsconfig-paths": "^3.15.0"
4087 + },
4088 + "engines": {
4089 + "node": ">=4"
4090 + },
4091 + "peerDependencies": {
4092 + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
4093 + }
4094 + },
4095 + "node_modules/eslint-plugin-import/node_modules/debug": {
4096 + "version": "3.2.7",
4097 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
4098 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
4099 + "dev": true,
4100 + "license": "MIT",
4101 + "dependencies": {
4102 + "ms": "^2.1.1"
4103 + }
4104 + },
4105 + "node_modules/eslint-plugin-jsx-a11y": {
4106 + "version": "6.10.2",
4107 + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
4108 + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
4109 + "dev": true,
4110 + "license": "MIT",
4111 + "dependencies": {
4112 + "aria-query": "^5.3.2",
4113 + "array-includes": "^3.1.8",
4114 + "array.prototype.flatmap": "^1.3.2",
4115 + "ast-types-flow": "^0.0.8",
4116 + "axe-core": "^4.10.0",
4117 + "axobject-query": "^4.1.0",
4118 + "damerau-levenshtein": "^1.0.8",
4119 + "emoji-regex": "^9.2.2",
4120 + "hasown": "^2.0.2",
4121 + "jsx-ast-utils": "^3.3.5",
4122 + "language-tags": "^1.0.9",
4123 + "minimatch": "^3.1.2",
4124 + "object.fromentries": "^2.0.8",
4125 + "safe-regex-test": "^1.0.3",
4126 + "string.prototype.includes": "^2.0.1"
4127 + },
4128 + "engines": {
4129 + "node": ">=4.0"
4130 + },
4131 + "peerDependencies": {
4132 + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
4133 + }
4134 + },
4135 + "node_modules/eslint-plugin-react": {
4136 + "version": "7.37.5",
4137 + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
4138 + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
4139 + "dev": true,
4140 + "license": "MIT",
4141 + "dependencies": {
4142 + "array-includes": "^3.1.8",
4143 + "array.prototype.findlast": "^1.2.5",
4144 + "array.prototype.flatmap": "^1.3.3",
4145 + "array.prototype.tosorted": "^1.1.4",
4146 + "doctrine": "^2.1.0",
4147 + "es-iterator-helpers": "^1.2.1",
4148 + "estraverse": "^5.3.0",
4149 + "hasown": "^2.0.2",
4150 + "jsx-ast-utils": "^2.4.1 || ^3.0.0",
4151 + "minimatch": "^3.1.2",
4152 + "object.entries": "^1.1.9",
4153 + "object.fromentries": "^2.0.8",
4154 + "object.values": "^1.2.1",
4155 + "prop-types": "^15.8.1",
4156 + "resolve": "^2.0.0-next.5",
4157 + "semver": "^6.3.1",
4158 + "string.prototype.matchall": "^4.0.12",
4159 + "string.prototype.repeat": "^1.0.0"
4160 + },
4161 + "engines": {
4162 + "node": ">=4"
4163 + },
4164 + "peerDependencies": {
4165 + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
4166 + }
4167 + },
4168 + "node_modules/eslint-plugin-react-hooks": {
4169 + "version": "7.1.1",
4170 + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
4171 + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
4172 + "dev": true,
4173 + "license": "MIT",
4174 + "dependencies": {
4175 + "@babel/core": "^7.24.4",
4176 + "@babel/parser": "^7.24.4",
4177 + "hermes-parser": "^0.25.1",
4178 + "zod": "^3.25.0 || ^4.0.0",
4179 + "zod-validation-error": "^3.5.0 || ^4.0.0"
4180 + },
4181 + "engines": {
4182 + "node": ">=18"
4183 + },
4184 + "peerDependencies": {
4185 + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
4186 + }
4187 + },
4188 + "node_modules/eslint-scope": {
4189 + "version": "8.4.0",
4190 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
4191 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
4192 + "dev": true,
4193 + "license": "BSD-2-Clause",
4194 + "dependencies": {
4195 + "esrecurse": "^4.3.0",
4196 + "estraverse": "^5.2.0"
4197 + },
4198 + "engines": {
4199 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4200 + },
4201 + "funding": {
4202 + "url": "https://opencollective.com/eslint"
4203 + }
4204 + },
4205 + "node_modules/eslint-visitor-keys": {
4206 + "version": "4.2.1",
4207 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
4208 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
4209 + "dev": true,
4210 + "license": "Apache-2.0",
4211 + "engines": {
4212 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4213 + },
4214 + "funding": {
4215 + "url": "https://opencollective.com/eslint"
4216 + }
4217 + },
4218 + "node_modules/espree": {
4219 + "version": "10.4.0",
4220 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
4221 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
4222 + "dev": true,
4223 + "license": "BSD-2-Clause",
4224 + "dependencies": {
4225 + "acorn": "^8.15.0",
4226 + "acorn-jsx": "^5.3.2",
4227 + "eslint-visitor-keys": "^4.2.1"
4228 + },
4229 + "engines": {
4230 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4231 + },
4232 + "funding": {
4233 + "url": "https://opencollective.com/eslint"
4234 + }
4235 + },
4236 + "node_modules/esquery": {
4237 + "version": "1.7.0",
4238 + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
4239 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
4240 + "dev": true,
4241 + "license": "BSD-3-Clause",
4242 + "dependencies": {
4243 + "estraverse": "^5.1.0"
4244 + },
4245 + "engines": {
4246 + "node": ">=0.10"
4247 + }
4248 + },
4249 + "node_modules/esrecurse": {
4250 + "version": "4.3.0",
4251 + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
4252 + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
4253 + "dev": true,
4254 + "license": "BSD-2-Clause",
4255 + "dependencies": {
4256 + "estraverse": "^5.2.0"
4257 + },
4258 + "engines": {
4259 + "node": ">=4.0"
4260 + }
4261 + },
4262 + "node_modules/estraverse": {
4263 + "version": "5.3.0",
4264 + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
4265 + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
4266 + "dev": true,
4267 + "license": "BSD-2-Clause",
4268 + "engines": {
4269 + "node": ">=4.0"
4270 + }
4271 + },
4272 + "node_modules/estree-walker": {
4273 + "version": "3.0.3",
4274 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
4275 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
4276 + "dev": true,
4277 + "license": "MIT",
4278 + "dependencies": {
4279 + "@types/estree": "^1.0.0"
4280 + }
4281 + },
4282 + "node_modules/esutils": {
4283 + "version": "2.0.3",
4284 + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
4285 + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
4286 + "dev": true,
4287 + "license": "BSD-2-Clause",
4288 + "engines": {
4289 + "node": ">=0.10.0"
4290 + }
4291 + },
4292 + "node_modules/expect-type": {
4293 + "version": "1.4.0",
4294 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
4295 + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
4296 + "dev": true,
4297 + "license": "Apache-2.0",
4298 + "engines": {
4299 + "node": ">=12.0.0"
4300 + }
4301 + },
4302 + "node_modules/fast-deep-equal": {
4303 + "version": "3.1.3",
4304 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
4305 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
4306 + "license": "MIT"
4307 + },
4308 + "node_modules/fast-glob": {
4309 + "version": "3.3.1",
4310 + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
4311 + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
4312 + "dev": true,
4313 + "license": "MIT",
4314 + "dependencies": {
4315 + "@nodelib/fs.stat": "^2.0.2",
4316 + "@nodelib/fs.walk": "^1.2.3",
4317 + "glob-parent": "^5.1.2",
4318 + "merge2": "^1.3.0",
4319 + "micromatch": "^4.0.4"
4320 + },
4321 + "engines": {
4322 + "node": ">=8.6.0"
4323 + }
4324 + },
4325 + "node_modules/fast-glob/node_modules/glob-parent": {
4326 + "version": "5.1.2",
4327 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
4328 + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
4329 + "dev": true,
4330 + "license": "ISC",
4331 + "dependencies": {
4332 + "is-glob": "^4.0.1"
4333 + },
4334 + "engines": {
4335 + "node": ">= 6"
4336 + }
4337 + },
4338 + "node_modules/fast-json-stable-stringify": {
4339 + "version": "2.1.0",
4340 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
4341 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
4342 + "dev": true,
4343 + "license": "MIT"
4344 + },
4345 + "node_modules/fast-levenshtein": {
4346 + "version": "2.0.6",
4347 + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
4348 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
4349 + "dev": true,
4350 + "license": "MIT"
4351 + },
4352 + "node_modules/fastq": {
4353 + "version": "1.20.1",
4354 + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
4355 + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
4356 + "dev": true,
4357 + "license": "ISC",
4358 + "dependencies": {
4359 + "reusify": "^1.0.4"
4360 + }
4361 + },
4362 + "node_modules/file-entry-cache": {
4363 + "version": "8.0.0",
4364 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
4365 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
4366 + "dev": true,
4367 + "license": "MIT",
4368 + "dependencies": {
4369 + "flat-cache": "^4.0.0"
4370 + },
4371 + "engines": {
4372 + "node": ">=16.0.0"
4373 + }
4374 + },
4375 + "node_modules/fill-range": {
4376 + "version": "7.1.1",
4377 + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
4378 + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
4379 + "dev": true,
4380 + "license": "MIT",
4381 + "dependencies": {
4382 + "to-regex-range": "^5.0.1"
4383 + },
4384 + "engines": {
4385 + "node": ">=8"
4386 + }
4387 + },
4388 + "node_modules/find-up": {
4389 + "version": "5.0.0",
4390 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
4391 + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
4392 + "dev": true,
4393 + "license": "MIT",
4394 + "dependencies": {
4395 + "locate-path": "^6.0.0",
4396 + "path-exists": "^4.0.0"
4397 + },
4398 + "engines": {
4399 + "node": ">=10"
4400 + },
4401 + "funding": {
4402 + "url": "https://github.com/sponsors/sindresorhus"
4403 + }
4404 + },
4405 + "node_modules/flat-cache": {
4406 + "version": "4.0.1",
4407 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
4408 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
4409 + "dev": true,
4410 + "license": "MIT",
4411 + "dependencies": {
4412 + "flatted": "^3.2.9",
4413 + "keyv": "^4.5.4"
4414 + },
4415 + "engines": {
4416 + "node": ">=16"
4417 + }
4418 + },
4419 + "node_modules/flatted": {
4420 + "version": "3.4.4",
4421 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
4422 + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
4423 + "dev": true,
4424 + "license": "ISC"
4425 + },
4426 + "node_modules/fontkit": {
4427 + "version": "2.0.4",
4428 + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
4429 + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
4430 + "license": "MIT",
4431 + "dependencies": {
4432 + "@swc/helpers": "^0.5.12",
4433 + "brotli": "^1.3.2",
4434 + "clone": "^2.1.2",
4435 + "dfa": "^1.2.0",
4436 + "fast-deep-equal": "^3.1.3",
4437 + "restructure": "^3.0.0",
4438 + "tiny-inflate": "^1.0.3",
4439 + "unicode-properties": "^1.4.0",
4440 + "unicode-trie": "^2.0.0"
4441 + }
4442 + },
4443 + "node_modules/for-each": {
4444 + "version": "0.3.5",
4445 + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
4446 + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
4447 + "dev": true,
4448 + "license": "MIT",
4449 + "dependencies": {
4450 + "is-callable": "^1.2.7"
4451 + },
4452 + "engines": {
4453 + "node": ">= 0.4"
4454 + },
4455 + "funding": {
4456 + "url": "https://github.com/sponsors/ljharb"
4457 + }
4458 + },
4459 + "node_modules/fsevents": {
4460 + "version": "2.3.3",
4461 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
4462 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
4463 + "dev": true,
4464 + "hasInstallScript": true,
4465 + "license": "MIT",
4466 + "optional": true,
4467 + "os": [
4468 + "darwin"
4469 + ],
4470 + "engines": {
4471 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
4472 + }
4473 + },
4474 + "node_modules/function-bind": {
4475 + "version": "1.1.2",
4476 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
4477 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
4478 + "dev": true,
4479 + "license": "MIT",
4480 + "funding": {
4481 + "url": "https://github.com/sponsors/ljharb"
4482 + }
4483 + },
4484 + "node_modules/function.prototype.name": {
4485 + "version": "1.2.0",
4486 + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
4487 + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
4488 + "dev": true,
4489 + "license": "MIT",
4490 + "dependencies": {
4491 + "call-bind": "^1.0.9",
4492 + "call-bound": "^1.0.4",
4493 + "es-define-property": "^1.0.1",
4494 + "es-errors": "^1.3.0",
4495 + "functions-have-names": "^1.2.3",
4496 + "has-property-descriptors": "^1.0.2",
4497 + "hasown": "^2.0.4",
4498 + "is-callable": "^1.2.7",
4499 + "is-document.all": "^1.0.0"
4500 + },
4501 + "engines": {
4502 + "node": ">= 0.4"
4503 + },
4504 + "funding": {
4505 + "url": "https://github.com/sponsors/ljharb"
4506 + }
4507 + },
4508 + "node_modules/functions-have-names": {
4509 + "version": "1.2.3",
4510 + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
4511 + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
4512 + "dev": true,
4513 + "license": "MIT",
4514 + "funding": {
4515 + "url": "https://github.com/sponsors/ljharb"
4516 + }
4517 + },
4518 + "node_modules/generator-function": {
4519 + "version": "2.0.1",
4520 + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
4521 + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
4522 + "dev": true,
4523 + "license": "MIT",
4524 + "engines": {
4525 + "node": ">= 0.4"
4526 + }
4527 + },
4528 + "node_modules/gensync": {
4529 + "version": "1.0.0-beta.2",
4530 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
4531 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
4532 + "dev": true,
4533 + "license": "MIT",
4534 + "engines": {
4535 + "node": ">=6.9.0"
4536 + }
4537 + },
4538 + "node_modules/get-intrinsic": {
4539 + "version": "1.3.0",
4540 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
4541 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
4542 + "dev": true,
4543 + "license": "MIT",
4544 + "dependencies": {
4545 + "call-bind-apply-helpers": "^1.0.2",
4546 + "es-define-property": "^1.0.1",
4547 + "es-errors": "^1.3.0",
4548 + "es-object-atoms": "^1.1.1",
4549 + "function-bind": "^1.1.2",
4550 + "get-proto": "^1.0.1",
4551 + "gopd": "^1.2.0",
4552 + "has-symbols": "^1.1.0",
4553 + "hasown": "^2.0.2",
4554 + "math-intrinsics": "^1.1.0"
4555 + },
4556 + "engines": {
4557 + "node": ">= 0.4"
4558 + },
4559 + "funding": {
4560 + "url": "https://github.com/sponsors/ljharb"
4561 + }
4562 + },
4563 + "node_modules/get-proto": {
4564 + "version": "1.0.1",
4565 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
4566 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
4567 + "dev": true,
4568 + "license": "MIT",
4569 + "dependencies": {
4570 + "dunder-proto": "^1.0.1",
4571 + "es-object-atoms": "^1.0.0"
4572 + },
4573 + "engines": {
4574 + "node": ">= 0.4"
4575 + }
4576 + },
4577 + "node_modules/get-symbol-description": {
4578 + "version": "1.1.0",
4579 + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
4580 + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
4581 + "dev": true,
4582 + "license": "MIT",
4583 + "dependencies": {
4584 + "call-bound": "^1.0.3",
4585 + "es-errors": "^1.3.0",
4586 + "get-intrinsic": "^1.2.6"
4587 + },
4588 + "engines": {
4589 + "node": ">= 0.4"
4590 + },
4591 + "funding": {
4592 + "url": "https://github.com/sponsors/ljharb"
4593 + }
4594 + },
4595 + "node_modules/get-tsconfig": {
4596 + "version": "4.14.1",
4597 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz",
4598 + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==",
4599 + "dev": true,
4600 + "license": "MIT",
4601 + "dependencies": {
4602 + "resolve-pkg-maps": "^1.0.0"
4603 + },
4604 + "funding": {
4605 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
4606 + }
4607 + },
4608 + "node_modules/glob-parent": {
4609 + "version": "6.0.2",
4610 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
4611 + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
4612 + "dev": true,
4613 + "license": "ISC",
4614 + "dependencies": {
4615 + "is-glob": "^4.0.3"
4616 + },
4617 + "engines": {
4618 + "node": ">=10.13.0"
4619 + }
4620 + },
4621 + "node_modules/globals": {
4622 + "version": "14.0.0",
4623 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
4624 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
4625 + "dev": true,
4626 + "license": "MIT",
4627 + "engines": {
4628 + "node": ">=18"
4629 + },
4630 + "funding": {
4631 + "url": "https://github.com/sponsors/sindresorhus"
4632 + }
4633 + },
4634 + "node_modules/globalthis": {
4635 + "version": "1.0.4",
4636 + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
4637 + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
4638 + "dev": true,
4639 + "license": "MIT",
4640 + "dependencies": {
4641 + "define-properties": "^1.2.1",
4642 + "gopd": "^1.0.1"
4643 + },
4644 + "engines": {
4645 + "node": ">= 0.4"
4646 + },
4647 + "funding": {
4648 + "url": "https://github.com/sponsors/ljharb"
4649 + }
4650 + },
4651 + "node_modules/gopd": {
4652 + "version": "1.2.0",
4653 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
4654 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
4655 + "dev": true,
4656 + "license": "MIT",
4657 + "engines": {
4658 + "node": ">= 0.4"
4659 + },
4660 + "funding": {
4661 + "url": "https://github.com/sponsors/ljharb"
4662 + }
4663 + },
4664 + "node_modules/graceful-fs": {
4665 + "version": "4.2.11",
4666 + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
4667 + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
4668 + "dev": true,
4669 + "license": "ISC"
4670 + },
4671 + "node_modules/has-bigints": {
4672 + "version": "1.1.0",
4673 + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
4674 + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
4675 + "dev": true,
4676 + "license": "MIT",
4677 + "engines": {
4678 + "node": ">= 0.4"
4679 + },
4680 + "funding": {
4681 + "url": "https://github.com/sponsors/ljharb"
4682 + }
4683 + },
4684 + "node_modules/has-flag": {
4685 + "version": "4.0.0",
4686 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
4687 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
4688 + "dev": true,
4689 + "license": "MIT",
4690 + "engines": {
4691 + "node": ">=8"
4692 + }
4693 + },
4694 + "node_modules/has-property-descriptors": {
4695 + "version": "1.0.2",
4696 + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
4697 + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
4698 + "dev": true,
4699 + "license": "MIT",
4700 + "dependencies": {
4701 + "es-define-property": "^1.0.0"
4702 + },
4703 + "funding": {
4704 + "url": "https://github.com/sponsors/ljharb"
4705 + }
4706 + },
4707 + "node_modules/has-proto": {
4708 + "version": "1.2.0",
4709 + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
4710 + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
4711 + "dev": true,
4712 + "license": "MIT",
4713 + "dependencies": {
4714 + "dunder-proto": "^1.0.0"
4715 + },
4716 + "engines": {
4717 + "node": ">= 0.4"
4718 + },
4719 + "funding": {
4720 + "url": "https://github.com/sponsors/ljharb"
4721 + }
4722 + },
4723 + "node_modules/has-symbols": {
4724 + "version": "1.1.0",
4725 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
4726 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
4727 + "dev": true,
4728 + "license": "MIT",
4729 + "engines": {
4730 + "node": ">= 0.4"
4731 + },
4732 + "funding": {
4733 + "url": "https://github.com/sponsors/ljharb"
4734 + }
4735 + },
4736 + "node_modules/has-tostringtag": {
4737 + "version": "1.0.2",
4738 + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
4739 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
4740 + "dev": true,
4741 + "license": "MIT",
4742 + "dependencies": {
4743 + "has-symbols": "^1.0.3"
4744 + },
4745 + "engines": {
4746 + "node": ">= 0.4"
4747 + },
4748 + "funding": {
4749 + "url": "https://github.com/sponsors/ljharb"
4750 + }
4751 + },
4752 + "node_modules/hasown": {
4753 + "version": "2.0.4",
4754 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
4755 + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
4756 + "dev": true,
4757 + "license": "MIT",
4758 + "dependencies": {
4759 + "function-bind": "^1.1.2"
4760 + },
4761 + "engines": {
4762 + "node": ">= 0.4"
4763 + }
4764 + },
4765 + "node_modules/hermes-estree": {
4766 + "version": "0.25.1",
4767 + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
4768 + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
4769 + "dev": true,
4770 + "license": "MIT"
4771 + },
4772 + "node_modules/hermes-parser": {
4773 + "version": "0.25.1",
4774 + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
4775 + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
4776 + "dev": true,
4777 + "license": "MIT",
4778 + "dependencies": {
4779 + "hermes-estree": "0.25.1"
4780 + }
4781 + },
4782 + "node_modules/ignore": {
4783 + "version": "5.3.2",
4784 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
4785 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
4786 + "dev": true,
4787 + "license": "MIT",
4788 + "engines": {
4789 + "node": ">= 4"
4790 + }
4791 + },
4792 + "node_modules/import-fresh": {
4793 + "version": "3.3.1",
4794 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
4795 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
4796 + "dev": true,
4797 + "license": "MIT",
4798 + "dependencies": {
4799 + "parent-module": "^1.0.0",
4800 + "resolve-from": "^4.0.0"
4801 + },
4802 + "engines": {
4803 + "node": ">=6"
4804 + },
4805 + "funding": {
4806 + "url": "https://github.com/sponsors/sindresorhus"
4807 + }
4808 + },
4809 + "node_modules/imurmurhash": {
4810 + "version": "0.1.4",
4811 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
4812 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
4813 + "dev": true,
4814 + "license": "MIT",
4815 + "engines": {
4816 + "node": ">=0.8.19"
4817 + }
4818 + },
4819 + "node_modules/internal-slot": {
4820 + "version": "1.1.0",
4821 + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
4822 + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
4823 + "dev": true,
4824 + "license": "MIT",
4825 + "dependencies": {
4826 + "es-errors": "^1.3.0",
4827 + "hasown": "^2.0.2",
4828 + "side-channel": "^1.1.0"
4829 + },
4830 + "engines": {
4831 + "node": ">= 0.4"
4832 + }
4833 + },
4834 + "node_modules/is-array-buffer": {
4835 + "version": "3.0.5",
4836 + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
4837 + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
4838 + "dev": true,
4839 + "license": "MIT",
4840 + "dependencies": {
4841 + "call-bind": "^1.0.8",
4842 + "call-bound": "^1.0.3",
4843 + "get-intrinsic": "^1.2.6"
4844 + },
4845 + "engines": {
4846 + "node": ">= 0.4"
4847 + },
4848 + "funding": {
4849 + "url": "https://github.com/sponsors/ljharb"
4850 + }
4851 + },
4852 + "node_modules/is-async-function": {
4853 + "version": "2.1.1",
4854 + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
4855 + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
4856 + "dev": true,
4857 + "license": "MIT",
4858 + "dependencies": {
4859 + "async-function": "^1.0.0",
4860 + "call-bound": "^1.0.3",
4861 + "get-proto": "^1.0.1",
4862 + "has-tostringtag": "^1.0.2",
4863 + "safe-regex-test": "^1.1.0"
4864 + },
4865 + "engines": {
4866 + "node": ">= 0.4"
4867 + },
4868 + "funding": {
4869 + "url": "https://github.com/sponsors/ljharb"
4870 + }
4871 + },
4872 + "node_modules/is-bigint": {
4873 + "version": "1.1.0",
4874 + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
4875 + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
4876 + "dev": true,
4877 + "license": "MIT",
4878 + "dependencies": {
4879 + "has-bigints": "^1.0.2"
4880 + },
4881 + "engines": {
4882 + "node": ">= 0.4"
4883 + },
4884 + "funding": {
4885 + "url": "https://github.com/sponsors/ljharb"
4886 + }
4887 + },
4888 + "node_modules/is-boolean-object": {
4889 + "version": "1.2.2",
4890 + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
4891 + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
4892 + "dev": true,
4893 + "license": "MIT",
4894 + "dependencies": {
4895 + "call-bound": "^1.0.3",
4896 + "has-tostringtag": "^1.0.2"
4897 + },
4898 + "engines": {
4899 + "node": ">= 0.4"
4900 + },
4901 + "funding": {
4902 + "url": "https://github.com/sponsors/ljharb"
4903 + }
4904 + },
4905 + "node_modules/is-bun-module": {
4906 + "version": "2.0.0",
4907 + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
4908 + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
4909 + "dev": true,
4910 + "license": "MIT",
4911 + "dependencies": {
4912 + "semver": "^7.7.1"
4913 + }
4914 + },
4915 + "node_modules/is-bun-module/node_modules/semver": {
4916 + "version": "7.8.5",
4917 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
4918 + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
4919 + "dev": true,
4920 + "license": "ISC",
4921 + "bin": {
4922 + "semver": "bin/semver.js"
4923 + },
4924 + "engines": {
4925 + "node": ">=10"
4926 + }
4927 + },
4928 + "node_modules/is-callable": {
4929 + "version": "1.2.7",
4930 + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
4931 + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
4932 + "dev": true,
4933 + "license": "MIT",
4934 + "engines": {
4935 + "node": ">= 0.4"
4936 + },
4937 + "funding": {
4938 + "url": "https://github.com/sponsors/ljharb"
4939 + }
4940 + },
4941 + "node_modules/is-core-module": {
4942 + "version": "2.16.2",
4943 + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
4944 + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
4945 + "dev": true,
4946 + "license": "MIT",
4947 + "dependencies": {
4948 + "hasown": "^2.0.3"
4949 + },
4950 + "engines": {
4951 + "node": ">= 0.4"
4952 + },
4953 + "funding": {
4954 + "url": "https://github.com/sponsors/ljharb"
4955 + }
4956 + },
4957 + "node_modules/is-data-view": {
4958 + "version": "1.0.2",
4959 + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
4960 + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
4961 + "dev": true,
4962 + "license": "MIT",
4963 + "dependencies": {
4964 + "call-bound": "^1.0.2",
4965 + "get-intrinsic": "^1.2.6",
4966 + "is-typed-array": "^1.1.13"
4967 + },
4968 + "engines": {
4969 + "node": ">= 0.4"
4970 + },
4971 + "funding": {
4972 + "url": "https://github.com/sponsors/ljharb"
4973 + }
4974 + },
4975 + "node_modules/is-date-object": {
4976 + "version": "1.1.0",
4977 + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
4978 + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
4979 + "dev": true,
4980 + "license": "MIT",
4981 + "dependencies": {
4982 + "call-bound": "^1.0.2",
4983 + "has-tostringtag": "^1.0.2"
4984 + },
4985 + "engines": {
4986 + "node": ">= 0.4"
4987 + },
4988 + "funding": {
4989 + "url": "https://github.com/sponsors/ljharb"
4990 + }
4991 + },
4992 + "node_modules/is-document.all": {
4993 + "version": "1.0.0",
4994 + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
4995 + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
4996 + "dev": true,
4997 + "license": "MIT",
4998 + "dependencies": {
4999 + "call-bound": "^1.0.4"
5000 + },
5001 + "engines": {
5002 + "node": ">= 0.4"
5003 + },
5004 + "funding": {
5005 + "url": "https://github.com/sponsors/ljharb"
5006 + }
5007 + },
5008 + "node_modules/is-extglob": {
5009 + "version": "2.1.1",
5010 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
5011 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
5012 + "dev": true,
5013 + "license": "MIT",
5014 + "engines": {
5015 + "node": ">=0.10.0"
5016 + }
5017 + },
5018 + "node_modules/is-finalizationregistry": {
5019 + "version": "1.1.1",
5020 + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
5021 + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
5022 + "dev": true,
5023 + "license": "MIT",
5024 + "dependencies": {
5025 + "call-bound": "^1.0.3"
5026 + },
5027 + "engines": {
5028 + "node": ">= 0.4"
5029 + },
5030 + "funding": {
5031 + "url": "https://github.com/sponsors/ljharb"
5032 + }
5033 + },
5034 + "node_modules/is-generator-function": {
5035 + "version": "1.1.2",
5036 + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
5037 + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
5038 + "dev": true,
5039 + "license": "MIT",
5040 + "dependencies": {
5041 + "call-bound": "^1.0.4",
5042 + "generator-function": "^2.0.0",
5043 + "get-proto": "^1.0.1",
5044 + "has-tostringtag": "^1.0.2",
5045 + "safe-regex-test": "^1.1.0"
5046 + },
5047 + "engines": {
5048 + "node": ">= 0.4"
5049 + },
5050 + "funding": {
5051 + "url": "https://github.com/sponsors/ljharb"
5052 + }
5053 + },
5054 + "node_modules/is-glob": {
5055 + "version": "4.0.3",
5056 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
5057 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
5058 + "dev": true,
5059 + "license": "MIT",
5060 + "dependencies": {
5061 + "is-extglob": "^2.1.1"
5062 + },
5063 + "engines": {
5064 + "node": ">=0.10.0"
5065 + }
5066 + },
5067 + "node_modules/is-map": {
5068 + "version": "2.0.3",
5069 + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
5070 + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
5071 + "dev": true,
5072 + "license": "MIT",
5073 + "engines": {
5074 + "node": ">= 0.4"
5075 + },
5076 + "funding": {
5077 + "url": "https://github.com/sponsors/ljharb"
5078 + }
5079 + },
5080 + "node_modules/is-negative-zero": {
5081 + "version": "2.0.3",
5082 + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
5083 + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
5084 + "dev": true,
5085 + "license": "MIT",
5086 + "engines": {
5087 + "node": ">= 0.4"
5088 + },
5089 + "funding": {
5090 + "url": "https://github.com/sponsors/ljharb"
5091 + }
5092 + },
5093 + "node_modules/is-number": {
5094 + "version": "7.0.0",
5095 + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
5096 + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
5097 + "dev": true,
5098 + "license": "MIT",
5099 + "engines": {
5100 + "node": ">=0.12.0"
5101 + }
5102 + },
5103 + "node_modules/is-number-object": {
5104 + "version": "1.1.1",
5105 + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
5106 + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
5107 + "dev": true,
5108 + "license": "MIT",
5109 + "dependencies": {
5110 + "call-bound": "^1.0.3",
5111 + "has-tostringtag": "^1.0.2"
5112 + },
5113 + "engines": {
5114 + "node": ">= 0.4"
5115 + },
5116 + "funding": {
5117 + "url": "https://github.com/sponsors/ljharb"
5118 + }
5119 + },
5120 + "node_modules/is-regex": {
5121 + "version": "1.2.1",
5122 + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
5123 + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
5124 + "dev": true,
5125 + "license": "MIT",
5126 + "dependencies": {
5127 + "call-bound": "^1.0.2",
5128 + "gopd": "^1.2.0",
5129 + "has-tostringtag": "^1.0.2",
5130 + "hasown": "^2.0.2"
5131 + },
5132 + "engines": {
5133 + "node": ">= 0.4"
5134 + },
5135 + "funding": {
5136 + "url": "https://github.com/sponsors/ljharb"
5137 + }
5138 + },
5139 + "node_modules/is-set": {
5140 + "version": "2.0.3",
5141 + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
5142 + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
5143 + "dev": true,
5144 + "license": "MIT",
5145 + "engines": {
5146 + "node": ">= 0.4"
5147 + },
5148 + "funding": {
5149 + "url": "https://github.com/sponsors/ljharb"
5150 + }
5151 + },
5152 + "node_modules/is-shared-array-buffer": {
5153 + "version": "1.0.4",
5154 + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
5155 + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
5156 + "dev": true,
5157 + "license": "MIT",
5158 + "dependencies": {
5159 + "call-bound": "^1.0.3"
5160 + },
5161 + "engines": {
5162 + "node": ">= 0.4"
5163 + },
5164 + "funding": {
5165 + "url": "https://github.com/sponsors/ljharb"
5166 + }
5167 + },
5168 + "node_modules/is-string": {
5169 + "version": "1.1.1",
5170 + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
5171 + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
5172 + "dev": true,
5173 + "license": "MIT",
5174 + "dependencies": {
5175 + "call-bound": "^1.0.3",
5176 + "has-tostringtag": "^1.0.2"
5177 + },
5178 + "engines": {
5179 + "node": ">= 0.4"
5180 + },
5181 + "funding": {
5182 + "url": "https://github.com/sponsors/ljharb"
5183 + }
5184 + },
5185 + "node_modules/is-symbol": {
5186 + "version": "1.1.1",
5187 + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
5188 + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
5189 + "dev": true,
5190 + "license": "MIT",
5191 + "dependencies": {
5192 + "call-bound": "^1.0.2",
5193 + "has-symbols": "^1.1.0",
5194 + "safe-regex-test": "^1.1.0"
5195 + },
5196 + "engines": {
5197 + "node": ">= 0.4"
5198 + },
5199 + "funding": {
5200 + "url": "https://github.com/sponsors/ljharb"
5201 + }
5202 + },
5203 + "node_modules/is-typed-array": {
5204 + "version": "1.1.15",
5205 + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
5206 + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
5207 + "dev": true,
5208 + "license": "MIT",
5209 + "dependencies": {
5210 + "which-typed-array": "^1.1.16"
5211 + },
5212 + "engines": {
5213 + "node": ">= 0.4"
5214 + },
5215 + "funding": {
5216 + "url": "https://github.com/sponsors/ljharb"
5217 + }
5218 + },
5219 + "node_modules/is-weakmap": {
5220 + "version": "2.0.2",
5221 + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
5222 + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
5223 + "dev": true,
5224 + "license": "MIT",
5225 + "engines": {
5226 + "node": ">= 0.4"
5227 + },
5228 + "funding": {
5229 + "url": "https://github.com/sponsors/ljharb"
5230 + }
5231 + },
5232 + "node_modules/is-weakref": {
5233 + "version": "1.1.1",
5234 + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
5235 + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
5236 + "dev": true,
5237 + "license": "MIT",
5238 + "dependencies": {
5239 + "call-bound": "^1.0.3"
5240 + },
5241 + "engines": {
5242 + "node": ">= 0.4"
5243 + },
5244 + "funding": {
5245 + "url": "https://github.com/sponsors/ljharb"
5246 + }
5247 + },
5248 + "node_modules/is-weakset": {
5249 + "version": "2.0.4",
5250 + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
5251 + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
5252 + "dev": true,
5253 + "license": "MIT",
5254 + "dependencies": {
5255 + "call-bound": "^1.0.3",
5256 + "get-intrinsic": "^1.2.6"
5257 + },
5258 + "engines": {
5259 + "node": ">= 0.4"
5260 + },
5261 + "funding": {
5262 + "url": "https://github.com/sponsors/ljharb"
5263 + }
5264 + },
5265 + "node_modules/isarray": {
5266 + "version": "2.0.5",
5267 + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
5268 + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
5269 + "dev": true,
5270 + "license": "MIT"
5271 + },
5272 + "node_modules/isexe": {
5273 + "version": "2.0.0",
5274 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
5275 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
5276 + "dev": true,
5277 + "license": "ISC"
5278 + },
5279 + "node_modules/iterator.prototype": {
5280 + "version": "1.1.5",
5281 + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
5282 + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
5283 + "dev": true,
5284 + "license": "MIT",
5285 + "dependencies": {
5286 + "define-data-property": "^1.1.4",
5287 + "es-object-atoms": "^1.0.0",
5288 + "get-intrinsic": "^1.2.6",
5289 + "get-proto": "^1.0.0",
5290 + "has-symbols": "^1.1.0",
5291 + "set-function-name": "^2.0.2"
5292 + },
5293 + "engines": {
5294 + "node": ">= 0.4"
5295 + }
5296 + },
5297 + "node_modules/jiti": {
5298 + "version": "2.7.0",
5299 + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
5300 + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
5301 + "dev": true,
5302 + "license": "MIT",
5303 + "bin": {
5304 + "jiti": "lib/jiti-cli.mjs"
5305 + }
5306 + },
5307 + "node_modules/js-md5": {
5308 + "version": "0.8.3",
5309 + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
5310 + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
5311 + "license": "MIT"
5312 + },
5313 + "node_modules/js-tokens": {
5314 + "version": "4.0.0",
5315 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
5316 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
5317 + "dev": true,
5318 + "license": "MIT"
5319 + },
5320 + "node_modules/js-yaml": {
5321 + "version": "4.3.1",
5322 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
5323 + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
5324 + "dev": true,
5325 + "funding": [
5326 + {
5327 + "type": "github",
5328 + "url": "https://github.com/sponsors/puzrin"
5329 + },
5330 + {
5331 + "type": "github",
5332 + "url": "https://github.com/sponsors/nodeca"
5333 + }
5334 + ],
5335 + "license": "MIT",
5336 + "dependencies": {
5337 + "argparse": "^2.0.1"
5338 + },
5339 + "bin": {
5340 + "js-yaml": "bin/js-yaml.js"
5341 + }
5342 + },
5343 + "node_modules/jsesc": {
5344 + "version": "3.1.0",
5345 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
5346 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
5347 + "dev": true,
5348 + "license": "MIT",
5349 + "bin": {
5350 + "jsesc": "bin/jsesc"
5351 + },
5352 + "engines": {
5353 + "node": ">=6"
5354 + }
5355 + },
5356 + "node_modules/json-buffer": {
5357 + "version": "3.0.1",
5358 + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
5359 + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
5360 + "dev": true,
5361 + "license": "MIT"
5362 + },
5363 + "node_modules/json-schema-traverse": {
5364 + "version": "0.4.1",
5365 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
5366 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
5367 + "dev": true,
5368 + "license": "MIT"
5369 + },
5370 + "node_modules/json-stable-stringify-without-jsonify": {
5371 + "version": "1.0.1",
5372 + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
5373 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
5374 + "dev": true,
5375 + "license": "MIT"
5376 + },
5377 + "node_modules/json5": {
5378 + "version": "2.2.3",
5379 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
5380 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
5381 + "dev": true,
5382 + "license": "MIT",
5383 + "bin": {
5384 + "json5": "lib/cli.js"
5385 + },
5386 + "engines": {
5387 + "node": ">=6"
5388 + }
5389 + },
5390 + "node_modules/jsx-ast-utils": {
5391 + "version": "3.3.5",
5392 + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
5393 + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
5394 + "dev": true,
5395 + "license": "MIT",
5396 + "dependencies": {
5397 + "array-includes": "^3.1.6",
5398 + "array.prototype.flat": "^1.3.1",
5399 + "object.assign": "^4.1.4",
5400 + "object.values": "^1.1.6"
5401 + },
5402 + "engines": {
5403 + "node": ">=4.0"
5404 + }
5405 + },
5406 + "node_modules/keyv": {
5407 + "version": "4.5.4",
5408 + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
5409 + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
5410 + "dev": true,
5411 + "license": "MIT",
5412 + "dependencies": {
5413 + "json-buffer": "3.0.1"
5414 + }
5415 + },
5416 + "node_modules/language-subtag-registry": {
5417 + "version": "0.3.23",
5418 + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
5419 + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
5420 + "dev": true,
5421 + "license": "CC0-1.0"
5422 + },
5423 + "node_modules/language-tags": {
5424 + "version": "1.0.9",
5425 + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
5426 + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
5427 + "dev": true,
5428 + "license": "MIT",
5429 + "dependencies": {
5430 + "language-subtag-registry": "^0.3.20"
5431 + },
5432 + "engines": {
5433 + "node": ">=0.10"
5434 + }
5435 + },
5436 + "node_modules/levn": {
5437 + "version": "0.4.1",
5438 + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
5439 + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
5440 + "dev": true,
5441 + "license": "MIT",
5442 + "dependencies": {
5443 + "prelude-ls": "^1.2.1",
5444 + "type-check": "~0.4.0"
5445 + },
5446 + "engines": {
5447 + "node": ">= 0.8.0"
5448 + }
5449 + },
5450 + "node_modules/lightningcss": {
5451 + "version": "1.32.0",
5452 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
5453 + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
5454 + "dev": true,
5455 + "license": "MPL-2.0",
5456 + "dependencies": {
5457 + "detect-libc": "^2.0.3"
5458 + },
5459 + "engines": {
5460 + "node": ">= 12.0.0"
5461 + },
5462 + "funding": {
5463 + "type": "opencollective",
5464 + "url": "https://opencollective.com/parcel"
5465 + },
5466 + "optionalDependencies": {
5467 + "lightningcss-android-arm64": "1.32.0",
5468 + "lightningcss-darwin-arm64": "1.32.0",
5469 + "lightningcss-darwin-x64": "1.32.0",
5470 + "lightningcss-freebsd-x64": "1.32.0",
5471 + "lightningcss-linux-arm-gnueabihf": "1.32.0",
5472 + "lightningcss-linux-arm64-gnu": "1.32.0",
5473 + "lightningcss-linux-arm64-musl": "1.32.0",
5474 + "lightningcss-linux-x64-gnu": "1.32.0",
5475 + "lightningcss-linux-x64-musl": "1.32.0",
5476 + "lightningcss-win32-arm64-msvc": "1.32.0",
5477 + "lightningcss-win32-x64-msvc": "1.32.0"
5478 + }
5479 + },
5480 + "node_modules/lightningcss-android-arm64": {
5481 + "version": "1.32.0",
5482 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
5483 + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
5484 + "cpu": [
5485 + "arm64"
5486 + ],
5487 + "dev": true,
5488 + "license": "MPL-2.0",
5489 + "optional": true,
5490 + "os": [
5491 + "android"
5492 + ],
5493 + "engines": {
5494 + "node": ">= 12.0.0"
5495 + },
5496 + "funding": {
5497 + "type": "opencollective",
5498 + "url": "https://opencollective.com/parcel"
5499 + }
5500 + },
5501 + "node_modules/lightningcss-darwin-arm64": {
5502 + "version": "1.32.0",
5503 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
5504 + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
5505 + "cpu": [
5506 + "arm64"
5507 + ],
5508 + "dev": true,
5509 + "license": "MPL-2.0",
5510 + "optional": true,
5511 + "os": [
5512 + "darwin"
5513 + ],
5514 + "engines": {
5515 + "node": ">= 12.0.0"
5516 + },
5517 + "funding": {
5518 + "type": "opencollective",
5519 + "url": "https://opencollective.com/parcel"
5520 + }
5521 + },
5522 + "node_modules/lightningcss-darwin-x64": {
5523 + "version": "1.32.0",
5524 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
5525 + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
5526 + "cpu": [
5527 + "x64"
5528 + ],
5529 + "dev": true,
5530 + "license": "MPL-2.0",
5531 + "optional": true,
5532 + "os": [
5533 + "darwin"
5534 + ],
5535 + "engines": {
5536 + "node": ">= 12.0.0"
5537 + },
5538 + "funding": {
5539 + "type": "opencollective",
5540 + "url": "https://opencollective.com/parcel"
5541 + }
5542 + },
5543 + "node_modules/lightningcss-freebsd-x64": {
5544 + "version": "1.32.0",
5545 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
5546 + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
5547 + "cpu": [
5548 + "x64"
5549 + ],
5550 + "dev": true,
5551 + "license": "MPL-2.0",
5552 + "optional": true,
5553 + "os": [
5554 + "freebsd"
5555 + ],
5556 + "engines": {
5557 + "node": ">= 12.0.0"
5558 + },
5559 + "funding": {
5560 + "type": "opencollective",
5561 + "url": "https://opencollective.com/parcel"
5562 + }
5563 + },
5564 + "node_modules/lightningcss-linux-arm-gnueabihf": {
5565 + "version": "1.32.0",
5566 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
5567 + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
5568 + "cpu": [
5569 + "arm"
5570 + ],
5571 + "dev": true,
5572 + "license": "MPL-2.0",
5573 + "optional": true,
5574 + "os": [
5575 + "linux"
5576 + ],
5577 + "engines": {
5578 + "node": ">= 12.0.0"
5579 + },
5580 + "funding": {
5581 + "type": "opencollective",
5582 + "url": "https://opencollective.com/parcel"
5583 + }
5584 + },
5585 + "node_modules/lightningcss-linux-arm64-gnu": {
5586 + "version": "1.32.0",
5587 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
5588 + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
5589 + "cpu": [
5590 + "arm64"
5591 + ],
5592 + "dev": true,
5593 + "libc": [
5594 + "glibc"
5595 + ],
5596 + "license": "MPL-2.0",
5597 + "optional": true,
5598 + "os": [
5599 + "linux"
5600 + ],
5601 + "engines": {
5602 + "node": ">= 12.0.0"
5603 + },
5604 + "funding": {
5605 + "type": "opencollective",
5606 + "url": "https://opencollective.com/parcel"
5607 + }
5608 + },
5609 + "node_modules/lightningcss-linux-arm64-musl": {
5610 + "version": "1.32.0",
5611 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
5612 + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
5613 + "cpu": [
5614 + "arm64"
5615 + ],
5616 + "dev": true,
5617 + "libc": [
5618 + "musl"
5619 + ],
5620 + "license": "MPL-2.0",
5621 + "optional": true,
5622 + "os": [
5623 + "linux"
5624 + ],
5625 + "engines": {
5626 + "node": ">= 12.0.0"
5627 + },
5628 + "funding": {
5629 + "type": "opencollective",
5630 + "url": "https://opencollective.com/parcel"
5631 + }
5632 + },
5633 + "node_modules/lightningcss-linux-x64-gnu": {
5634 + "version": "1.32.0",
5635 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
5636 + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
5637 + "cpu": [
5638 + "x64"
5639 + ],
5640 + "dev": true,
5641 + "libc": [
5642 + "glibc"
5643 + ],
5644 + "license": "MPL-2.0",
5645 + "optional": true,
5646 + "os": [
5647 + "linux"
5648 + ],
5649 + "engines": {
5650 + "node": ">= 12.0.0"
5651 + },
5652 + "funding": {
5653 + "type": "opencollective",
5654 + "url": "https://opencollective.com/parcel"
5655 + }
5656 + },
5657 + "node_modules/lightningcss-linux-x64-musl": {
5658 + "version": "1.32.0",
5659 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
5660 + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
5661 + "cpu": [
5662 + "x64"
5663 + ],
5664 + "dev": true,
5665 + "libc": [
5666 + "musl"
5667 + ],
5668 + "license": "MPL-2.0",
5669 + "optional": true,
5670 + "os": [
5671 + "linux"
5672 + ],
5673 + "engines": {
5674 + "node": ">= 12.0.0"
5675 + },
5676 + "funding": {
5677 + "type": "opencollective",
5678 + "url": "https://opencollective.com/parcel"
5679 + }
5680 + },
5681 + "node_modules/lightningcss-win32-arm64-msvc": {
5682 + "version": "1.32.0",
5683 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
5684 + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
5685 + "cpu": [
5686 + "arm64"
5687 + ],
5688 + "dev": true,
5689 + "license": "MPL-2.0",
5690 + "optional": true,
5691 + "os": [
5692 + "win32"
5693 + ],
5694 + "engines": {
5695 + "node": ">= 12.0.0"
5696 + },
5697 + "funding": {
5698 + "type": "opencollective",
5699 + "url": "https://opencollective.com/parcel"
5700 + }
5701 + },
5702 + "node_modules/lightningcss-win32-x64-msvc": {
5703 + "version": "1.32.0",
5704 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
5705 + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
5706 + "cpu": [
5707 + "x64"
5708 + ],
5709 + "dev": true,
5710 + "license": "MPL-2.0",
5711 + "optional": true,
5712 + "os": [
5713 + "win32"
5714 + ],
5715 + "engines": {
5716 + "node": ">= 12.0.0"
5717 + },
5718 + "funding": {
5719 + "type": "opencollective",
5720 + "url": "https://opencollective.com/parcel"
5721 + }
5722 + },
5723 + "node_modules/linebreak": {
5724 + "version": "1.1.0",
5725 + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
5726 + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
5727 + "license": "MIT",
5728 + "dependencies": {
5729 + "base64-js": "0.0.8",
5730 + "unicode-trie": "^2.0.0"
5731 + }
5732 + },
5733 + "node_modules/linebreak/node_modules/base64-js": {
5734 + "version": "0.0.8",
5735 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
5736 + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
5737 + "license": "MIT",
5738 + "engines": {
5739 + "node": ">= 0.4"
5740 + }
5741 + },
5742 + "node_modules/locate-path": {
5743 + "version": "6.0.0",
5744 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
5745 + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
5746 + "dev": true,
5747 + "license": "MIT",
5748 + "dependencies": {
5749 + "p-locate": "^5.0.0"
5750 + },
5751 + "engines": {
5752 + "node": ">=10"
5753 + },
5754 + "funding": {
5755 + "url": "https://github.com/sponsors/sindresorhus"
5756 + }
5757 + },
5758 + "node_modules/lodash.merge": {
5759 + "version": "4.6.2",
5760 + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
5761 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
5762 + "dev": true,
5763 + "license": "MIT"
5764 + },
5765 + "node_modules/loose-envify": {
5766 + "version": "1.4.0",
5767 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
5768 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
5769 + "dev": true,
5770 + "license": "MIT",
5771 + "dependencies": {
5772 + "js-tokens": "^3.0.0 || ^4.0.0"
5773 + },
5774 + "bin": {
5775 + "loose-envify": "cli.js"
5776 + }
5777 + },
5778 + "node_modules/lru-cache": {
5779 + "version": "5.1.1",
5780 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
5781 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
5782 + "dev": true,
5783 + "license": "ISC",
5784 + "dependencies": {
5785 + "yallist": "^3.0.2"
5786 + }
5787 + },
5788 + "node_modules/magic-string": {
5789 + "version": "0.30.21",
5790 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
5791 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
5792 + "dev": true,
5793 + "license": "MIT",
5794 + "dependencies": {
5795 + "@jridgewell/sourcemap-codec": "^1.5.5"
5796 + }
5797 + },
5798 + "node_modules/math-intrinsics": {
5799 + "version": "1.1.0",
5800 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
5801 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
5802 + "dev": true,
5803 + "license": "MIT",
5804 + "engines": {
5805 + "node": ">= 0.4"
5806 + }
5807 + },
5808 + "node_modules/merge2": {
5809 + "version": "1.4.1",
5810 + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
5811 + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
5812 + "dev": true,
5813 + "license": "MIT",
5814 + "engines": {
5815 + "node": ">= 8"
5816 + }
5817 + },
5818 + "node_modules/micromatch": {
5819 + "version": "4.0.8",
5820 + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
5821 + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
5822 + "dev": true,
5823 + "license": "MIT",
5824 + "dependencies": {
5825 + "braces": "^3.0.3",
5826 + "picomatch": "^2.3.1"
5827 + },
5828 + "engines": {
5829 + "node": ">=8.6"
5830 + }
5831 + },
5832 + "node_modules/minimatch": {
5833 + "version": "3.1.5",
5834 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
5835 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
5836 + "dev": true,
5837 + "license": "ISC",
5838 + "dependencies": {
5839 + "brace-expansion": "^1.1.7"
5840 + },
5841 + "engines": {
5842 + "node": "*"
5843 + }
5844 + },
5845 + "node_modules/minimist": {
5846 + "version": "1.2.8",
5847 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
5848 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
5849 + "dev": true,
5850 + "license": "MIT",
5851 + "funding": {
5852 + "url": "https://github.com/sponsors/ljharb"
5853 + }
5854 + },
5855 + "node_modules/ms": {
5856 + "version": "2.1.3",
5857 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
5858 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5859 + "dev": true,
5860 + "license": "MIT"
5861 + },
5862 + "node_modules/nanoid": {
5863 + "version": "3.3.18",
5864 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
5865 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
5866 + "funding": [
5867 + {
5868 + "type": "github",
5869 + "url": "https://github.com/sponsors/ai"
5870 + }
5871 + ],
5872 + "license": "MIT",
5873 + "bin": {
5874 + "nanoid": "bin/nanoid.cjs"
5875 + },
5876 + "engines": {
5877 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
5878 + }
5879 + },
5880 + "node_modules/napi-postinstall": {
5881 + "version": "0.3.4",
5882 + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
5883 + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
5884 + "dev": true,
5885 + "license": "MIT",
5886 + "bin": {
5887 + "napi-postinstall": "lib/cli.js"
5888 + },
5889 + "engines": {
5890 + "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
5891 + },
5892 + "funding": {
5893 + "url": "https://opencollective.com/napi-postinstall"
5894 + }
5895 + },
5896 + "node_modules/natural-compare": {
5897 + "version": "1.4.0",
5898 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
5899 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
5900 + "dev": true,
5901 + "license": "MIT"
5902 + },
5903 + "node_modules/next": {
5904 + "version": "16.3.0",
5905 + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
5906 + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
5907 + "license": "MIT",
5908 + "dependencies": {
5909 + "@next/env": "16.3.0",
5910 + "@swc/helpers": "0.5.15",
5911 + "baseline-browser-mapping": "^2.9.19",
5912 + "caniuse-lite": "^1.0.30001579",
5913 + "postcss": "8.5.23",
5914 + "styled-jsx": "5.1.6"
5915 + },
5916 + "bin": {
5917 + "next": "dist/bin/next"
5918 + },
5919 + "engines": {
5920 + "node": ">=20.9.0"
5921 + },
5922 + "optionalDependencies": {
5923 + "@next/swc-darwin-arm64": "16.3.0",
5924 + "@next/swc-darwin-x64": "16.3.0",
5925 + "@next/swc-linux-arm64-gnu": "16.3.0",
5926 + "@next/swc-linux-arm64-musl": "16.3.0",
5927 + "@next/swc-linux-x64-gnu": "16.3.0",
5928 + "@next/swc-linux-x64-musl": "16.3.0",
5929 + "@next/swc-win32-arm64-msvc": "16.3.0",
5930 + "@next/swc-win32-x64-msvc": "16.3.0",
5931 + "sharp": "^0.35.3"
5932 + },
5933 + "peerDependencies": {
5934 + "@opentelemetry/api": "^1.1.0",
5935 + "@playwright/test": "^1.51.1",
5936 + "babel-plugin-react-compiler": "*",
5937 + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
5938 + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
5939 + "sass": "^1.3.0"
5940 + },
5941 + "peerDependenciesMeta": {
5942 + "@opentelemetry/api": {
5943 + "optional": true
5944 + },
5945 + "@playwright/test": {
5946 + "optional": true
5947 + },
5948 + "babel-plugin-react-compiler": {
5949 + "optional": true
5950 + },
5951 + "sass": {
5952 + "optional": true
5953 + }
5954 + }
5955 + },
5956 + "node_modules/next/node_modules/postcss": {
5957 + "version": "8.5.23",
5958 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
5959 + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
5960 + "funding": [
5961 + {
5962 + "type": "opencollective",
5963 + "url": "https://opencollective.com/postcss/"
5964 + },
5965 + {
5966 + "type": "tidelift",
5967 + "url": "https://tidelift.com/funding/github/npm/postcss"
5968 + },
5969 + {
5970 + "type": "github",
5971 + "url": "https://github.com/sponsors/ai"
5972 + }
5973 + ],
5974 + "license": "MIT",
5975 + "dependencies": {
5976 + "nanoid": "^3.3.16",
5977 + "picocolors": "^1.1.1",
5978 + "source-map-js": "^1.2.1"
5979 + },
5980 + "engines": {
5981 + "node": "^10 || ^12 || >=14"
5982 + }
5983 + },
5984 + "node_modules/node-addon-api": {
5985 + "version": "8.9.1",
5986 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
5987 + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
5988 + "license": "MIT",
5989 + "engines": {
5990 + "node": "^18 || ^20 || >= 21"
5991 + }
5992 + },
5993 + "node_modules/node-exports-info": {
5994 + "version": "1.6.2",
5995 + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
5996 + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==",
5997 + "dev": true,
5998 + "license": "MIT",
5999 + "dependencies": {
6000 + "array.prototype.flatmap": "^1.3.3",
6001 + "es-errors": "^1.3.0",
6002 + "object.entries": "^1.1.9",
6003 + "semver": "^6.3.1"
6004 + },
6005 + "engines": {
6006 + "node": ">= 0.4"
6007 + },
6008 + "funding": {
6009 + "url": "https://github.com/sponsors/ljharb"
6010 + }
6011 + },
6012 + "node_modules/node-releases": {
6013 + "version": "2.0.53",
6014 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
6015 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
6016 + "dev": true,
6017 + "license": "MIT",
6018 + "engines": {
6019 + "node": ">=18"
6020 + }
6021 + },
6022 + "node_modules/object-assign": {
6023 + "version": "4.1.1",
6024 + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
6025 + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
6026 + "dev": true,
6027 + "license": "MIT",
6028 + "engines": {
6029 + "node": ">=0.10.0"
6030 + }
6031 + },
6032 + "node_modules/object-inspect": {
6033 + "version": "1.13.4",
6034 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
6035 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
6036 + "dev": true,
6037 + "license": "MIT",
6038 + "engines": {
6039 + "node": ">= 0.4"
6040 + },
6041 + "funding": {
6042 + "url": "https://github.com/sponsors/ljharb"
6043 + }
6044 + },
6045 + "node_modules/object-keys": {
6046 + "version": "1.1.1",
6047 + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
6048 + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
6049 + "dev": true,
6050 + "license": "MIT",
6051 + "engines": {
6052 + "node": ">= 0.4"
6053 + }
6054 + },
6055 + "node_modules/object.assign": {
6056 + "version": "4.1.7",
6057 + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
6058 + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
6059 + "dev": true,
6060 + "license": "MIT",
6061 + "dependencies": {
6062 + "call-bind": "^1.0.8",
6063 + "call-bound": "^1.0.3",
6064 + "define-properties": "^1.2.1",
6065 + "es-object-atoms": "^1.0.0",
6066 + "has-symbols": "^1.1.0",
6067 + "object-keys": "^1.1.1"
6068 + },
6069 + "engines": {
6070 + "node": ">= 0.4"
6071 + },
6072 + "funding": {
6073 + "url": "https://github.com/sponsors/ljharb"
6074 + }
6075 + },
6076 + "node_modules/object.entries": {
6077 + "version": "1.1.9",
6078 + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
6079 + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
6080 + "dev": true,
6081 + "license": "MIT",
6082 + "dependencies": {
6083 + "call-bind": "^1.0.8",
6084 + "call-bound": "^1.0.4",
6085 + "define-properties": "^1.2.1",
6086 + "es-object-atoms": "^1.1.1"
6087 + },
6088 + "engines": {
6089 + "node": ">= 0.4"
6090 + }
6091 + },
6092 + "node_modules/object.fromentries": {
6093 + "version": "2.0.8",
6094 + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
6095 + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
6096 + "dev": true,
6097 + "license": "MIT",
6098 + "dependencies": {
6099 + "call-bind": "^1.0.7",
6100 + "define-properties": "^1.2.1",
6101 + "es-abstract": "^1.23.2",
6102 + "es-object-atoms": "^1.0.0"
6103 + },
6104 + "engines": {
6105 + "node": ">= 0.4"
6106 + },
6107 + "funding": {
6108 + "url": "https://github.com/sponsors/ljharb"
6109 + }
6110 + },
6111 + "node_modules/object.groupby": {
6112 + "version": "1.0.3",
6113 + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
6114 + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
6115 + "dev": true,
6116 + "license": "MIT",
6117 + "dependencies": {
6118 + "call-bind": "^1.0.7",
6119 + "define-properties": "^1.2.1",
6120 + "es-abstract": "^1.23.2"
6121 + },
6122 + "engines": {
6123 + "node": ">= 0.4"
6124 + }
6125 + },
6126 + "node_modules/object.values": {
6127 + "version": "1.2.1",
6128 + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
6129 + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
6130 + "dev": true,
6131 + "license": "MIT",
6132 + "dependencies": {
6133 + "call-bind": "^1.0.8",
6134 + "call-bound": "^1.0.3",
6135 + "define-properties": "^1.2.1",
6136 + "es-object-atoms": "^1.0.0"
6137 + },
6138 + "engines": {
6139 + "node": ">= 0.4"
6140 + },
6141 + "funding": {
6142 + "url": "https://github.com/sponsors/ljharb"
6143 + }
6144 + },
6145 + "node_modules/obug": {
6146 + "version": "2.1.4",
6147 + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
6148 + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
6149 + "dev": true,
6150 + "funding": [
6151 + "https://github.com/sponsors/sxzz",
6152 + "https://opencollective.com/debug"
6153 + ],
6154 + "license": "MIT",
6155 + "engines": {
6156 + "node": ">=12.20.0"
6157 + }
6158 + },
6159 + "node_modules/optionator": {
6160 + "version": "0.9.4",
6161 + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
6162 + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
6163 + "dev": true,
6164 + "license": "MIT",
6165 + "dependencies": {
6166 + "deep-is": "^0.1.3",
6167 + "fast-levenshtein": "^2.0.6",
6168 + "levn": "^0.4.1",
6169 + "prelude-ls": "^1.2.1",
6170 + "type-check": "^0.4.0",
6171 + "word-wrap": "^1.2.5"
6172 + },
6173 + "engines": {
6174 + "node": ">= 0.8.0"
6175 + }
6176 + },
6177 + "node_modules/own-keys": {
6178 + "version": "1.0.2",
6179 + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
6180 + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==",
6181 + "dev": true,
6182 + "license": "MIT",
6183 + "dependencies": {
6184 + "call-bound": "^1.0.4",
6185 + "get-intrinsic": "^1.3.0",
6186 + "object-keys": "^1.1.1",
6187 + "safe-push-apply": "^1.0.0"
6188 + },
6189 + "engines": {
6190 + "node": ">= 0.4"
6191 + },
6192 + "funding": {
6193 + "url": "https://github.com/sponsors/ljharb"
6194 + }
6195 + },
6196 + "node_modules/p-limit": {
6197 + "version": "3.1.0",
6198 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
6199 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
6200 + "dev": true,
6201 + "license": "MIT",
6202 + "dependencies": {
6203 + "yocto-queue": "^0.1.0"
6204 + },
6205 + "engines": {
6206 + "node": ">=10"
6207 + },
6208 + "funding": {
6209 + "url": "https://github.com/sponsors/sindresorhus"
6210 + }
6211 + },
6212 + "node_modules/p-locate": {
6213 + "version": "5.0.0",
6214 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
6215 + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
6216 + "dev": true,
6217 + "license": "MIT",
6218 + "dependencies": {
6219 + "p-limit": "^3.0.2"
6220 + },
6221 + "engines": {
6222 + "node": ">=10"
6223 + },
6224 + "funding": {
6225 + "url": "https://github.com/sponsors/sindresorhus"
6226 + }
6227 + },
6228 + "node_modules/pako": {
6229 + "version": "1.0.11",
6230 + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
6231 + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
6232 + "license": "(MIT AND Zlib)"
6233 + },
6234 + "node_modules/parent-module": {
6235 + "version": "1.0.1",
6236 + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
6237 + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
6238 + "dev": true,
6239 + "license": "MIT",
6240 + "dependencies": {
6241 + "callsites": "^3.0.0"
6242 + },
6243 + "engines": {
6244 + "node": ">=6"
6245 + }
6246 + },
6247 + "node_modules/path-exists": {
6248 + "version": "4.0.0",
6249 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
6250 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
6251 + "dev": true,
6252 + "license": "MIT",
6253 + "engines": {
6254 + "node": ">=8"
6255 + }
6256 + },
6257 + "node_modules/path-key": {
6258 + "version": "3.1.1",
6259 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
6260 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
6261 + "dev": true,
6262 + "license": "MIT",
6263 + "engines": {
6264 + "node": ">=8"
6265 + }
6266 + },
6267 + "node_modules/path-parse": {
6268 + "version": "1.0.7",
6269 + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
6270 + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
6271 + "dev": true,
6272 + "license": "MIT"
6273 + },
6274 + "node_modules/pathe": {
6275 + "version": "2.0.3",
6276 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
6277 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
6278 + "dev": true,
6279 + "license": "MIT"
6280 + },
6281 + "node_modules/pdfkit": {
6282 + "version": "0.19.1",
6283 + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
6284 + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
6285 + "license": "MIT",
6286 + "dependencies": {
6287 + "@noble/ciphers": "^1.0.0",
6288 + "@noble/hashes": "^1.6.0",
6289 + "fontkit": "^2.0.4",
6290 + "js-md5": "^0.8.3",
6291 + "linebreak": "^1.1.0",
6292 + "png-js": "^1.1.0"
6293 + }
6294 + },
6295 + "node_modules/picocolors": {
6296 + "version": "1.1.1",
6297 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
6298 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
6299 + "license": "ISC"
6300 + },
6301 + "node_modules/picomatch": {
6302 + "version": "2.3.2",
6303 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
6304 + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
6305 + "dev": true,
6306 + "license": "MIT",
6307 + "engines": {
6308 + "node": ">=8.6"
6309 + },
6310 + "funding": {
6311 + "url": "https://github.com/sponsors/jonschlinkert"
6312 + }
6313 + },
6314 + "node_modules/playwright": {
6315 + "version": "1.62.1",
6316 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
6317 + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
6318 + "dev": true,
6319 + "license": "Apache-2.0",
6320 + "dependencies": {
6321 + "playwright-core": "1.62.1"
6322 + },
6323 + "bin": {
6324 + "playwright": "cli.js"
6325 + },
6326 + "engines": {
6327 + "node": ">=20"
6328 + },
6329 + "optionalDependencies": {
6330 + "fsevents": "2.3.2"
6331 + }
6332 + },
6333 + "node_modules/playwright-core": {
6334 + "version": "1.62.1",
6335 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
6336 + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
6337 + "dev": true,
6338 + "license": "Apache-2.0",
6339 + "bin": {
6340 + "playwright-core": "cli.js"
6341 + },
6342 + "engines": {
6343 + "node": ">=20"
6344 + }
6345 + },
6346 + "node_modules/playwright/node_modules/fsevents": {
6347 + "version": "2.3.2",
6348 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
6349 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
6350 + "dev": true,
6351 + "hasInstallScript": true,
6352 + "license": "MIT",
6353 + "optional": true,
6354 + "os": [
6355 + "darwin"
6356 + ],
6357 + "engines": {
6358 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
6359 + }
6360 + },
6361 + "node_modules/png-js": {
6362 + "version": "1.1.0",
6363 + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
6364 + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
6365 + "dependencies": {
6366 + "browserify-zlib": "^0.2.0"
6367 + }
6368 + },
6369 + "node_modules/possible-typed-array-names": {
6370 + "version": "1.1.0",
6371 + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
6372 + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
6373 + "dev": true,
6374 + "license": "MIT",
6375 + "engines": {
6376 + "node": ">= 0.4"
6377 + }
6378 + },
6379 + "node_modules/postcss": {
6380 + "version": "8.5.26",
6381 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
6382 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
6383 + "dev": true,
6384 + "funding": [
6385 + {
6386 + "type": "opencollective",
6387 + "url": "https://opencollective.com/postcss/"
6388 + },
6389 + {
6390 + "type": "tidelift",
6391 + "url": "https://tidelift.com/funding/github/npm/postcss"
6392 + },
6393 + {
6394 + "type": "github",
6395 + "url": "https://github.com/sponsors/ai"
6396 + }
6397 + ],
6398 + "license": "MIT",
6399 + "dependencies": {
6400 + "nanoid": "^3.3.17",
6401 + "picocolors": "^1.1.1",
6402 + "source-map-js": "^1.2.1"
6403 + },
6404 + "engines": {
6405 + "node": "^10 || ^12 || >=14"
6406 + }
6407 + },
6408 + "node_modules/prelude-ls": {
6409 + "version": "1.2.1",
6410 + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
6411 + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
6412 + "dev": true,
6413 + "license": "MIT",
6414 + "engines": {
6415 + "node": ">= 0.8.0"
6416 + }
6417 + },
6418 + "node_modules/prop-types": {
6419 + "version": "15.8.1",
6420 + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
6421 + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
6422 + "dev": true,
6423 + "license": "MIT",
6424 + "dependencies": {
6425 + "loose-envify": "^1.4.0",
6426 + "object-assign": "^4.1.1",
6427 + "react-is": "^16.13.1"
6428 + }
6429 + },
6430 + "node_modules/punycode": {
6431 + "version": "2.3.1",
6432 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
6433 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
6434 + "dev": true,
6435 + "license": "MIT",
6436 + "engines": {
6437 + "node": ">=6"
6438 + }
6439 + },
6440 + "node_modules/queue-microtask": {
6441 + "version": "1.2.3",
6442 + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
6443 + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
6444 + "dev": true,
6445 + "funding": [
6446 + {
6447 + "type": "github",
6448 + "url": "https://github.com/sponsors/feross"
6449 + },
6450 + {
6451 + "type": "patreon",
6452 + "url": "https://www.patreon.com/feross"
6453 + },
6454 + {
6455 + "type": "consulting",
6456 + "url": "https://feross.org/support"
6457 + }
6458 + ],
6459 + "license": "MIT"
6460 + },
6461 + "node_modules/react": {
6462 + "version": "19.2.8",
6463 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
6464 + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
6465 + "license": "MIT",
6466 + "engines": {
6467 + "node": ">=0.10.0"
6468 + }
6469 + },
6470 + "node_modules/react-dom": {
6471 + "version": "19.2.8",
6472 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
6473 + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
6474 + "license": "MIT",
6475 + "dependencies": {
6476 + "scheduler": "^0.27.0"
6477 + },
6478 + "peerDependencies": {
6479 + "react": "^19.2.8"
6480 + }
6481 + },
6482 + "node_modules/react-is": {
6483 + "version": "16.13.1",
6484 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
6485 + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
6486 + "dev": true,
6487 + "license": "MIT"
6488 + },
6489 + "node_modules/reflect.getprototypeof": {
6490 + "version": "1.0.10",
6491 + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
6492 + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
6493 + "dev": true,
6494 + "license": "MIT",
6495 + "dependencies": {
6496 + "call-bind": "^1.0.8",
6497 + "define-properties": "^1.2.1",
6498 + "es-abstract": "^1.23.9",
6499 + "es-errors": "^1.3.0",
6500 + "es-object-atoms": "^1.0.0",
6501 + "get-intrinsic": "^1.2.7",
6502 + "get-proto": "^1.0.1",
6503 + "which-builtin-type": "^1.2.1"
6504 + },
6505 + "engines": {
6506 + "node": ">= 0.4"
6507 + },
6508 + "funding": {
6509 + "url": "https://github.com/sponsors/ljharb"
6510 + }
6511 + },
6512 + "node_modules/regexp.prototype.flags": {
6513 + "version": "1.5.4",
6514 + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
6515 + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
6516 + "dev": true,
6517 + "license": "MIT",
6518 + "dependencies": {
6519 + "call-bind": "^1.0.8",
6520 + "define-properties": "^1.2.1",
6521 + "es-errors": "^1.3.0",
6522 + "get-proto": "^1.0.1",
6523 + "gopd": "^1.2.0",
6524 + "set-function-name": "^2.0.2"
6525 + },
6526 + "engines": {
6527 + "node": ">= 0.4"
6528 + },
6529 + "funding": {
6530 + "url": "https://github.com/sponsors/ljharb"
6531 + }
6532 + },
6533 + "node_modules/resolve": {
6534 + "version": "2.0.0-next.7",
6535 + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
6536 + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==",
6537 + "dev": true,
6538 + "license": "MIT",
6539 + "dependencies": {
6540 + "es-errors": "^1.3.0",
6541 + "is-core-module": "^2.16.2",
6542 + "node-exports-info": "^1.6.0",
6543 + "object-keys": "^1.1.1",
6544 + "path-parse": "^1.0.7",
6545 + "supports-preserve-symlinks-flag": "^1.0.0"
6546 + },
6547 + "bin": {
6548 + "resolve": "bin/resolve"
6549 + },
6550 + "engines": {
6551 + "node": ">= 0.4"
6552 + },
6553 + "funding": {
6554 + "url": "https://github.com/sponsors/ljharb"
6555 + }
6556 + },
6557 + "node_modules/resolve-from": {
6558 + "version": "4.0.0",
6559 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
6560 + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
6561 + "dev": true,
6562 + "license": "MIT",
6563 + "engines": {
6564 + "node": ">=4"
6565 + }
6566 + },
6567 + "node_modules/resolve-pkg-maps": {
6568 + "version": "1.0.0",
6569 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
6570 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
6571 + "dev": true,
6572 + "license": "MIT",
6573 + "funding": {
6574 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
6575 + }
6576 + },
6577 + "node_modules/restructure": {
6578 + "version": "3.0.2",
6579 + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
6580 + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
6581 + "license": "MIT"
6582 + },
6583 + "node_modules/reusify": {
6584 + "version": "1.1.0",
6585 + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
6586 + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
6587 + "dev": true,
6588 + "license": "MIT",
6589 + "engines": {
6590 + "iojs": ">=1.0.0",
6591 + "node": ">=0.10.0"
6592 + }
6593 + },
6594 + "node_modules/rolldown": {
6595 + "version": "1.2.3",
6596 + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
6597 + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
6598 + "dev": true,
6599 + "license": "MIT",
6600 + "dependencies": {
6601 + "@oxc-project/types": "=0.143.0",
6602 + "@rolldown/pluginutils": "^1.0.0"
6603 + },
6604 + "bin": {
6605 + "rolldown": "bin/cli.mjs"
6606 + },
6607 + "engines": {
6608 + "node": "^20.19.0 || >=22.12.0"
6609 + },
6610 + "optionalDependencies": {
6611 + "@rolldown/binding-android-arm64": "1.2.3",
6612 + "@rolldown/binding-darwin-arm64": "1.2.3",
6613 + "@rolldown/binding-darwin-x64": "1.2.3",
6614 + "@rolldown/binding-freebsd-x64": "1.2.3",
6615 + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
6616 + "@rolldown/binding-linux-arm64-gnu": "1.2.3",
6617 + "@rolldown/binding-linux-arm64-musl": "1.2.3",
6618 + "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
6619 + "@rolldown/binding-linux-s390x-gnu": "1.2.3",
6620 + "@rolldown/binding-linux-x64-gnu": "1.2.3",
6621 + "@rolldown/binding-linux-x64-musl": "1.2.3",
6622 + "@rolldown/binding-openharmony-arm64": "1.2.3",
6623 + "@rolldown/binding-win32-arm64-msvc": "1.2.3",
6624 + "@rolldown/binding-win32-x64-msvc": "1.2.3"
6625 + }
6626 + },
6627 + "node_modules/run-parallel": {
6628 + "version": "1.2.0",
6629 + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
6630 + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
6631 + "dev": true,
6632 + "funding": [
6633 + {
6634 + "type": "github",
6635 + "url": "https://github.com/sponsors/feross"
6636 + },
6637 + {
6638 + "type": "patreon",
6639 + "url": "https://www.patreon.com/feross"
6640 + },
6641 + {
6642 + "type": "consulting",
6643 + "url": "https://feross.org/support"
6644 + }
6645 + ],
6646 + "license": "MIT",
6647 + "dependencies": {
6648 + "queue-microtask": "^1.2.2"
6649 + }
6650 + },
6651 + "node_modules/safe-array-concat": {
6652 + "version": "1.1.4",
6653 + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
6654 + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
6655 + "dev": true,
6656 + "license": "MIT",
6657 + "dependencies": {
6658 + "call-bind": "^1.0.9",
6659 + "call-bound": "^1.0.4",
6660 + "get-intrinsic": "^1.3.0",
6661 + "has-symbols": "^1.1.0",
6662 + "isarray": "^2.0.5"
6663 + },
6664 + "engines": {
6665 + "node": ">=0.4"
6666 + },
6667 + "funding": {
6668 + "url": "https://github.com/sponsors/ljharb"
6669 + }
6670 + },
6671 + "node_modules/safe-push-apply": {
6672 + "version": "1.0.0",
6673 + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
6674 + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
6675 + "dev": true,
6676 + "license": "MIT",
6677 + "dependencies": {
6678 + "es-errors": "^1.3.0",
6679 + "isarray": "^2.0.5"
6680 + },
6681 + "engines": {
6682 + "node": ">= 0.4"
6683 + },
6684 + "funding": {
6685 + "url": "https://github.com/sponsors/ljharb"
6686 + }
6687 + },
6688 + "node_modules/safe-regex-test": {
6689 + "version": "1.1.0",
6690 + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
6691 + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
6692 + "dev": true,
6693 + "license": "MIT",
6694 + "dependencies": {
6695 + "call-bound": "^1.0.2",
6696 + "es-errors": "^1.3.0",
6697 + "is-regex": "^1.2.1"
6698 + },
6699 + "engines": {
6700 + "node": ">= 0.4"
6701 + },
6702 + "funding": {
6703 + "url": "https://github.com/sponsors/ljharb"
6704 + }
6705 + },
6706 + "node_modules/scheduler": {
6707 + "version": "0.27.0",
6708 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
6709 + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
6710 + "license": "MIT"
6711 + },
6712 + "node_modules/semver": {
6713 + "version": "6.3.1",
6714 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
6715 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
6716 + "dev": true,
6717 + "license": "ISC",
6718 + "bin": {
6719 + "semver": "bin/semver.js"
6720 + }
6721 + },
6722 + "node_modules/set-function-length": {
6723 + "version": "1.2.2",
6724 + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
6725 + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
6726 + "dev": true,
6727 + "license": "MIT",
6728 + "dependencies": {
6729 + "define-data-property": "^1.1.4",
6730 + "es-errors": "^1.3.0",
6731 + "function-bind": "^1.1.2",
6732 + "get-intrinsic": "^1.2.4",
6733 + "gopd": "^1.0.1",
6734 + "has-property-descriptors": "^1.0.2"
6735 + },
6736 + "engines": {
6737 + "node": ">= 0.4"
6738 + }
6739 + },
6740 + "node_modules/set-function-name": {
6741 + "version": "2.0.2",
6742 + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
6743 + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
6744 + "dev": true,
6745 + "license": "MIT",
6746 + "dependencies": {
6747 + "define-data-property": "^1.1.4",
6748 + "es-errors": "^1.3.0",
6749 + "functions-have-names": "^1.2.3",
6750 + "has-property-descriptors": "^1.0.2"
6751 + },
6752 + "engines": {
6753 + "node": ">= 0.4"
6754 + }
6755 + },
6756 + "node_modules/set-proto": {
6757 + "version": "1.0.0",
6758 + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
6759 + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
6760 + "dev": true,
6761 + "license": "MIT",
6762 + "dependencies": {
6763 + "dunder-proto": "^1.0.1",
6764 + "es-errors": "^1.3.0",
6765 + "es-object-atoms": "^1.0.0"
6766 + },
6767 + "engines": {
6768 + "node": ">= 0.4"
6769 + }
6770 + },
6771 + "node_modules/sharp": {
6772 + "version": "0.35.3",
6773 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
6774 + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
6775 + "license": "Apache-2.0",
6776 + "optional": true,
6777 + "dependencies": {
6778 + "@img/colour": "^1.1.0",
6779 + "detect-libc": "^2.1.2",
6780 + "semver": "^7.8.5"
6781 + },
6782 + "engines": {
6783 + "node": ">=20.9.0"
6784 + },
6785 + "funding": {
6786 + "url": "https://opencollective.com/libvips"
6787 + },
6788 + "optionalDependencies": {
6789 + "@img/sharp-darwin-arm64": "0.35.3",
6790 + "@img/sharp-darwin-x64": "0.35.3",
6791 + "@img/sharp-freebsd-wasm32": "0.35.3",
6792 + "@img/sharp-libvips-darwin-arm64": "1.3.2",
6793 + "@img/sharp-libvips-darwin-x64": "1.3.2",
6794 + "@img/sharp-libvips-linux-arm": "1.3.2",
6795 + "@img/sharp-libvips-linux-arm64": "1.3.2",
6796 + "@img/sharp-libvips-linux-ppc64": "1.3.2",
6797 + "@img/sharp-libvips-linux-riscv64": "1.3.2",
6798 + "@img/sharp-libvips-linux-s390x": "1.3.2",
6799 + "@img/sharp-libvips-linux-x64": "1.3.2",
6800 + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
6801 + "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
6802 + "@img/sharp-linux-arm": "0.35.3",
6803 + "@img/sharp-linux-arm64": "0.35.3",
6804 + "@img/sharp-linux-ppc64": "0.35.3",
6805 + "@img/sharp-linux-riscv64": "0.35.3",
6806 + "@img/sharp-linux-s390x": "0.35.3",
6807 + "@img/sharp-linux-x64": "0.35.3",
6808 + "@img/sharp-linuxmusl-arm64": "0.35.3",
6809 + "@img/sharp-linuxmusl-x64": "0.35.3",
6810 + "@img/sharp-webcontainers-wasm32": "0.35.3",
6811 + "@img/sharp-win32-arm64": "0.35.3",
6812 + "@img/sharp-win32-ia32": "0.35.3",
6813 + "@img/sharp-win32-x64": "0.35.3"
6814 + },
6815 + "peerDependenciesMeta": {
6816 + "@types/node": {
6817 + "optional": true
6818 + }
6819 + }
6820 + },
6821 + "node_modules/sharp/node_modules/semver": {
6822 + "version": "7.8.5",
6823 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
6824 + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
6825 + "license": "ISC",
6826 + "optional": true,
6827 + "bin": {
6828 + "semver": "bin/semver.js"
6829 + },
6830 + "engines": {
6831 + "node": ">=10"
6832 + }
6833 + },
6834 + "node_modules/shebang-command": {
6835 + "version": "2.0.0",
6836 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
6837 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
6838 + "dev": true,
6839 + "license": "MIT",
6840 + "dependencies": {
6841 + "shebang-regex": "^3.0.0"
6842 + },
6843 + "engines": {
6844 + "node": ">=8"
6845 + }
6846 + },
6847 + "node_modules/shebang-regex": {
6848 + "version": "3.0.0",
6849 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
6850 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
6851 + "dev": true,
6852 + "license": "MIT",
6853 + "engines": {
6854 + "node": ">=8"
6855 + }
6856 + },
6857 + "node_modules/side-channel": {
6858 + "version": "1.1.1",
6859 + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
6860 + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
6861 + "dev": true,
6862 + "license": "MIT",
6863 + "dependencies": {
6864 + "es-errors": "^1.3.0",
6865 + "object-inspect": "^1.13.4",
6866 + "side-channel-list": "^1.0.1",
6867 + "side-channel-map": "^1.0.1",
6868 + "side-channel-weakmap": "^1.0.2"
6869 + },
6870 + "engines": {
6871 + "node": ">= 0.4"
6872 + },
6873 + "funding": {
6874 + "url": "https://github.com/sponsors/ljharb"
6875 + }
6876 + },
6877 + "node_modules/side-channel-list": {
6878 + "version": "1.0.1",
6879 + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
6880 + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
6881 + "dev": true,
6882 + "license": "MIT",
6883 + "dependencies": {
6884 + "es-errors": "^1.3.0",
6885 + "object-inspect": "^1.13.4"
6886 + },
6887 + "engines": {
6888 + "node": ">= 0.4"
6889 + },
6890 + "funding": {
6891 + "url": "https://github.com/sponsors/ljharb"
6892 + }
6893 + },
6894 + "node_modules/side-channel-map": {
6895 + "version": "1.0.1",
6896 + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
6897 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
6898 + "dev": true,
6899 + "license": "MIT",
6900 + "dependencies": {
6901 + "call-bound": "^1.0.2",
6902 + "es-errors": "^1.3.0",
6903 + "get-intrinsic": "^1.2.5",
6904 + "object-inspect": "^1.13.3"
6905 + },
6906 + "engines": {
6907 + "node": ">= 0.4"
6908 + },
6909 + "funding": {
6910 + "url": "https://github.com/sponsors/ljharb"
6911 + }
6912 + },
6913 + "node_modules/side-channel-weakmap": {
6914 + "version": "1.0.2",
6915 + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
6916 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
6917 + "dev": true,
6918 + "license": "MIT",
6919 + "dependencies": {
6920 + "call-bound": "^1.0.2",
6921 + "es-errors": "^1.3.0",
6922 + "get-intrinsic": "^1.2.5",
6923 + "object-inspect": "^1.13.3",
6924 + "side-channel-map": "^1.0.1"
6925 + },
6926 + "engines": {
6927 + "node": ">= 0.4"
6928 + },
6929 + "funding": {
6930 + "url": "https://github.com/sponsors/ljharb"
6931 + }
6932 + },
6933 + "node_modules/siginfo": {
6934 + "version": "2.0.0",
6935 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
6936 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
6937 + "dev": true,
6938 + "license": "ISC"
6939 + },
6940 + "node_modules/source-map-js": {
6941 + "version": "1.2.1",
6942 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
6943 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
6944 + "license": "BSD-3-Clause",
6945 + "engines": {
6946 + "node": ">=0.10.0"
6947 + }
6948 + },
6949 + "node_modules/stable-hash": {
6950 + "version": "0.0.5",
6951 + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
6952 + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
6953 + "dev": true,
6954 + "license": "MIT"
6955 + },
6956 + "node_modules/stackback": {
6957 + "version": "0.0.2",
6958 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
6959 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
6960 + "dev": true,
6961 + "license": "MIT"
6962 + },
6963 + "node_modules/std-env": {
6964 + "version": "4.2.0",
6965 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
6966 + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
6967 + "dev": true,
6968 + "license": "MIT"
6969 + },
6970 + "node_modules/stop-iteration-iterator": {
6971 + "version": "1.1.0",
6972 + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
6973 + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
6974 + "dev": true,
6975 + "license": "MIT",
6976 + "dependencies": {
6977 + "es-errors": "^1.3.0",
6978 + "internal-slot": "^1.1.0"
6979 + },
6980 + "engines": {
6981 + "node": ">= 0.4"
6982 + }
6983 + },
6984 + "node_modules/string.prototype.includes": {
6985 + "version": "2.0.1",
6986 + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
6987 + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
6988 + "dev": true,
6989 + "license": "MIT",
6990 + "dependencies": {
6991 + "call-bind": "^1.0.7",
6992 + "define-properties": "^1.2.1",
6993 + "es-abstract": "^1.23.3"
6994 + },
6995 + "engines": {
6996 + "node": ">= 0.4"
6997 + }
6998 + },
6999 + "node_modules/string.prototype.matchall": {
7000 + "version": "4.0.12",
7001 + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
7002 + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
7003 + "dev": true,
7004 + "license": "MIT",
7005 + "dependencies": {
7006 + "call-bind": "^1.0.8",
7007 + "call-bound": "^1.0.3",
7008 + "define-properties": "^1.2.1",
7009 + "es-abstract": "^1.23.6",
7010 + "es-errors": "^1.3.0",
7011 + "es-object-atoms": "^1.0.0",
7012 + "get-intrinsic": "^1.2.6",
7013 + "gopd": "^1.2.0",
7014 + "has-symbols": "^1.1.0",
7015 + "internal-slot": "^1.1.0",
7016 + "regexp.prototype.flags": "^1.5.3",
7017 + "set-function-name": "^2.0.2",
7018 + "side-channel": "^1.1.0"
7019 + },
7020 + "engines": {
7021 + "node": ">= 0.4"
7022 + },
7023 + "funding": {
7024 + "url": "https://github.com/sponsors/ljharb"
7025 + }
7026 + },
7027 + "node_modules/string.prototype.repeat": {
7028 + "version": "1.0.0",
7029 + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
7030 + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
7031 + "dev": true,
7032 + "license": "MIT",
7033 + "dependencies": {
7034 + "define-properties": "^1.1.3",
7035 + "es-abstract": "^1.17.5"
7036 + }
7037 + },
7038 + "node_modules/string.prototype.trim": {
7039 + "version": "1.2.11",
7040 + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz",
7041 + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==",
7042 + "dev": true,
7043 + "license": "MIT",
7044 + "dependencies": {
7045 + "call-bind": "^1.0.9",
7046 + "call-bound": "^1.0.4",
7047 + "define-data-property": "^1.1.4",
7048 + "define-properties": "^1.2.1",
7049 + "es-abstract": "^1.24.2",
7050 + "es-object-atoms": "^1.1.2",
7051 + "has-property-descriptors": "^1.0.2",
7052 + "safe-regex-test": "^1.1.0"
7053 + },
7054 + "engines": {
7055 + "node": ">= 0.4"
7056 + },
7057 + "funding": {
7058 + "url": "https://github.com/sponsors/ljharb"
7059 + }
7060 + },
7061 + "node_modules/string.prototype.trimend": {
7062 + "version": "1.0.10",
7063 + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz",
7064 + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==",
7065 + "dev": true,
7066 + "license": "MIT",
7067 + "dependencies": {
7068 + "call-bind": "^1.0.9",
7069 + "call-bound": "^1.0.4",
7070 + "define-properties": "^1.2.1",
7071 + "es-object-atoms": "^1.1.2"
7072 + },
7073 + "engines": {
7074 + "node": ">= 0.4"
7075 + },
7076 + "funding": {
7077 + "url": "https://github.com/sponsors/ljharb"
7078 + }
7079 + },
7080 + "node_modules/string.prototype.trimstart": {
7081 + "version": "1.0.8",
7082 + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
7083 + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
7084 + "dev": true,
7085 + "license": "MIT",
7086 + "dependencies": {
7087 + "call-bind": "^1.0.7",
7088 + "define-properties": "^1.2.1",
7089 + "es-object-atoms": "^1.0.0"
7090 + },
7091 + "engines": {
7092 + "node": ">= 0.4"
7093 + },
7094 + "funding": {
7095 + "url": "https://github.com/sponsors/ljharb"
7096 + }
7097 + },
7098 + "node_modules/strip-bom": {
7099 + "version": "3.0.0",
7100 + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
7101 + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
7102 + "dev": true,
7103 + "license": "MIT",
7104 + "engines": {
7105 + "node": ">=4"
7106 + }
7107 + },
7108 + "node_modules/strip-json-comments": {
7109 + "version": "3.1.1",
7110 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
7111 + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
7112 + "dev": true,
7113 + "license": "MIT",
7114 + "engines": {
7115 + "node": ">=8"
7116 + },
7117 + "funding": {
7118 + "url": "https://github.com/sponsors/sindresorhus"
7119 + }
7120 + },
7121 + "node_modules/styled-jsx": {
7122 + "version": "5.1.6",
7123 + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
7124 + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
7125 + "license": "MIT",
7126 + "dependencies": {
7127 + "client-only": "0.0.1"
7128 + },
7129 + "engines": {
7130 + "node": ">= 12.0.0"
7131 + },
7132 + "peerDependencies": {
7133 + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
7134 + },
7135 + "peerDependenciesMeta": {
7136 + "@babel/core": {
7137 + "optional": true
7138 + },
7139 + "babel-plugin-macros": {
7140 + "optional": true
7141 + }
7142 + }
7143 + },
7144 + "node_modules/supports-color": {
7145 + "version": "7.2.0",
7146 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
7147 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
7148 + "dev": true,
7149 + "license": "MIT",
7150 + "dependencies": {
7151 + "has-flag": "^4.0.0"
7152 + },
7153 + "engines": {
7154 + "node": ">=8"
7155 + }
7156 + },
7157 + "node_modules/supports-preserve-symlinks-flag": {
7158 + "version": "1.0.0",
7159 + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
7160 + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
7161 + "dev": true,
7162 + "license": "MIT",
7163 + "engines": {
7164 + "node": ">= 0.4"
7165 + },
7166 + "funding": {
7167 + "url": "https://github.com/sponsors/ljharb"
7168 + }
7169 + },
7170 + "node_modules/tailwindcss": {
7171 + "version": "4.3.3",
7172 + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
7173 + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
7174 + "dev": true,
7175 + "license": "MIT"
7176 + },
7177 + "node_modules/tapable": {
7178 + "version": "2.3.3",
7179 + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
7180 + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
7181 + "dev": true,
7182 + "license": "MIT",
7183 + "engines": {
7184 + "node": ">=6"
7185 + },
7186 + "funding": {
7187 + "type": "opencollective",
7188 + "url": "https://opencollective.com/webpack"
7189 + }
7190 + },
7191 + "node_modules/tiny-inflate": {
7192 + "version": "1.0.3",
7193 + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
7194 + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
7195 + "license": "MIT"
7196 + },
7197 + "node_modules/tinybench": {
7198 + "version": "2.9.0",
7199 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
7200 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
7201 + "dev": true,
7202 + "license": "MIT"
7203 + },
7204 + "node_modules/tinyexec": {
7205 + "version": "1.3.0",
7206 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
7207 + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
7208 + "dev": true,
7209 + "license": "MIT",
7210 + "engines": {
7211 + "node": ">=18"
7212 + }
7213 + },
7214 + "node_modules/tinyglobby": {
7215 + "version": "0.2.17",
7216 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
7217 + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
7218 + "dev": true,
7219 + "license": "MIT",
7220 + "dependencies": {
7221 + "fdir": "^6.5.0",
7222 + "picomatch": "^4.0.4"
7223 + },
7224 + "engines": {
7225 + "node": ">=12.0.0"
7226 + },
7227 + "funding": {
7228 + "url": "https://github.com/sponsors/SuperchupuDev"
7229 + }
7230 + },
7231 + "node_modules/tinyglobby/node_modules/fdir": {
7232 + "version": "6.5.0",
7233 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
7234 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
7235 + "dev": true,
7236 + "license": "MIT",
7237 + "engines": {
7238 + "node": ">=12.0.0"
7239 + },
7240 + "peerDependencies": {
7241 + "picomatch": "^3 || ^4"
7242 + },
7243 + "peerDependenciesMeta": {
7244 + "picomatch": {
7245 + "optional": true
7246 + }
7247 + }
7248 + },
7249 + "node_modules/tinyglobby/node_modules/picomatch": {
7250 + "version": "4.0.5",
7251 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
7252 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
7253 + "dev": true,
7254 + "license": "MIT",
7255 + "engines": {
7256 + "node": ">=12"
7257 + },
7258 + "funding": {
7259 + "url": "https://github.com/sponsors/jonschlinkert"
7260 + }
7261 + },
7262 + "node_modules/tinyrainbow": {
7263 + "version": "3.1.1",
7264 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
7265 + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
7266 + "dev": true,
7267 + "license": "MIT",
7268 + "engines": {
7269 + "node": ">=14.0.0"
7270 + }
7271 + },
7272 + "node_modules/to-regex-range": {
7273 + "version": "5.0.1",
7274 + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
7275 + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
7276 + "dev": true,
7277 + "license": "MIT",
7278 + "dependencies": {
7279 + "is-number": "^7.0.0"
7280 + },
7281 + "engines": {
7282 + "node": ">=8.0"
7283 + }
7284 + },
7285 + "node_modules/ts-api-utils": {
7286 + "version": "2.5.0",
7287 + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
7288 + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
7289 + "dev": true,
7290 + "license": "MIT",
7291 + "engines": {
7292 + "node": ">=18.12"
7293 + },
7294 + "peerDependencies": {
7295 + "typescript": ">=4.8.4"
7296 + }
7297 + },
7298 + "node_modules/tsconfig-paths": {
7299 + "version": "3.15.0",
7300 + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
7301 + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
7302 + "dev": true,
7303 + "license": "MIT",
7304 + "dependencies": {
7305 + "@types/json5": "^0.0.29",
7306 + "json5": "^1.0.2",
7307 + "minimist": "^1.2.6",
7308 + "strip-bom": "^3.0.0"
7309 + }
7310 + },
7311 + "node_modules/tsconfig-paths/node_modules/json5": {
7312 + "version": "1.0.2",
7313 + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
7314 + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
7315 + "dev": true,
7316 + "license": "MIT",
7317 + "dependencies": {
7318 + "minimist": "^1.2.0"
7319 + },
7320 + "bin": {
7321 + "json5": "lib/cli.js"
7322 + }
7323 + },
7324 + "node_modules/tslib": {
7325 + "version": "2.8.1",
7326 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
7327 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
7328 + "license": "0BSD"
7329 + },
7330 + "node_modules/type-check": {
7331 + "version": "0.4.0",
7332 + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
7333 + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
7334 + "dev": true,
7335 + "license": "MIT",
7336 + "dependencies": {
7337 + "prelude-ls": "^1.2.1"
7338 + },
7339 + "engines": {
7340 + "node": ">= 0.8.0"
7341 + }
7342 + },
7343 + "node_modules/typed-array-buffer": {
7344 + "version": "1.0.3",
7345 + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
7346 + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
7347 + "dev": true,
7348 + "license": "MIT",
7349 + "dependencies": {
7350 + "call-bound": "^1.0.3",
7351 + "es-errors": "^1.3.0",
7352 + "is-typed-array": "^1.1.14"
7353 + },
7354 + "engines": {
7355 + "node": ">= 0.4"
7356 + }
7357 + },
7358 + "node_modules/typed-array-byte-length": {
7359 + "version": "1.0.3",
7360 + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
7361 + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
7362 + "dev": true,
7363 + "license": "MIT",
7364 + "dependencies": {
7365 + "call-bind": "^1.0.8",
7366 + "for-each": "^0.3.3",
7367 + "gopd": "^1.2.0",
7368 + "has-proto": "^1.2.0",
7369 + "is-typed-array": "^1.1.14"
7370 + },
7371 + "engines": {
7372 + "node": ">= 0.4"
7373 + },
7374 + "funding": {
7375 + "url": "https://github.com/sponsors/ljharb"
7376 + }
7377 + },
7378 + "node_modules/typed-array-byte-offset": {
7379 + "version": "1.0.4",
7380 + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
7381 + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
7382 + "dev": true,
7383 + "license": "MIT",
7384 + "dependencies": {
7385 + "available-typed-arrays": "^1.0.7",
7386 + "call-bind": "^1.0.8",
7387 + "for-each": "^0.3.3",
7388 + "gopd": "^1.2.0",
7389 + "has-proto": "^1.2.0",
7390 + "is-typed-array": "^1.1.15",
7391 + "reflect.getprototypeof": "^1.0.9"
7392 + },
7393 + "engines": {
7394 + "node": ">= 0.4"
7395 + },
7396 + "funding": {
7397 + "url": "https://github.com/sponsors/ljharb"
7398 + }
7399 + },
7400 + "node_modules/typed-array-length": {
7401 + "version": "1.0.8",
7402 + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz",
7403 + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==",
7404 + "dev": true,
7405 + "license": "MIT",
7406 + "dependencies": {
7407 + "call-bind": "^1.0.9",
7408 + "for-each": "^0.3.5",
7409 + "gopd": "^1.2.0",
7410 + "is-typed-array": "^1.1.15",
7411 + "possible-typed-array-names": "^1.1.0",
7412 + "reflect.getprototypeof": "^1.0.10"
7413 + },
7414 + "engines": {
7415 + "node": ">= 0.4"
7416 + },
7417 + "funding": {
7418 + "url": "https://github.com/sponsors/ljharb"
7419 + }
7420 + },
7421 + "node_modules/typescript": {
7422 + "version": "5.9.3",
7423 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
7424 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
7425 + "dev": true,
7426 + "license": "Apache-2.0",
7427 + "bin": {
7428 + "tsc": "bin/tsc",
7429 + "tsserver": "bin/tsserver"
7430 + },
7431 + "engines": {
7432 + "node": ">=14.17"
7433 + }
7434 + },
7435 + "node_modules/typescript-eslint": {
7436 + "version": "8.66.0",
7437 + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
7438 + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
7439 + "dev": true,
7440 + "license": "MIT",
7441 + "dependencies": {
7442 + "@typescript-eslint/eslint-plugin": "8.66.0",
7443 + "@typescript-eslint/parser": "8.66.0",
7444 + "@typescript-eslint/typescript-estree": "8.66.0",
7445 + "@typescript-eslint/utils": "8.66.0"
7446 + },
7447 + "engines": {
7448 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
7449 + },
7450 + "funding": {
7451 + "type": "opencollective",
7452 + "url": "https://opencollective.com/typescript-eslint"
7453 + },
7454 + "peerDependencies": {
7455 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
7456 + "typescript": ">=4.8.4 <6.1.0"
7457 + }
7458 + },
7459 + "node_modules/unbox-primitive": {
7460 + "version": "1.1.0",
7461 + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
7462 + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
7463 + "dev": true,
7464 + "license": "MIT",
7465 + "dependencies": {
7466 + "call-bound": "^1.0.3",
7467 + "has-bigints": "^1.0.2",
7468 + "has-symbols": "^1.1.0",
7469 + "which-boxed-primitive": "^1.1.1"
7470 + },
7471 + "engines": {
7472 + "node": ">= 0.4"
7473 + },
7474 + "funding": {
7475 + "url": "https://github.com/sponsors/ljharb"
7476 + }
7477 + },
7478 + "node_modules/undici-types": {
7479 + "version": "6.21.0",
7480 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
7481 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
7482 + "dev": true,
7483 + "license": "MIT"
7484 + },
7485 + "node_modules/unicode-properties": {
7486 + "version": "1.4.1",
7487 + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
7488 + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
7489 + "license": "MIT",
7490 + "dependencies": {
7491 + "base64-js": "^1.3.0",
7492 + "unicode-trie": "^2.0.0"
7493 + }
7494 + },
7495 + "node_modules/unicode-trie": {
7496 + "version": "2.0.0",
7497 + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
7498 + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
7499 + "license": "MIT",
7500 + "dependencies": {
7501 + "pako": "^0.2.5",
7502 + "tiny-inflate": "^1.0.0"
7503 + }
7504 + },
7505 + "node_modules/unicode-trie/node_modules/pako": {
7506 + "version": "0.2.9",
7507 + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
7508 + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
7509 + "license": "MIT"
7510 + },
7511 + "node_modules/unrs-resolver": {
7512 + "version": "1.12.2",
7513 + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
7514 + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
7515 + "dev": true,
7516 + "hasInstallScript": true,
7517 + "license": "MIT",
7518 + "dependencies": {
7519 + "napi-postinstall": "^0.3.4"
7520 + },
7521 + "funding": {
7522 + "url": "https://opencollective.com/unrs-resolver"
7523 + },
7524 + "optionalDependencies": {
7525 + "@unrs/resolver-binding-android-arm-eabi": "1.12.2",
7526 + "@unrs/resolver-binding-android-arm64": "1.12.2",
7527 + "@unrs/resolver-binding-darwin-arm64": "1.12.2",
7528 + "@unrs/resolver-binding-darwin-x64": "1.12.2",
7529 + "@unrs/resolver-binding-freebsd-x64": "1.12.2",
7530 + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
7531 + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
7532 + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
7533 + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
7534 + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
7535 + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
7536 + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
7537 + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
7538 + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
7539 + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
7540 + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
7541 + "@unrs/resolver-binding-linux-x64-musl": "1.12.2",
7542 + "@unrs/resolver-binding-openharmony-arm64": "1.12.2",
7543 + "@unrs/resolver-binding-wasm32-wasi": "1.12.2",
7544 + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
7545 + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
7546 + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
7547 + }
7548 + },
7549 + "node_modules/update-browserslist-db": {
7550 + "version": "1.3.0",
7551 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz",
7552 + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==",
7553 + "dev": true,
7554 + "funding": [
7555 + {
7556 + "type": "opencollective",
7557 + "url": "https://opencollective.com/browserslist"
7558 + },
7559 + {
7560 + "type": "tidelift",
7561 + "url": "https://tidelift.com/funding/github/npm/browserslist"
7562 + },
7563 + {
7564 + "type": "github",
7565 + "url": "https://github.com/sponsors/ai"
7566 + }
7567 + ],
7568 + "license": "MIT",
7569 + "dependencies": {
7570 + "escalade": "^3.2.0",
7571 + "picocolors": "^1.1.1"
7572 + },
7573 + "bin": {
7574 + "update-browserslist-db": "cli.js"
7575 + },
7576 + "peerDependencies": {
7577 + "browserslist": ">= 4.21.0"
7578 + }
7579 + },
7580 + "node_modules/uri-js": {
7581 + "version": "4.4.1",
7582 + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
7583 + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
7584 + "dev": true,
7585 + "license": "BSD-2-Clause",
7586 + "dependencies": {
7587 + "punycode": "^2.1.0"
7588 + }
7589 + },
7590 + "node_modules/vite": {
7591 + "version": "8.2.1",
7592 + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
7593 + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
7594 + "dev": true,
7595 + "license": "MIT",
7596 + "dependencies": {
7597 + "lightningcss": "^1.33.0",
7598 + "picomatch": "^4.0.5",
7599 + "postcss": "^8.5.25",
7600 + "rolldown": "~1.2.1",
7601 + "tinyglobby": "^0.2.17"
7602 + },
7603 + "bin": {
7604 + "vite": "bin/vite.js"
7605 + },
7606 + "engines": {
7607 + "node": "^20.19.0 || >=22.12.0"
7608 + },
7609 + "funding": {
7610 + "url": "https://github.com/vitejs/vite?sponsor=1"
7611 + },
7612 + "optionalDependencies": {
7613 + "fsevents": "~2.3.3"
7614 + },
7615 + "peerDependencies": {
7616 + "@types/node": "^20.19.0 || >=22.12.0",
7617 + "@vitejs/devtools": "^0.4.0",
7618 + "esbuild": "^0.27.0 || ^0.28.0",
7619 + "jiti": ">=1.21.0",
7620 + "less": "^4.0.0",
7621 + "sass": "^1.70.0",
7622 + "sass-embedded": "^1.70.0",
7623 + "stylus": ">=0.54.8",
7624 + "sugarss": "^5.0.0",
7625 + "terser": "^5.16.0",
7626 + "tsx": "^4.8.1",
7627 + "yaml": "^2.4.2"
7628 + },
7629 + "peerDependenciesMeta": {
7630 + "@types/node": {
7631 + "optional": true
7632 + },
7633 + "@vitejs/devtools": {
7634 + "optional": true
7635 + },
7636 + "esbuild": {
7637 + "optional": true
7638 + },
7639 + "jiti": {
7640 + "optional": true
7641 + },
7642 + "less": {
7643 + "optional": true
7644 + },
7645 + "sass": {
7646 + "optional": true
7647 + },
7648 + "sass-embedded": {
7649 + "optional": true
7650 + },
7651 + "stylus": {
7652 + "optional": true
7653 + },
7654 + "sugarss": {
7655 + "optional": true
7656 + },
7657 + "terser": {
7658 + "optional": true
7659 + },
7660 + "tsx": {
7661 + "optional": true
7662 + },
7663 + "yaml": {
7664 + "optional": true
7665 + }
7666 + }
7667 + },
7668 + "node_modules/vite/node_modules/lightningcss": {
7669 + "version": "1.33.0",
7670 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
7671 + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
7672 + "dev": true,
7673 + "license": "MPL-2.0",
7674 + "dependencies": {
7675 + "detect-libc": "^2.0.3"
7676 + },
7677 + "engines": {
7678 + "node": ">= 12.0.0"
7679 + },
7680 + "funding": {
7681 + "type": "opencollective",
7682 + "url": "https://opencollective.com/parcel"
7683 + },
7684 + "optionalDependencies": {
7685 + "lightningcss-android-arm64": "1.33.0",
7686 + "lightningcss-darwin-arm64": "1.33.0",
7687 + "lightningcss-darwin-x64": "1.33.0",
7688 + "lightningcss-freebsd-x64": "1.33.0",
7689 + "lightningcss-linux-arm-gnueabihf": "1.33.0",
7690 + "lightningcss-linux-arm64-gnu": "1.33.0",
7691 + "lightningcss-linux-arm64-musl": "1.33.0",
7692 + "lightningcss-linux-x64-gnu": "1.33.0",
7693 + "lightningcss-linux-x64-musl": "1.33.0",
7694 + "lightningcss-win32-arm64-msvc": "1.33.0",
7695 + "lightningcss-win32-x64-msvc": "1.33.0"
7696 + }
7697 + },
7698 + "node_modules/vite/node_modules/lightningcss-android-arm64": {
7699 + "version": "1.33.0",
7700 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
7701 + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
7702 + "cpu": [
7703 + "arm64"
7704 + ],
7705 + "dev": true,
7706 + "license": "MPL-2.0",
7707 + "optional": true,
7708 + "os": [
7709 + "android"
7710 + ],
7711 + "engines": {
7712 + "node": ">= 12.0.0"
7713 + },
7714 + "funding": {
7715 + "type": "opencollective",
7716 + "url": "https://opencollective.com/parcel"
7717 + }
7718 + },
7719 + "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
7720 + "version": "1.33.0",
7721 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
7722 + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
7723 + "cpu": [
7724 + "arm64"
7725 + ],
7726 + "dev": true,
7727 + "license": "MPL-2.0",
7728 + "optional": true,
7729 + "os": [
7730 + "darwin"
7731 + ],
7732 + "engines": {
7733 + "node": ">= 12.0.0"
7734 + },
7735 + "funding": {
7736 + "type": "opencollective",
7737 + "url": "https://opencollective.com/parcel"
7738 + }
7739 + },
7740 + "node_modules/vite/node_modules/lightningcss-darwin-x64": {
7741 + "version": "1.33.0",
7742 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
7743 + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
7744 + "cpu": [
7745 + "x64"
7746 + ],
7747 + "dev": true,
7748 + "license": "MPL-2.0",
7749 + "optional": true,
7750 + "os": [
7751 + "darwin"
7752 + ],
7753 + "engines": {
7754 + "node": ">= 12.0.0"
7755 + },
7756 + "funding": {
7757 + "type": "opencollective",
7758 + "url": "https://opencollective.com/parcel"
7759 + }
7760 + },
7761 + "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
7762 + "version": "1.33.0",
7763 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
7764 + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
7765 + "cpu": [
7766 + "x64"
7767 + ],
7768 + "dev": true,
7769 + "license": "MPL-2.0",
7770 + "optional": true,
7771 + "os": [
7772 + "freebsd"
7773 + ],
7774 + "engines": {
7775 + "node": ">= 12.0.0"
7776 + },
7777 + "funding": {
7778 + "type": "opencollective",
7779 + "url": "https://opencollective.com/parcel"
7780 + }
7781 + },
7782 + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
7783 + "version": "1.33.0",
7784 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
7785 + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
7786 + "cpu": [
7787 + "arm"
7788 + ],
7789 + "dev": true,
7790 + "license": "MPL-2.0",
7791 + "optional": true,
7792 + "os": [
7793 + "linux"
7794 + ],
7795 + "engines": {
7796 + "node": ">= 12.0.0"
7797 + },
7798 + "funding": {
7799 + "type": "opencollective",
7800 + "url": "https://opencollective.com/parcel"
7801 + }
7802 + },
7803 + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
7804 + "version": "1.33.0",
7805 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
7806 + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
7807 + "cpu": [
7808 + "arm64"
7809 + ],
7810 + "dev": true,
7811 + "libc": [
7812 + "glibc"
7813 + ],
7814 + "license": "MPL-2.0",
7815 + "optional": true,
7816 + "os": [
7817 + "linux"
7818 + ],
7819 + "engines": {
7820 + "node": ">= 12.0.0"
7821 + },
7822 + "funding": {
7823 + "type": "opencollective",
7824 + "url": "https://opencollective.com/parcel"
7825 + }
7826 + },
7827 + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
7828 + "version": "1.33.0",
7829 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
7830 + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
7831 + "cpu": [
7832 + "arm64"
7833 + ],
7834 + "dev": true,
7835 + "libc": [
7836 + "musl"
7837 + ],
7838 + "license": "MPL-2.0",
7839 + "optional": true,
7840 + "os": [
7841 + "linux"
7842 + ],
7843 + "engines": {
7844 + "node": ">= 12.0.0"
7845 + },
7846 + "funding": {
7847 + "type": "opencollective",
7848 + "url": "https://opencollective.com/parcel"
7849 + }
7850 + },
7851 + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
7852 + "version": "1.33.0",
7853 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
7854 + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
7855 + "cpu": [
7856 + "x64"
7857 + ],
7858 + "dev": true,
7859 + "libc": [
7860 + "glibc"
7861 + ],
7862 + "license": "MPL-2.0",
7863 + "optional": true,
7864 + "os": [
7865 + "linux"
7866 + ],
7867 + "engines": {
7868 + "node": ">= 12.0.0"
7869 + },
7870 + "funding": {
7871 + "type": "opencollective",
7872 + "url": "https://opencollective.com/parcel"
7873 + }
7874 + },
7875 + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
7876 + "version": "1.33.0",
7877 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
7878 + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
7879 + "cpu": [
7880 + "x64"
7881 + ],
7882 + "dev": true,
7883 + "libc": [
7884 + "musl"
7885 + ],
7886 + "license": "MPL-2.0",
7887 + "optional": true,
7888 + "os": [
7889 + "linux"
7890 + ],
7891 + "engines": {
7892 + "node": ">= 12.0.0"
7893 + },
7894 + "funding": {
7895 + "type": "opencollective",
7896 + "url": "https://opencollective.com/parcel"
7897 + }
7898 + },
7899 + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
7900 + "version": "1.33.0",
7901 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
7902 + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
7903 + "cpu": [
7904 + "arm64"
7905 + ],
7906 + "dev": true,
7907 + "license": "MPL-2.0",
7908 + "optional": true,
7909 + "os": [
7910 + "win32"
7911 + ],
7912 + "engines": {
7913 + "node": ">= 12.0.0"
7914 + },
7915 + "funding": {
7916 + "type": "opencollective",
7917 + "url": "https://opencollective.com/parcel"
7918 + }
7919 + },
7920 + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
7921 + "version": "1.33.0",
7922 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
7923 + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
7924 + "cpu": [
7925 + "x64"
7926 + ],
7927 + "dev": true,
7928 + "license": "MPL-2.0",
7929 + "optional": true,
7930 + "os": [
7931 + "win32"
7932 + ],
7933 + "engines": {
7934 + "node": ">= 12.0.0"
7935 + },
7936 + "funding": {
7937 + "type": "opencollective",
7938 + "url": "https://opencollective.com/parcel"
7939 + }
7940 + },
7941 + "node_modules/vite/node_modules/picomatch": {
7942 + "version": "4.0.5",
7943 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
7944 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
7945 + "dev": true,
7946 + "license": "MIT",
7947 + "engines": {
7948 + "node": ">=12"
7949 + },
7950 + "funding": {
7951 + "url": "https://github.com/sponsors/jonschlinkert"
7952 + }
7953 + },
7954 + "node_modules/vitest": {
7955 + "version": "4.1.10",
7956 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
7957 + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
7958 + "dev": true,
7959 + "license": "MIT",
7960 + "dependencies": {
7961 + "@vitest/expect": "4.1.10",
7962 + "@vitest/mocker": "4.1.10",
7963 + "@vitest/pretty-format": "4.1.10",
7964 + "@vitest/runner": "4.1.10",
7965 + "@vitest/snapshot": "4.1.10",
7966 + "@vitest/spy": "4.1.10",
7967 + "@vitest/utils": "4.1.10",
7968 + "es-module-lexer": "^2.0.0",
7969 + "expect-type": "^1.3.0",
7970 + "magic-string": "^0.30.21",
7971 + "obug": "^2.1.1",
7972 + "pathe": "^2.0.3",
7973 + "picomatch": "^4.0.3",
7974 + "std-env": "^4.0.0-rc.1",
7975 + "tinybench": "^2.9.0",
7976 + "tinyexec": "^1.0.2",
7977 + "tinyglobby": "^0.2.15",
7978 + "tinyrainbow": "^3.1.0",
7979 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
7980 + "why-is-node-running": "^2.3.0"
7981 + },
7982 + "bin": {
7983 + "vitest": "vitest.mjs"
7984 + },
7985 + "engines": {
7986 + "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
7987 + },
7988 + "funding": {
7989 + "url": "https://opencollective.com/vitest"
7990 + },
7991 + "peerDependencies": {
7992 + "@edge-runtime/vm": "*",
7993 + "@opentelemetry/api": "^1.9.0",
7994 + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
7995 + "@vitest/browser-playwright": "4.1.10",
7996 + "@vitest/browser-preview": "4.1.10",
7997 + "@vitest/browser-webdriverio": "4.1.10",
7998 + "@vitest/coverage-istanbul": "4.1.10",
7999 + "@vitest/coverage-v8": "4.1.10",
8000 + "@vitest/ui": "4.1.10",
8001 + "happy-dom": "*",
8002 + "jsdom": "*",
8003 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
8004 + },
8005 + "peerDependenciesMeta": {
8006 + "@edge-runtime/vm": {
8007 + "optional": true
8008 + },
8009 + "@opentelemetry/api": {
8010 + "optional": true
8011 + },
8012 + "@types/node": {
8013 + "optional": true
8014 + },
8015 + "@vitest/browser-playwright": {
8016 + "optional": true
8017 + },
8018 + "@vitest/browser-preview": {
8019 + "optional": true
8020 + },
8021 + "@vitest/browser-webdriverio": {
8022 + "optional": true
8023 + },
8024 + "@vitest/coverage-istanbul": {
8025 + "optional": true
8026 + },
8027 + "@vitest/coverage-v8": {
8028 + "optional": true
8029 + },
8030 + "@vitest/ui": {
8031 + "optional": true
8032 + },
8033 + "happy-dom": {
8034 + "optional": true
8035 + },
8036 + "jsdom": {
8037 + "optional": true
8038 + },
8039 + "vite": {
8040 + "optional": false
8041 + }
8042 + }
8043 + },
8044 + "node_modules/vitest/node_modules/picomatch": {
8045 + "version": "4.0.5",
8046 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
8047 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
8048 + "dev": true,
8049 + "license": "MIT",
8050 + "engines": {
8051 + "node": ">=12"
8052 + },
8053 + "funding": {
8054 + "url": "https://github.com/sponsors/jonschlinkert"
8055 + }
8056 + },
8057 + "node_modules/which": {
8058 + "version": "2.0.2",
8059 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
8060 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
8061 + "dev": true,
8062 + "license": "ISC",
8063 + "dependencies": {
8064 + "isexe": "^2.0.0"
8065 + },
8066 + "bin": {
8067 + "node-which": "bin/node-which"
8068 + },
8069 + "engines": {
8070 + "node": ">= 8"
8071 + }
8072 + },
8073 + "node_modules/which-boxed-primitive": {
8074 + "version": "1.1.1",
8075 + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
8076 + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
8077 + "dev": true,
8078 + "license": "MIT",
8079 + "dependencies": {
8080 + "is-bigint": "^1.1.0",
8081 + "is-boolean-object": "^1.2.1",
8082 + "is-number-object": "^1.1.1",
8083 + "is-string": "^1.1.1",
8084 + "is-symbol": "^1.1.1"
8085 + },
8086 + "engines": {
8087 + "node": ">= 0.4"
8088 + },
8089 + "funding": {
8090 + "url": "https://github.com/sponsors/ljharb"
8091 + }
8092 + },
8093 + "node_modules/which-builtin-type": {
8094 + "version": "1.2.1",
8095 + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
8096 + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
8097 + "dev": true,
8098 + "license": "MIT",
8099 + "dependencies": {
8100 + "call-bound": "^1.0.2",
8101 + "function.prototype.name": "^1.1.6",
8102 + "has-tostringtag": "^1.0.2",
8103 + "is-async-function": "^2.0.0",
8104 + "is-date-object": "^1.1.0",
8105 + "is-finalizationregistry": "^1.1.0",
8106 + "is-generator-function": "^1.0.10",
8107 + "is-regex": "^1.2.1",
8108 + "is-weakref": "^1.0.2",
8109 + "isarray": "^2.0.5",
8110 + "which-boxed-primitive": "^1.1.0",
8111 + "which-collection": "^1.0.2",
8112 + "which-typed-array": "^1.1.16"
8113 + },
8114 + "engines": {
8115 + "node": ">= 0.4"
8116 + },
8117 + "funding": {
8118 + "url": "https://github.com/sponsors/ljharb"
8119 + }
8120 + },
8121 + "node_modules/which-collection": {
8122 + "version": "1.0.2",
8123 + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
8124 + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
8125 + "dev": true,
8126 + "license": "MIT",
8127 + "dependencies": {
8128 + "is-map": "^2.0.3",
8129 + "is-set": "^2.0.3",
8130 + "is-weakmap": "^2.0.2",
8131 + "is-weakset": "^2.0.3"
8132 + },
8133 + "engines": {
8134 + "node": ">= 0.4"
8135 + },
8136 + "funding": {
8137 + "url": "https://github.com/sponsors/ljharb"
8138 + }
8139 + },
8140 + "node_modules/which-typed-array": {
8141 + "version": "1.1.22",
8142 + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
8143 + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
8144 + "dev": true,
8145 + "license": "MIT",
8146 + "dependencies": {
8147 + "available-typed-arrays": "^1.0.7",
8148 + "call-bind": "^1.0.9",
8149 + "call-bound": "^1.0.4",
8150 + "for-each": "^0.3.5",
8151 + "get-proto": "^1.0.1",
8152 + "gopd": "^1.2.0",
8153 + "has-tostringtag": "^1.0.2"
8154 + },
8155 + "engines": {
8156 + "node": ">= 0.4"
8157 + },
8158 + "funding": {
8159 + "url": "https://github.com/sponsors/ljharb"
8160 + }
8161 + },
8162 + "node_modules/why-is-node-running": {
8163 + "version": "2.3.0",
8164 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
8165 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
8166 + "dev": true,
8167 + "license": "MIT",
8168 + "dependencies": {
8169 + "siginfo": "^2.0.0",
8170 + "stackback": "0.0.2"
8171 + },
8172 + "bin": {
8173 + "why-is-node-running": "cli.js"
8174 + },
8175 + "engines": {
8176 + "node": ">=8"
8177 + }
8178 + },
8179 + "node_modules/word-wrap": {
8180 + "version": "1.2.5",
8181 + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
8182 + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
8183 + "dev": true,
8184 + "license": "MIT",
8185 + "engines": {
8186 + "node": ">=0.10.0"
8187 + }
8188 + },
8189 + "node_modules/yallist": {
8190 + "version": "3.1.1",
8191 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
8192 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
8193 + "dev": true,
8194 + "license": "ISC"
8195 + },
8196 + "node_modules/yocto-queue": {
8197 + "version": "0.1.0",
8198 + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
8199 + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
8200 + "dev": true,
8201 + "license": "MIT",
8202 + "engines": {
8203 + "node": ">=10"
8204 + },
8205 + "funding": {
8206 + "url": "https://github.com/sponsors/sindresorhus"
8207 + }
8208 + },
8209 + "node_modules/zod": {
8210 + "version": "4.4.3",
8211 + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
8212 + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
8213 + "dev": true,
8214 + "license": "MIT",
8215 + "funding": {
8216 + "url": "https://github.com/sponsors/colinhacks"
8217 + }
8218 + },
8219 + "node_modules/zod-validation-error": {
8220 + "version": "4.0.2",
8221 + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
8222 + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
8223 + "dev": true,
8224 + "license": "MIT",
8225 + "engines": {
8226 + "node": ">=18.0.0"
8227 + },
8228 + "peerDependencies": {
8229 + "zod": "^3.25.0 || ^4.0.0"
8230 + }
8231 + }
8232 + }
8233 +}
added app/package.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "name": "valoplex",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev",
7 + "build": "next build",
8 + "start": "next start",
9 + "lint": "eslint",
10 + "test": "vitest run"
11 + },
12 + "dependencies": {
13 + "better-sqlite3": "^13.0.3",
14 + "next": "16.3.0",
15 + "pdfkit": "^0.19.1",
16 + "react": "19.2.8",
17 + "react-dom": "19.2.8"
18 + },
19 + "devDependencies": {
20 + "@tailwindcss/postcss": "^4",
21 + "@types/better-sqlite3": "^9.6.0",
22 + "@types/node": "^20",
23 + "@types/pdfkit": "^0.17.6",
24 + "@types/react": "^19",
25 + "@types/react-dom": "^19",
26 + "eslint": "^9",
27 + "eslint-config-next": "16.3.0",
28 + "playwright": "^1.62.1",
29 + "tailwindcss": "^4",
30 + "typescript": "^5",
31 + "vitest": "^4.1.10"
32 + }
33 +}
added app/postcss.config.mjs +7 −0
@@ -0,0 +1,7 @@
1 +const config = {
2 + plugins: {
3 + "@tailwindcss/postcss": {},
4 + },
5 +};
6 +
7 +export default config;
added app/public/file.svg +1 −0
@@ -0,0 +1 @@
1 +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
added app/public/globe.svg +1 −0
@@ -0,0 +1 @@
1 +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
added app/public/next.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
added app/public/vercel.svg +1 −0
@@ -0,0 +1 @@
1 +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
added app/public/window.svg +1 −0
@@ -0,0 +1 @@
1 +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
added app/src/app/api/estimate/route.ts +42 −0
@@ -0,0 +1,42 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { estimateByUnitId, estimateManual } from "@/lib/estimator";
4 +
5 +export function GET(req: NextRequest) {
6 + const id = req.nextUrl.searchParams.get("id");
7 + if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
8 + const r = estimateByUnitId(id);
9 + if (!r) return NextResponse.json({ error: "unité introuvable" }, { status: 404 });
10 + return NextResponse.json(r);
11 +}
12 +
13 +export async function POST(req: NextRequest) {
14 + const body = (await req.json()) as {
15 + municipality?: string;
16 + typeProp?: string;
17 + portes?: number;
18 + floorArea?: number;
19 + yearBuilt?: number;
20 + landArea?: number;
21 + };
22 + if (!body.municipality || !body.typeProp) {
23 + return NextResponse.json(
24 + { error: "municipality et typeProp requis" },
25 + { status: 400 }
26 + );
27 + }
28 + const r = estimateManual({
29 + municipality: body.municipality,
30 + typeProp: "plex",
31 + portes: body.portes,
32 + floorArea: body.floorArea,
33 + yearBuilt: body.yearBuilt,
34 + landArea: body.landArea,
35 + });
36 + if (!r)
37 + return NextResponse.json(
38 + { error: "municipalité introuvable ou marché insuffisant" },
39 + { status: 404 }
40 + );
41 + return NextResponse.json(r);
42 +}
added app/src/app/api/lead/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { saveLead } from "@/lib/db";
4 +
5 +export async function POST(req: NextRequest) {
6 + const body = (await req.json()) as {
7 + email?: string;
8 + unitId?: string;
9 + estimate?: number;
10 + };
11 + const email = body.email?.trim() ?? "";
12 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
13 + return NextResponse.json({ error: "courriel invalide" }, { status: 400 });
14 + }
15 + saveLead(email, body.unitId ?? null, body.estimate ?? null);
16 + return NextResponse.json({ ok: true });
17 +}
added app/src/app/api/portfolio/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { estimatePortfolio } from "@/lib/estimator";
4 +
5 +export async function POST(req: NextRequest) {
6 + const body = (await req.json()) as { ids?: string[] };
7 + const ids = (body.ids ?? []).filter((x) => typeof x === "string").slice(0, 40);
8 + if (ids.length === 0)
9 + return NextResponse.json({ error: "ids requis" }, { status: 400 });
10 + const r = estimatePortfolio(ids);
11 + if (r.items.length === 0)
12 + return NextResponse.json({ error: "aucune propriété trouvée" }, { status: 404 });
13 + return NextResponse.json(r);
14 +}
added app/src/app/api/report/portfolio/route.ts +25 −0
@@ -0,0 +1,25 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { estimatePortfolio } from "@/lib/estimator";
4 +import { buildPortfolioReport } from "@/lib/report-portfolio";
5 +
6 +export async function GET(req: NextRequest) {
7 + const ids = (req.nextUrl.searchParams.get("ids") ?? "")
8 + .split(",")
9 + .map((s) => s.trim())
10 + .filter(Boolean)
11 + .slice(0, 40);
12 + if (ids.length === 0)
13 + return NextResponse.json({ error: "ids requis" }, { status: 400 });
14 + const pf = estimatePortfolio(ids);
15 + if (pf.items.length === 0)
16 + return NextResponse.json({ error: "aucune propriété trouvée" }, { status: 404 });
17 + const pdf = await buildPortfolioReport(pf);
18 + return new NextResponse(new Uint8Array(pdf), {
19 + headers: {
20 + "Content-Type": "application/pdf",
21 + "Content-Disposition": `attachment; filename="valoplex-parc-${pf.items.length}-proprietes.pdf"`,
22 + "Cache-Control": "private, max-age=300",
23 + },
24 + });
25 +}
added app/src/app/api/report/pro/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { estimateByUnitId, indexType } from "@/lib/estimator";
4 +import { getMarketIndex } from "@/lib/db";
5 +import { buildProReport } from "@/lib/report-pro";
6 +
7 +export async function GET(req: NextRequest) {
8 + const id = req.nextUrl.searchParams.get("id");
9 + if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
10 + const data = estimateByUnitId(id);
11 + if (!data || !data.unit)
12 + return NextResponse.json({ error: "unité introuvable" }, { status: 404 });
13 + const idx = getMarketIndex(indexType());
14 + const pdf = await buildProReport(data, idx);
15 + const slug = (data.unit.adresse ?? "propriete")
16 + .toLowerCase()
17 + .normalize("NFD")
18 + .replace(/[̀-ͯ]/g, "")
19 + .replace(/[^a-z0-9]+/g, "-")
20 + .replace(/^-+|-+$/g, "");
21 + return new NextResponse(new Uint8Array(pdf), {
22 + headers: {
23 + "Content-Type": "application/pdf",
24 + "Content-Disposition": `attachment; filename="valoplex-rapport-detaille-${slug}.pdf"`,
25 + "Cache-Control": "private, max-age=300",
26 + },
27 + });
28 +}
added app/src/app/api/report/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { estimateByUnitId } from "@/lib/estimator";
4 +import { buildReport } from "@/lib/report";
5 +
6 +export async function GET(req: NextRequest) {
7 + const id = req.nextUrl.searchParams.get("id");
8 + if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
9 + const data = estimateByUnitId(id);
10 + if (!data || !data.unit)
11 + return NextResponse.json({ error: "unité introuvable" }, { status: 404 });
12 + const pdf = await buildReport(data);
13 + const slug = (data.unit.adresse ?? "propriete")
14 + .toLowerCase()
15 + .normalize("NFD")
16 + .replace(/[̀-ͯ]/g, "")
17 + .replace(/[^a-z0-9]+/g, "-")
18 + .replace(/^-+|-+$/g, "");
19 + return new NextResponse(new Uint8Array(pdf), {
20 + headers: {
21 + "Content-Type": "application/pdf",
22 + "Content-Disposition": `attachment; filename="valoplex-${slug}.pdf"`,
23 + "Cache-Control": "private, max-age=300",
24 + },
25 + });
26 +}
added app/src/app/api/report/stats/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextResponse } from "next/server";
3 +import { buildStatsReport } from "@/lib/report-stats";
4 +import stats from "@/data/stats.json";
5 +import type { ProvStats } from "@/components/StatsView";
6 +
7 +export async function GET() {
8 + const pdf = await buildStatsReport(stats as ProvStats);
9 + return new NextResponse(new Uint8Array(pdf), {
10 + headers: {
11 + "Content-Type": "application/pdf",
12 + "Content-Disposition":
13 + 'attachment; filename="valoplex-rapport-statistique-provincial.pdf"',
14 + "Cache-Control": "public, max-age=3600",
15 + },
16 + });
17 +}
added app/src/app/api/search/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { searchUnits } from "@/lib/db";
4 +
5 +export function GET(req: NextRequest) {
6 + const q = req.nextUrl.searchParams.get("q") ?? "";
7 + if (q.trim().length < 3) return NextResponse.json({ results: [] });
8 + try {
9 + const results = searchUnits(q, 8).map((u) => ({
10 + id: u.id_provinc,
11 + adresse: u.adresse,
12 + apt: u.apt,
13 + municipalite: u.municipalite,
14 + typeProp: u.type_prop,
15 + }));
16 + return NextResponse.json({ results });
17 + } catch {
18 + return NextResponse.json({ results: [] });
19 + }
20 +}
added app/src/app/conditions/page.tsx +119 −0
@@ -0,0 +1,119 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import LegalPage from "@/components/LegalPage";
4 +
5 +const FR = [
6 + {
7 + title: "Objet et acceptation",
8 + paras: [
9 + "ValoPlex (www.valoplex.com) est une plateforme d'estimation statistique de la valeur de propriétés situées au Québec, exploitée par Simon-Pierre Boucher (contact@spboucher.ai). En accédant au site ou en utilisant ses fonctionnalités — recherche d'adresse, estimation, rapport PDF — vous acceptez les présentes conditions d'utilisation. Si vous ne les acceptez pas, veuillez ne pas utiliser le service.",
10 + ],
11 + },
12 + {
13 + title: "Nature du service — pas une évaluation professionnelle",
14 + paras: [
15 + "Les estimations produites par ValoPlex sont générées automatiquement par des modèles statistiques appliqués à des données publiques. Elles sont fournies à titre strictement informatif et indicatif.",
16 + "Une estimation ValoPlex ne constitue pas : une évaluation au sens de la Loi sur la fiscalité municipale ; un rapport d'évaluation préparé par un évaluateur agréé membre de l'Ordre des évaluateurs agréés du Québec (OEAQ) ; un avis de valeur marchande aux fins de financement hypothécaire, d'assurance, de litige, de partage ou de fiscalité ; ni un conseil financier, juridique ou immobilier. Pour toute décision importante, consultez un professionnel qualifié.",
17 + ],
18 + },
19 + {
20 + title: "Exactitude et fourchettes",
21 + paras: [
22 + "La méthodologie, ses métriques de précision (erreur médiane d'environ 11 % sur ventes retenues) et ses limites connues sont documentées publiquement sur la page Méthodologie. Chaque estimation est présentée avec une fourchette et un indice de confiance ; l'écart entre l'estimation et un éventuel prix de vente réel peut être substantiel, particulièrement dans les marchés minces ou pour des propriétés atypiques. Vous reconnaissez utiliser les estimations à vos propres risques.",
23 + ],
24 + },
25 + {
26 + title: "Sources de données",
27 + paras: [
28 + "Le service s'appuie sur les rôles d'évaluation foncière du Québec diffusés en données ouvertes par le ministère des Affaires municipales et de l'Habitation (MAMH) sous licence ouverte, ainsi que sur des données de transactions publiées. Les renseignements relatifs aux propriétaires sont caviardés à la source par le MAMH et n'apparaissent nulle part sur ValoPlex. Malgré un soin raisonnable, les données sources peuvent contenir des erreurs, des retards de mise à jour ou des omissions.",
29 + ],
30 + },
31 + {
32 + title: "Utilisation permise",
33 + paras: [
34 + "Le service est offert pour un usage personnel et raisonnable. Sont interdits sans autorisation écrite préalable : l'extraction massive ou systématique de données (scraping), la revente ou la redistribution commerciale des estimations, la surcharge délibérée de l'infrastructure, et toute utilisation contraire aux lois applicables. Nous nous réservons le droit de limiter ou de bloquer l'accès en cas d'abus.",
35 + ],
36 + },
37 + {
38 + title: "Propriété intellectuelle",
39 + paras: [
40 + "La marque ValoPlex, l'interface, les modèles, les rapports PDF et la présentation des résultats sont la propriété de Simon-Pierre Boucher. Les données publiques sous-jacentes demeurent régies par leurs licences respectives (dont la licence des données ouvertes du gouvernement du Québec).",
41 + ],
42 + },
43 + {
44 + title: "Limitation de responsabilité",
45 + paras: [
46 + "Dans la mesure maximale permise par la loi, le service est fourni « tel quel » et « selon la disponibilité », sans garantie d'aucune sorte, expresse ou implicite. Simon-Pierre Boucher ne saurait être tenu responsable de quelque dommage direct ou indirect — incluant perte financière liée à une décision d'achat, de vente, de financement ou d'investissement — découlant de l'utilisation ou de l'impossibilité d'utiliser le service ou de la confiance accordée à une estimation.",
47 + ],
48 + },
49 + {
50 + title: "Modifications et droit applicable",
51 + paras: [
52 + "Les présentes conditions peuvent être modifiées en tout temps ; la version en vigueur est celle publiée sur cette page avec sa date de mise à jour. Le service et les présentes conditions sont régis par les lois applicables au Québec et les lois du Canada. Tout litige relève des tribunaux compétents du district judiciaire de Québec.",
53 + ],
54 + },
55 +];
56 +
57 +const EN = [
58 + {
59 + title: "Purpose and acceptance",
60 + paras: [
61 + "ValoPlex (www.valoplex.com) is a statistical property-value estimation platform for Québec, operated by Simon-Pierre Boucher (contact@spboucher.ai). By accessing the site or using its features — address search, estimates, PDF reports — you accept these terms of service. If you do not accept them, please do not use the service.",
62 + ],
63 + },
64 + {
65 + title: "Nature of the service — not a professional appraisal",
66 + paras: [
67 + "ValoPlex estimates are generated automatically by statistical models applied to public data and are provided for information purposes only.",
68 + "A ValoPlex estimate is not: an assessment within the meaning of the Act respecting municipal taxation; an appraisal report prepared by a chartered appraiser (OEAQ); an opinion of market value for mortgage, insurance, litigation, division or tax purposes; nor financial, legal or real-estate advice. For any significant decision, consult a qualified professional.",
69 + ],
70 + },
71 + {
72 + title: "Accuracy and ranges",
73 + paras: [
74 + "The methodology, its accuracy metrics (median error of about 11% on held-out sales) and its known limitations are publicly documented on the Methodology page. Every estimate is presented with a range and a confidence index; the gap between an estimate and an actual sale price can be substantial, particularly in thin markets or for atypical properties. You acknowledge using estimates at your own risk.",
75 + ],
76 + },
77 + {
78 + title: "Data sources",
79 + paras: [
80 + "The service relies on Québec's property assessment rolls released as open data by the MAMH under an open licence, and on published transaction data. Owner information is redacted at the source by the MAMH and appears nowhere on ValoPlex. Despite reasonable care, source data may contain errors, update delays or omissions.",
81 + ],
82 + },
83 + {
84 + title: "Permitted use",
85 + paras: [
86 + "The service is offered for personal, reasonable use. Without prior written authorization, the following are prohibited: bulk or systematic data extraction (scraping), commercial resale or redistribution of estimates, deliberate infrastructure overload, and any use contrary to applicable law. We reserve the right to limit or block access in case of abuse.",
87 + ],
88 + },
89 + {
90 + title: "Intellectual property",
91 + paras: [
92 + "The ValoPlex brand, interface, models, PDF reports and presentation of results are the property of Simon-Pierre Boucher. The underlying public data remains governed by its respective licences (including the Québec government open-data licence).",
93 + ],
94 + },
95 + {
96 + title: "Limitation of liability",
97 + paras: [
98 + "To the maximum extent permitted by law, the service is provided “as is” and “as available”, without warranty of any kind. Simon-Pierre Boucher shall not be liable for any direct or indirect damage — including financial loss related to a purchase, sale, financing or investment decision — arising from the use of, or reliance on, the service or any estimate.",
99 + ],
100 + },
101 + {
102 + title: "Changes and governing law",
103 + paras: [
104 + "These terms may be amended at any time; the version in force is the one published on this page with its update date. The service and these terms are governed by the laws applicable in Québec and the laws of Canada. Any dispute falls under the competent courts of the judicial district of Québec.",
105 + ],
106 + },
107 +];
108 +
109 +export default function ConditionsPage() {
110 + return (
111 + <LegalPage
112 + crumb={{ fr: "Conditions d'utilisation", en: "Terms of service" }}
113 + kicker={{ fr: "Le contrat, sans jargon inutile", en: "The contract, plainly" }}
114 + title={{ fr: "Conditions d'utilisation", en: "Terms of service" }}
115 + updated="2026-08-08"
116 + sections={{ fr: FR, en: EN }}
117 + />
118 + );
119 +}
added app/src/app/confidentialite/page.tsx +129 −0
@@ -0,0 +1,129 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import LegalPage from "@/components/LegalPage";
4 +import { openCookiePrefs } from "@/components/CookieConsent";
5 +import { useLang } from "@/components/LangContext";
6 +
7 +const FR = [
8 + {
9 + title: "Qui est responsable de vos renseignements",
10 + paras: [
11 + "La présente politique décrit comment ValoPlex (www.valoplex.com) recueille, utilise et protège les renseignements personnels, conformément à la Loi sur la protection des renseignements personnels dans le secteur privé du Québec (telle que modifiée par la Loi 25). Le responsable de la protection des renseignements personnels est Simon-Pierre Boucher, joignable à contact@spboucher.ai.",
12 + ],
13 + },
14 + {
15 + title: "Ce que nous recueillons — et ce que nous ne recueillons pas",
16 + paras: [
17 + "Nous recueillons uniquement : (1) l'adresse courriel que vous fournissez volontairement pour recevoir un rapport, avec l'identifiant de la propriété et l'estimation associées ; (2) les requêtes techniques nécessaires au fonctionnement (journaux de serveur standards : adresse IP, horodatage, page demandée), conservées brièvement à des fins de sécurité et de diagnostic.",
18 + "Nous ne recueillons pas : de nom, de compte, de mot de passe, de données de paiement, ni de profil publicitaire. Aucune donnée n'est vendue, louée ou partagée à des fins commerciales. Aucun pisteur publicitaire tiers n'est présent sur le site. Les recherches d'adresses portent sur des données publiques du rôle foncier ; les renseignements des propriétaires sont caviardés à la source par le MAMH.",
19 + ],
20 + },
21 + {
22 + title: "Finalités et conservation",
23 + paras: [
24 + "Le courriel fourni sert exclusivement à vous transmettre le rapport demandé et, si vous y consentez explicitement, des suivis liés à ValoPlex. Il est conservé au maximum 24 mois après la dernière interaction, puis supprimé. Les journaux techniques sont conservés au plus 90 jours. Les données sont hébergées sur une infrastructure privée située au Canada (Québec).",
25 + ],
26 + },
27 + {
28 + title: "Témoins (cookies) et technologies similaires",
29 + paras: [
30 + "Le site utilise un minimum de stockage local, décrit ci-dessous. Les témoins de préférences ne sont déposés qu'avec votre consentement, que vous pouvez retirer en tout temps via le sélecteur de témoins (bouton « Préférences de témoins » au pied de page ou ci-dessous).",
31 + "vp-lang — préférence : retient votre langue d'affichage (FR/EN) ; localStorage ; 12 mois ; déposé seulement si vous acceptez les témoins de préférences. vp-cookie-consent — essentiel : mémorise vos choix de consentement ; localStorage ; 12 mois. Témoins de session essentiels — sécurité et équilibrage du service ; durée de la session.",
32 + "Aucun témoin de mesure d'audience ni témoin publicitaire n'est actuellement utilisé.",
33 + ],
34 + },
35 + {
36 + title: "Vos droits",
37 + paras: [
38 + "Vous pouvez en tout temps : accéder aux renseignements que nous détenons sur vous ; demander leur rectification ou leur suppression ; retirer votre consentement (témoins via le sélecteur ; courriel en répondant « désabonnement » ou en écrivant à contact@spboucher.ai). Nous répondons dans un délai maximal de 30 jours.",
39 + "Si vous estimez que vos droits n'ont pas été respectés, vous pouvez déposer une plainte auprès de la Commission d'accès à l'information du Québec (cai.gouv.qc.ca).",
40 + ],
41 + },
42 + {
43 + title: "Sécurité et incidents",
44 + paras: [
45 + "Les données transitent chiffrées (HTTPS/TLS) et sont stockées sur des systèmes à accès restreint. En cas d'incident de confidentialité présentant un risque de préjudice sérieux, les personnes concernées et la Commission d'accès à l'information seront avisées conformément à la loi, et l'incident sera consigné au registre des incidents.",
46 + ],
47 + },
48 + {
49 + title: "Modifications",
50 + paras: [
51 + "Toute modification substantielle de cette politique sera publiée sur cette page avec une nouvelle date de mise à jour. La poursuite de l'utilisation du service après publication vaut prise de connaissance.",
52 + ],
53 + },
54 +];
55 +
56 +const EN = [
57 + {
58 + title: "Who is responsible for your information",
59 + paras: [
60 + "This policy describes how ValoPlex (www.valoplex.com) collects, uses and protects personal information, in accordance with Québec's Act respecting the protection of personal information in the private sector (as amended by Law 25). The person in charge of the protection of personal information is Simon-Pierre Boucher, reachable at contact@spboucher.ai.",
61 + ],
62 + },
63 + {
64 + title: "What we collect — and what we do not",
65 + paras: [
66 + "We collect only: (1) the email address you voluntarily provide to receive a report, together with the related property ID and estimate; (2) the technical requests needed to operate the site (standard server logs: IP address, timestamp, requested page), kept briefly for security and diagnostics.",
67 + "We do not collect: names, accounts, passwords, payment data or advertising profiles. No data is sold, rented or shared for commercial purposes. No third-party ad trackers are present. Address searches query public assessment-roll data; owner information is redacted at the source by the MAMH.",
68 + ],
69 + },
70 + {
71 + title: "Purposes and retention",
72 + paras: [
73 + "The email you provide is used exclusively to send you the requested report and, with your explicit consent, ValoPlex follow-ups. It is kept at most 24 months after the last interaction, then deleted. Technical logs are kept at most 90 days. Data is hosted on private infrastructure located in Canada (Québec).",
74 + ],
75 + },
76 + {
77 + title: "Cookies and similar technologies",
78 + paras: [
79 + "The site uses a minimum of local storage, described below. Preference cookies are only set with your consent, which you can withdraw at any time via the cookie selector (footer button “Cookie preferences” or below).",
80 + "vp-lang — preference: remembers your display language (FR/EN); localStorage; 12 months; set only if you accept preference cookies. vp-cookie-consent — essential: stores your consent choices; localStorage; 12 months. Essential session cookies — service security and balancing; session duration.",
81 + "No analytics or advertising cookies are currently used.",
82 + ],
83 + },
84 + {
85 + title: "Your rights",
86 + paras: [
87 + "At any time you may: access the information we hold about you; request its rectification or deletion; withdraw your consent (cookies via the selector; email by replying “unsubscribe” or writing to contact@spboucher.ai). We respond within 30 days.",
88 + "If you believe your rights have not been respected, you may file a complaint with the Commission d'accès à l'information du Québec (cai.gouv.qc.ca).",
89 + ],
90 + },
91 + {
92 + title: "Security and incidents",
93 + paras: [
94 + "Data travels encrypted (HTTPS/TLS) and is stored on access-restricted systems. In the event of a confidentiality incident presenting a risk of serious harm, affected persons and the Commission d'accès à l'information will be notified as required by law, and the incident recorded in the incident register.",
95 + ],
96 + },
97 + {
98 + title: "Changes",
99 + paras: [
100 + "Any substantial change to this policy will be published on this page with a new update date. Continued use of the service after publication constitutes acknowledgement.",
101 + ],
102 + },
103 +];
104 +
105 +export default function ConfidentialitePage() {
106 + const { lang } = useLang();
107 + const fr = lang === "fr";
108 + return (
109 + <LegalPage
110 + crumb={{ fr: "Politique de confidentialité", en: "Privacy policy" }}
111 + kicker={{ fr: "Loi 25 · Québec", en: "Law 25 · Québec" }}
112 + title={{ fr: "Politique de confidentialité", en: "Privacy policy" }}
113 + updated="2026-08-08"
114 + sections={{ fr: FR, en: EN }}
115 + >
116 + <div className="vp-card mt-6 p-6">
117 + <p className="klabel">{fr ? "Sélecteur de témoins" : "Cookie selector"}</p>
118 + <p className="mt-2 max-w-2xl text-[14px] text-ink-2">
119 + {fr
120 + ? "Consultez ou modifiez vos choix de témoins en tout temps — le panneau se rouvre avec vos réglages actuels."
121 + : "Review or change your cookie choices at any time — the panel reopens with your current settings."}
122 + </p>
123 + <button className="btn btn-primary mt-4" onClick={openCookiePrefs}>
124 + 🍪 {fr ? "Ouvrir les préférences de témoins" : "Open cookie preferences"}
125 + </button>
126 + </div>
127 + </LegalPage>
128 + );
129 +}
added app/src/app/estimation/[id]/page.tsx +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { notFound } from "next/navigation";
3 +import { estimateByUnitId } from "@/lib/estimator";
4 +import ResultView from "@/components/ResultView";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +export default async function EstimationPage({
9 + params,
10 +}: {
11 + params: Promise<{ id: string }>;
12 +}) {
13 + const { id } = await params;
14 + const data = estimateByUnitId(decodeURIComponent(id));
15 + if (!data) notFound();
16 + return <ResultView data={data} />;
17 +}
added app/src/app/estimation/manuelle/page.tsx +36 −0
@@ -0,0 +1,36 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { notFound } from "next/navigation";
3 +import { estimateManual } from "@/lib/estimator";
4 +import ResultView from "@/components/ResultView";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +export default async function ManualEstimationPage({
9 + searchParams,
10 +}: {
11 + searchParams: Promise<Record<string, string | string[] | undefined>>;
12 +}) {
13 + const sp = await searchParams;
14 + const one = (k: string) => {
15 + const v = sp[k];
16 + return Array.isArray(v) ? v[0] : v;
17 + };
18 + const municipality = one("municipality");
19 + const typeProp = one("typeProp");
20 + if (!municipality || !typeProp) notFound();
21 + const num = (k: string) => {
22 + const v = one(k);
23 + const n = v ? Number(v) : NaN;
24 + return Number.isFinite(n) ? n : undefined;
25 + };
26 + const data = estimateManual({
27 + municipality,
28 + typeProp,
29 + portes: num("portes"),
30 + floorArea: num("floorArea"),
31 + yearBuilt: num("yearBuilt"),
32 + landArea: num("landArea"),
33 + });
34 + if (!data) notFound();
35 + return <ResultView data={data} />;
36 +}
added app/src/app/favicon.ico +0 −0

Binary file not shown.

added app/src/app/globals.css +234 −0
@@ -0,0 +1,234 @@
1 +/* -----------------------------------------------------------------------------
2 + ValoPlex — estimation immobilière transparente (Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + Système de design « éditorial sharp » — identité ORANGE ValoPlex :
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent orange vif #ff9f45 + ocre brûlé #b25f16 sur encre
8 +----------------------------------------------------------------------------- */
9 +@import "tailwindcss";
10 +
11 +:root {
12 + --paper: #f5f3ee;
13 + --surface: #ffffff;
14 + --surface-2: #faf9f5;
15 + --ink: #141814;
16 + --ink-2: #4d5551;
17 + --ink-3: #8b928c;
18 + --line: rgba(20, 24, 20, 0.14);
19 + --line-strong: rgba(20, 24, 20, 0.85);
20 + --green: #b25f16;
21 + --green-deep: #8a4710;
22 + --lime: #ff9f45;
23 + --lime-soft: #ffedd9;
24 + --amber: #e8a33d;
25 + --amber-soft: #fdf3e2;
26 + --danger: #b3423a;
27 + --danger-soft: #fbe9e7;
28 + --r-card: 10px;
29 + --r-ctl: 6px;
30 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
31 + --shadow-off: 6px 6px 0 var(--ink);
32 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
33 +}
34 +
35 +@theme inline {
36 + --color-paper: var(--paper);
37 + --color-surface: var(--surface);
38 + --color-surface-2: var(--surface-2);
39 + --color-ink: var(--ink);
40 + --color-ink-2: var(--ink-2);
41 + --color-ink-3: var(--ink-3);
42 + --color-green: var(--green);
43 + --color-green-deep: var(--green-deep);
44 + --color-lime: var(--lime);
45 + --color-lime-soft: var(--lime-soft);
46 + --color-amber: var(--amber);
47 + --color-amber-soft: var(--amber-soft);
48 + --color-danger: var(--danger);
49 + --font-display: var(--font-space-grotesk), system-ui, sans-serif;
50 + --font-body: var(--font-inter), system-ui, sans-serif;
51 + --font-mono: var(--font-jetbrains), ui-monospace, monospace;
52 +}
53 +
54 +html { scroll-behavior: smooth; }
55 +body {
56 + background: var(--paper);
57 + color: var(--ink);
58 + font-family: var(--font-inter), system-ui, sans-serif;
59 + font-size: 15px;
60 + line-height: 1.55;
61 + -webkit-font-smoothing: antialiased;
62 + padding-bottom: env(safe-area-inset-bottom);
63 +}
64 +/* grain subtil — texture signature */
65 +body::before {
66 + content: "";
67 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
68 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
69 +}
70 +/* au-dessus du grain (::before positionné, premier dans l'ordre de peinture) ;
71 + surtout PAS de z-index ici : une règle hors @layer écraserait les utilitaires
72 + Tailwind (z-[1100] du header, z-[3000] du menu) */
73 +body > * { position: relative; }
74 +
75 +h1, h2, h3, h4 {
76 + font-family: var(--font-space-grotesk), system-ui, sans-serif;
77 + letter-spacing: -0.03em;
78 +}
79 +::selection { background: var(--lime); color: var(--ink); }
80 +
81 +/* ---------- primitives signature ---------- */
82 +.vp-display { font-family: var(--font-space-grotesk), system-ui, sans-serif; }
83 +.vp-mono { font-family: var(--font-jetbrains), ui-monospace, monospace; }
84 +
85 +.kicker {
86 + font-family: var(--font-jetbrains), ui-monospace, monospace;
87 + font-size: 11.5px; font-weight: 500; text-transform: uppercase;
88 + letter-spacing: 0.14em; color: var(--green);
89 + display: inline-flex; align-items: center; gap: 8px;
90 +}
91 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
92 +
93 +.klabel {
94 + font-family: var(--font-jetbrains), ui-monospace, monospace;
95 + font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
96 + color: var(--ink-3); font-weight: 700;
97 +}
98 +
99 +.vp-card {
100 + background: var(--surface);
101 + border: 1.5px solid var(--ink);
102 + border-radius: var(--r-card);
103 + box-shadow: var(--shadow-off-soft);
104 +}
105 +.vp-card-hover { transition: transform 0.16s ease, box-shadow 0.16s ease; }
106 +.vp-card-hover:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
107 +
108 +.vp-input {
109 + border: 1.5px solid var(--line); background: var(--surface-2);
110 + border-radius: var(--r-ctl); padding: 11px 12px; font-size: 15px;
111 + color: var(--ink); outline: none; font-family: inherit; width: 100%;
112 + transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px;
113 + appearance: none; -webkit-appearance: none;
114 +}
115 +.vp-input:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
116 +select.vp-input {
117 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
118 + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
119 +}
120 +@media (max-width: 900px) { .vp-input { font-size: 16px; } } /* anti-zoom iOS */
121 +
122 +.btn {
123 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px;
124 + font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px;
125 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
126 + font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: 0.01em;
127 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
128 +}
129 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
130 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
131 +.btn-primary:hover { background: var(--green-deep); }
132 +.btn-ghost { background: transparent; color: var(--ink); }
133 +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
134 +
135 +.stat-chip {
136 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px;
137 + padding: 8px 16px; font-family: var(--font-jetbrains), ui-monospace, monospace;
138 + font-size: 12px; color: var(--ink-2); display: inline-flex; gap: 8px; align-items: center;
139 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); white-space: nowrap;
140 +}
141 +.stat-chip b { color: var(--ink); font-weight: 700; }
142 +.stat-chip .pulse {
143 + width: 8px; height: 8px; border-radius: 50%; background: var(--green);
144 + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
145 +}
146 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(255, 159, 69, 0.4); } }
147 +
148 +.hl {
149 + background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block;
150 + transform: rotate(-1deg);
151 +}
152 +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
153 +
154 +/* ticker */
155 +.ticker {
156 + background: var(--ink); color: var(--lime); overflow: hidden;
157 + font-family: var(--font-jetbrains), ui-monospace, monospace;
158 + font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase;
159 + padding: 7px 0; white-space: nowrap;
160 + border-bottom: 1px solid rgba(255, 159, 69, 0.25);
161 +}
162 +.ticker-track { display: inline-flex; animation: ticker 44s linear infinite; will-change: transform; }
163 +.ticker span { padding: 0 26px; position: relative; }
164 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
165 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
166 +@media (prefers-reduced-motion: reduce) {
167 + .ticker-track { animation: none; }
168 + * { transition-duration: 0.01ms !important; }
169 +}
170 +
171 +/* kv cells (fiche) */
172 +.kv-cell {
173 + background: var(--surface-2); border: 1.5px solid var(--line);
174 + border-radius: var(--r-ctl); padding: 10px 13px;
175 +}
176 +.kv-cell .k {
177 + font-family: var(--font-jetbrains), ui-monospace, monospace;
178 + font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em;
179 + color: var(--ink-3); font-weight: 700;
180 +}
181 +.kv-cell .v {
182 + font-weight: 700; font-size: 14.5px; margin-top: 2px;
183 + font-family: var(--font-space-grotesk), system-ui, sans-serif;
184 +}
185 +
186 +/* pills */
187 +.pill {
188 + display: inline-block; border-radius: 4px; padding: 3px 10px;
189 + font-family: var(--font-jetbrains), ui-monospace, monospace;
190 + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
191 +}
192 +.pill-conf-A { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); }
193 +.pill-conf-B { background: var(--lime-soft); color: var(--green-deep); border: 1px solid var(--green); }
194 +.pill-conf-C { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
195 +.pill-conf-D { background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger); }
196 +
197 +/* tableau éditorial */
198 +.src-wrap {
199 + overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card);
200 + box-shadow: var(--shadow-off-soft); background: var(--surface);
201 +}
202 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; }
203 +.src-table th {
204 + text-align: left; font-family: var(--font-jetbrains), ui-monospace, monospace;
205 + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
206 + padding: 12px 14px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
207 + white-space: nowrap;
208 +}
209 +.src-table td { padding: 11px 14px; border-bottom: 1px solid var(--line); font-size: 13.5px; vertical-align: top; }
210 +.src-table tr:last-child td { border-bottom: none; }
211 +.src-table tr:hover td { background: var(--surface-2); }
212 +
213 +/* animations de révélation */
214 +@keyframes rise {
215 + from { opacity: 0; transform: translateY(12px); }
216 + to { opacity: 1; transform: none; }
217 +}
218 +.rise { animation: rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
219 +@keyframes growX { from { transform: scaleX(0); } }
220 +.grow-x { transform-origin: left; animation: growX 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
221 +@keyframes winPop { from { opacity: 0; } }
222 +.vv-win { animation: winPop 0.4s ease both; }
223 +@media (prefers-reduced-motion: reduce) {
224 + .rise, .grow-x, .vv-win { animation: none; }
225 +}
226 +
227 +/* barres horizontales (historique) */
228 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
229 +.hbar-fill {
230 + display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0;
231 + min-width: 2px; transition: background 0.12s ease;
232 +}
233 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
234 +
added app/src/app/layout.tsx +94 −0
@@ -0,0 +1,94 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 +import "./globals.css";
5 +import { LangProvider, LangToggle } from "@/components/LangContext";
6 +import CookieConsent from "@/components/CookieConsent";
7 +import FooterNav from "@/components/FooterNav";
8 +import HeaderNav from "@/components/HeaderNav";
9 +import Link from "next/link";
10 +
11 +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
12 +const spaceGrotesk = Space_Grotesk({
13 + subsets: ["latin"],
14 + variable: "--font-space-grotesk",
15 +});
16 +const jetbrains = JetBrains_Mono({
17 + subsets: ["latin"],
18 + variable: "--font-jetbrains",
19 +});
20 +
21 +export const metadata: Metadata = {
22 + title: "ValoPlex — la vraie valeur, sans boîte noire",
23 + description:
24 + "Le moteur d'évaluation spécialisé plex du Québec : modèle hédonique avancé + ventes de plex comparables, fourchette calibrée et indice de confiance.",
25 +};
26 +
27 +const TICKER = [
28 + "393 867 plex évalués",
29 + "1 733 744 portes",
30 + "91 060 ventes de plex 2021-2026",
31 + "Fourchette P10-P90 calibrée à 80 %",
32 + "Modèle en ratio prix/rôle — juste du duplex à la tour",
33 + "Zéro boîte noire — le calcul est montré",
34 + "Valeur par porte, ajustements par porte",
35 + "388,5 G$ de plex au Québec",
36 +];
37 +
38 +export default function RootLayout({ children }: LayoutProps<"/">) {
39 + return (
40 + <html lang="fr">
41 + <body
42 + className={`${inter.variable} ${spaceGrotesk.variable} ${jetbrains.variable}`}
43 + >
44 + <LangProvider>
45 + {/* fond opaque sur mobile (backdrop-filter casse les enfants fixed sur WebKit) */}
46 + <header className="sticky top-0 z-[1100] border-b-2 border-ink bg-paper md:bg-[rgba(245,243,238,0.88)] md:backdrop-blur-xl">
47 + <div className="relative mx-auto flex h-16 max-w-6xl items-center gap-5 px-4 sm:px-6">
48 + <Link
49 + href="/"
50 + className="vp-display group flex items-center text-[22px] font-bold leading-none tracking-[-0.04em] sm:text-[26px]"
51 + >
52 + Valo
53 + <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime transition-transform duration-200 group-hover:rotate-0">
54 + Plex
55 + </span>
56 + </Link>
57 + <div className="ml-auto flex items-center gap-2">
58 + <HeaderNav />
59 + <LangToggle />
60 + </div>
61 + </div>
62 + </header>
63 + <div className="ticker" aria-hidden="true">
64 + <div className="ticker-track">
65 + {[...TICKER, ...TICKER].map((s, i) => (
66 + <span key={i}>{s}</span>
67 + ))}
68 + </div>
69 + </div>
70 + <main className="mx-auto max-w-6xl px-4 pb-16 sm:px-6">{children}</main>
71 + <footer className="mt-6 bg-ink py-11 text-[13px] text-[rgba(245,243,238,0.75)]">
72 + <div className="mx-auto max-w-6xl px-4 sm:px-6">
73 + <p className="vp-display mb-3 text-[30px] font-bold tracking-[-0.04em] text-paper">
74 + Valo<span className="text-lime">Plex</span>
75 + </p>
76 + <p className="max-w-2xl">
77 + Le moteur d&apos;évaluation spécialisé plex : 393 867 immeubles à
78 + revenus du Québec (rôles MAMH, données ouvertes) et 91 060 ventes
79 + réelles de plex. Modèle hédonique avancé + comparables ajustés
80 + porte par porte, calcul montré en entier.
81 + </p>
82 + <FooterNav />
83 + <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.08em] text-[rgba(245,243,238,0.45)]">
84 + www.valoplex.com — Simon-Pierre Boucher · contact@spboucher.ai —
85 + estimation statistique à titre indicatif
86 + </p>
87 + </div>
88 + </footer>
89 + <CookieConsent />
90 + </LangProvider>
91 + </body>
92 + </html>
93 + );
94 +}
added app/src/app/methodologie/page.tsx +259 −0
@@ -0,0 +1,259 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import Link from "next/link";
4 +import { useLang } from "@/components/LangContext";
5 +
6 +type Row = [string, string];
7 +interface Section {
8 + num: string;
9 + title: string;
10 + paras: string[];
11 + table?: { head: [string, string, string]; rows: [string, string, string][] };
12 + kv?: Row[];
13 +}
14 +
15 +const FR: Section[] = [
16 + {
17 + num: "01",
18 + title: "Domaine : les plex du Québec, tous les plex",
19 + paras: [
20 + "ValoPlex couvre l'ensemble des immeubles résidentiels à logements multiples du Québec : toute unité d'évaluation d'usage « Logement » (CUBF 1000) comptant 2 logements et plus au rôle — du duplex au petit immeuble de rapport. Périmètre 2026 : 393 867 plex totalisant 1 733 744 portes, dans plus de 1 000 municipalités, suivis sur six millésimes de rôle (2021-2026, source MAMH, données ouvertes, géoréférencés NAD83).",
21 + "Le corpus de ventes compte 91 060 transactions de plex (2021-2026) appariées à leur unité d'évaluation par la fusion spatiale de Vrai-Prix (99,9 % d'appariement, distance médiane 0,6 m) — y compris les ventes de plex étiquetées autrement à la source mais dont l'immeuble apparié est bel et bien un plex.",
22 + ],
23 + kv: [
24 + ["Plex au rôle 2026", "393 867"],
25 + ["Portes (logements)", "1 733 744"],
26 + ["Ventes de plex 2021-2026", "91 060"],
27 + ["Millésimes couverts", "2021 → 2026"],
28 + ["Définition", "CUBF 1000 · 2 logements et +"],
29 + ["Valeur totale estimée", "388,5 G$"],
30 + ],
31 + },
32 + {
33 + num: "02",
34 + title: "Un modèle en ratio, à l'épreuve des échelles",
35 + paras: [
36 + "Le modèle central est un gradient boosting (LightGBM) entraîné exclusivement sur les ventes de plex. Sa cible n'est pas le prix : c'est le RATIO log(prix / valeur au rôle). Cette normalisation d'échelle est décisive pour les immeubles à revenus : un modèle en prix brut, dominé par la masse des duplex, compresse les grands immeubles vers le bas (nous avons mesuré des tours au rôle de 700 M$ prédites à 5 M$). En ratio, le duplex de 400 000 $ et la tour de 700 M$ sont évalués avec la même justesse relative — le modèle prédit l'écart au rôle, jamais une grandeur hors de son domaine d'entraînement.",
37 + "Vingt-cinq variables, dont plusieurs spécifiques aux immeubles à revenus : nombre de portes, aire habitable par porte, valeur au rôle par porte, coût du bâtiment au m², chambres locatives, locaux non résidentiels (mixité commerciale), en plus des dimensions du terrain, de l'âge, de la localisation fine et du temps continu.",
38 + "Filtres d'entraînement : appariement fiable au rôle, prix entre 60 000 $ et 30 M$, ratio prix/évaluation dans [0,25 ; 4]. Échantillon final : 91 060 ventes (63 827 entraînement, 13 616 calibration, 13 617 test).",
39 + ],
40 + },
41 + {
42 + num: "03",
43 + title: "Fourchette calibrée par méthode conforme",
44 + paras: [
45 + "Deux modèles de régression quantile (10e et 90e centiles) produisent une fourchette propre à chaque plex. Mais les quantiles bruts d'un modèle sont notoirement trop étroits : sur nos ventes de test, ils ne couvraient que 73,6 % des prix réels au lieu des 80 % annoncés.",
46 + "ValoPlex applique donc une calibration conforme (split conformal) : sur un ensemble de calibration de 13 616 ventes jamais vues du modèle, on mesure le score de non-conformité de chaque vente et on élargit les bornes du quantile empirique requis. Résultat vérifié sur l'ensemble de test : couverture de 79,6 % ≈ 80 %. Quand ValoPlex affiche une fourchette, elle contient le vrai prix 8 fois sur 10 — c'est mesuré, pas supposé.",
47 + ],
48 + },
49 + {
50 + num: "04",
51 + title: "Validation — norme IAAO, segmentée par taille",
52 + paras: [
53 + "Validation selon la norme IAAO sur études de ratios, sur 13 617 ventes de test jamais vues, plus un test temporel strict (entraîné sans 2026, testé sur 9 172 ventes de 2026 : MdAPE 13,8 % — le modèle tient dans le futur). Les immeubles de 6 portes et plus sont plus difficiles (marchés minces, logique de revenu dominante) — l'indice de confiance affiché le reflète.",
54 + ],
55 + table: {
56 + head: ["Segment", "Erreur médiane", "COD / ratio médian"],
57 + rows: [
58 + ["Ensemble des plex", "13,97 %", "27,1 / 0,973"],
59 + ["Duplex (2 portes)", "13,95 %", "26,8 / 0,971"],
60 + ["Triplex (3 portes)", "12,76 %", "25,6 / 0,982"],
61 + ["4-5 portes", "14,50 %", "26,9 / 0,972"],
62 + ["6-12 portes", "17,67 %", "35,1 / 0,942"],
63 + ["Test temporel 2026", "13,81 %", "28,2 / 1,003"],
64 + ["Couverture fourchette (calibrée)", "79,6 % (cible 80 %)", "—"],
65 + ],
66 + },
67 + },
68 + {
69 + num: "05",
70 + title: "Moteur en ligne : comparables porte par porte",
71 + paras: [
72 + "À la consultation, l'estimation combine 65 % modèle hédonique + 35 % comparables. Les ventes candidates sont cherchées dans un rayon adaptatif (2,2 → 28 km), filtrées par similarité de portes (±1 porte, relâché à ±3 en marché mince) et de superficie (±20 → ±40 %).",
73 + "Quatre ajustements, tous affichés en dollars : conditions de marché (indice mensuel du $/m² des ventes de plex, lissé 3 mois), superficie (50 % du $/m² du comparable, plafonné ±25 %), âge (0,5 %/an, plafonné ±10 %) et portes (50 % du prix par porte du comparable par porte d'écart, plafonné ±30 %). Pondération gaussienne : distance (σ 1 500 m), récence (σ 24 mois), similarité de superficie (σ 25 %) et de portes (σ 2,5). L'estimation comparables est la médiane pondérée des 12 meilleurs prix ajustés.",
74 + ],
75 + },
76 + {
77 + num: "06",
78 + title: "Historique 2021-2026 et statistiques",
79 + paras: [
80 + "Chaque plex porte six estimations historisées (2021-2026), calculées avec les caractéristiques du rôle de chaque millésime — nouvelles constructions incluses au fil des ans. Les agrégats provinciaux (valeur totale des plex, palmarès par municipalité, répartition par taille) sont recalculés à chaque mise à jour des données et publiés sur la page Statistiques.",
81 + ],
82 + },
83 + {
84 + num: "07",
85 + title: "Limites connues",
86 + paras: [
87 + "Les revenus locatifs réels, les baux en vigueur et l'état d'entretien ne figurent pas dans les données ouvertes du rôle : ValoPlex approxime la dimension revenu par les variables par porte, la mixité et les valeurs au rôle, mais un immeuble aux loyers exceptionnellement bas ou récemment rénové sera mal capté. Les immeubles de 13 portes et plus sont en extrapolation partielle (51 ventes de test seulement) — la fourchette s'élargit en conséquence. Une estimation statistique ne remplace ni l'évaluateur agréé (OEAQ) ni l'analyse d'un courtier spécialisé en multilogement.",
88 + ],
89 + },
90 +];
91 +
92 +const EN: Section[] = [
93 + {
94 + num: "01",
95 + title: "Domain: Québec plexes — all of them",
96 + paras: [
97 + "ValoPlex covers every multi-dwelling residential building in Québec: any assessment unit with 'Dwelling' use (CUBF 1000) and 2+ dwellings on the roll — from duplexes to small income buildings. 2026 perimeter: 393,867 plexes totalling 1,733,744 doors, tracked over six roll vintages (2021-2026, MAMH open data, georeferenced).",
98 + "The sales corpus counts 91,060 plex transactions (2021-2026) matched to their assessment unit by Vrai-Prix's spatial merge (99.9% matched, median distance 0.6 m) — including plex sales labelled otherwise at the source whose matched building is indeed a plex.",
99 + ],
100 + kv: [
101 + ["Plexes on 2026 roll", "393,867"],
102 + ["Doors (dwellings)", "1,733,744"],
103 + ["Plex sales 2021-2026", "91,060"],
104 + ["Vintages", "2021 → 2026"],
105 + ["Definition", "CUBF 1000 · 2+ dwellings"],
106 + ["Total estimated value", "$388.5B"],
107 + ],
108 + },
109 + {
110 + num: "02",
111 + title: "A ratio model, scale-proof",
112 + paras: [
113 + "The core model is a LightGBM gradient boosting trained exclusively on plex sales. Its target is not the price: it is the RATIO log(price / assessed value). This scale normalization is decisive for income properties: a raw-price model, dominated by the mass of duplexes, crushes large buildings downward (we measured towers assessed at $700M predicted at $5M). On ratio, the $400k duplex and the $700M tower are valued with the same relative accuracy — the model predicts the gap to the roll, never a magnitude outside its training domain.",
114 + "Twenty-five variables, several specific to income properties: door count, living area per door, assessed value per door, building cost per m², rental rooms, non-residential units (commercial mix), plus lot dimensions, age, fine location and continuous time.",
115 + "Training filters: reliable roll match, price between $60k and $30M, price/assessment ratio in [0.25, 4]. Final sample: 91,060 sales (63,827 train, 13,616 calibration, 13,617 test).",
116 + ],
117 + },
118 + {
119 + num: "03",
120 + title: "Conformally calibrated range",
121 + paras: [
122 + "Two quantile regression models (10th and 90th percentiles) produce a per-plex range. Raw model quantiles are notoriously too narrow: on our test sales they covered only 73.6% of true prices instead of the advertised 80%.",
123 + "ValoPlex therefore applies split conformal calibration: on a 13,616-sale calibration set never seen by the model, we measure each sale's non-conformity score and widen the bounds by the required empirical quantile. Verified on the test set: 79.6% coverage ≈ 80%. When ValoPlex shows a range, the true price lands inside 8 times out of 10 — measured, not assumed.",
124 + ],
125 + },
126 + {
127 + num: "04",
128 + title: "Validation — IAAO standard, segmented by size",
129 + paras: [
130 + "Validation follows the IAAO Standard on Ratio Studies, on 13,617 unseen test sales, plus a strict temporal test (trained without 2026, tested on 9,172 sales of 2026: MdAPE 13.8% — the model holds into the future). Buildings of 6+ doors are harder (thin markets, income-driven pricing) — the displayed confidence index reflects it.",
131 + ],
132 + table: {
133 + head: ["Segment", "Median error", "COD / median ratio"],
134 + rows: [
135 + ["All plexes", "13.97%", "27.1 / 0.973"],
136 + ["Duplexes (2 doors)", "13.95%", "26.8 / 0.971"],
137 + ["Triplexes (3 doors)", "12.76%", "25.6 / 0.982"],
138 + ["4-5 doors", "14.50%", "26.9 / 0.972"],
139 + ["6-12 doors", "17.67%", "35.1 / 0.942"],
140 + ["Temporal test 2026", "13.81%", "28.2 / 1.003"],
141 + ["Range coverage (calibrated)", "79.6% (target 80%)", "—"],
142 + ],
143 + },
144 + },
145 + {
146 + num: "05",
147 + title: "Online engine: door-by-door comparables",
148 + paras: [
149 + "At consultation time, the estimate blends 65% hedonic model + 35% comparables. Candidate sales are fetched in an adaptive radius (2.2 → 28 km), filtered by door similarity (±1 door, relaxed to ±3 in thin markets) and floor area (±20 → ±40%).",
150 + "Four adjustments, all displayed in dollars: market conditions (monthly plex $/m² index, 3-month smoothed), area (50% of the comp's $/m², capped ±25%), age (0.5%/yr, capped ±10%) and doors (50% of the comp's price per door per door of difference, capped ±30%). Gaussian weights on distance (σ 1,500 m), recency (σ 24 mo), area similarity (σ 25%) and door similarity (σ 2.5). The comparables estimate is the weighted median of the 12 best adjusted prices.",
151 + ],
152 + },
153 + {
154 + num: "06",
155 + title: "2021-2026 history and statistics",
156 + paras: [
157 + "Every plex carries six historical estimates (2021-2026), computed with each vintage's roll characteristics — new construction included over time. Provincial aggregates (total plex value, municipal ranking, size breakdown) are recomputed at every data refresh and published on the Statistics page.",
158 + ],
159 + },
160 + {
161 + num: "07",
162 + title: "Known limitations",
163 + paras: [
164 + "Actual rents, leases in force and upkeep do not appear in open roll data: ValoPlex approximates the income dimension through per-door variables, commercial mix and assessed values, but a building with exceptionally low rents or recent renovations will be poorly captured. Buildings of 13+ doors are partially extrapolated (only 51 test sales) — the range widens accordingly. A statistical estimate replaces neither a chartered appraiser (OEAQ) nor a multi-residential brokerage analysis.",
165 + ],
166 + },
167 +];
168 +
169 +export default function MethodologiePage() {
170 + const { lang } = useLang();
171 + const fr = lang === "fr";
172 + const sections = fr ? FR : EN;
173 + return (
174 + <div className="py-10">
175 + <nav className="vp-mono mb-8 flex items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">
176 + <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">
177 + ValoPlex
178 + </Link>
179 + <span>/</span>
180 + <span>{fr ? "Méthodologie" : "Methodology"}</span>
181 + </nav>
182 + <span className="kicker">{fr ? "Le calcul, en entier" : "The math, in full"}</span>
183 + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(30px,5vw,52px)] font-bold uppercase leading-[1] tracking-[-0.03em]">
184 + {fr ? (
185 + <>
186 + Méthodologie <span className="hl">ultra-détaillée</span>
187 + </>
188 + ) : (
189 + <>
190 + The <span className="hl">full</span> methodology
191 + </>
192 + )}
193 + </h1>
194 + <p className="mt-4 max-w-2xl text-[16px] text-ink-2">
195 + {fr
196 + ? "Notre différenciateur est la transparence totale. Cette page documente chaque étape — données, fusion, modèle, validation, moteur — au niveau de détail d'un rapport technique."
197 + : "Our differentiator is total transparency. This page documents every step — data, merge, model, validation, engine — at technical-report depth."}
198 + </p>
199 +
200 + <div className="mt-10 space-y-8">
201 + {sections.map((sct) => (
202 + <section key={sct.num} className="vp-card p-6 sm:p-8">
203 + <div className="flex items-baseline gap-4">
204 + <span className="vp-mono text-[13px] font-bold text-green">{sct.num}</span>
205 + <h2 className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]">
206 + {sct.title}
207 + </h2>
208 + </div>
209 + <div className="mt-4 space-y-3.5">
210 + {sct.paras.map((p, i) => (
211 + <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2">
212 + {p}
213 + </p>
214 + ))}
215 + </div>
216 + {sct.kv && (
217 + <div className="mt-5 grid grid-cols-2 gap-2.5 sm:grid-cols-3">
218 + {sct.kv.map(([k, v]) => (
219 + <div key={k} className="kv-cell">
220 + <p className="k">{k}</p>
221 + <p className="v">{v}</p>
222 + </div>
223 + ))}
224 + </div>
225 + )}
226 + {sct.table && (
227 + <div className="src-wrap mt-5">
228 + <table className="src-table">
229 + <thead>
230 + <tr>
231 + {sct.table.head.map((h) => (
232 + <th key={h}>{h}</th>
233 + ))}
234 + </tr>
235 + </thead>
236 + <tbody>
237 + {sct.table.rows.map(([a, b, c]) => (
238 + <tr key={a}>
239 + <td className="font-semibold">{a}</td>
240 + <td className="vp-mono text-[12.5px]">{b}</td>
241 + <td className="vp-mono text-[12.5px]">{c}</td>
242 + </tr>
243 + ))}
244 + </tbody>
245 + </table>
246 + </div>
247 + )}
248 + </section>
249 + ))}
250 + </div>
251 +
252 + <p className="vp-mono mt-8 text-[11px] uppercase tracking-[0.06em] text-ink-3">
253 + {fr
254 + ? "Sources : rôles d'évaluation foncière du Québec (MAMH, données ouvertes) · IAAO Standard on Ratio Studies · IAAO Standard on AVMs"
255 + : "Sources: Québec property assessment rolls (MAMH, open data) · IAAO Standard on Ratio Studies · IAAO Standard on AVMs"}
256 + </p>
257 + </div>
258 + );
259 +}
added app/src/app/page.tsx +100 −0
@@ -0,0 +1,100 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import SearchBox from "@/components/SearchBox";
4 +import EstimateForm from "@/components/EstimateForm";
5 +import { useLang } from "@/components/LangContext";
6 +
7 +export default function Home() {
8 + const { lang, t } = useLang();
9 + const fr = lang === "fr";
10 + return (
11 + <div className="pb-6 pt-10 sm:pt-14">
12 + {/* ---- Héros ---- */}
13 + <section>
14 + <span className="kicker">
15 + {fr ? "Spécialiste plex · Québec · 2021-2026" : "Plex specialist · Québec · 2021-2026"}
16 + </span>
17 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(34px,6.4vw,72px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">
18 + {fr ? (
19 + <>
20 + Votre <span className="outline-txt">plex</span> vaut combien,
21 + <br />
22 + <span className="hl">porte par porte ?</span>
23 + </>
24 + ) : (
25 + <>
26 + What&apos;s your <span className="outline-txt">plex</span> worth,
27 + <br />
28 + <span className="hl">door by door?</span>
29 + </>
30 + )}
31 + </h1>
32 + <p className="mt-5 max-w-xl text-[16.5px] text-ink-2">{t("sub")}</p>
33 +
34 + <div className="mt-7 flex gap-2.5 overflow-x-auto pb-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
35 + <span className="stat-chip">
36 + <span className="pulse" />
37 + <b>393 867</b> plex
38 + </span>
39 + <span className="stat-chip">
40 + <b>1 733 744</b> {fr ? "portes" : "doors"}
41 + </span>
42 + <span className="stat-chip">
43 + <b>91 060</b> {fr ? "ventes de plex" : "plex sales"}
44 + </span>
45 + <span className="stat-chip">
46 + {fr ? "couverture calibrée" : "calibrated coverage"} <b>80 %</b>
47 + </span>
48 + </div>
49 + </section>
50 +
51 + {/* ---- Recherche ---- */}
52 + <section className="mt-9">
53 + <div className="vp-card p-4 sm:p-5">
54 + <p className="klabel mb-2 pl-0.5">
55 + {fr ? "Adresse du plex" : "Plex address"}
56 + </p>
57 + <SearchBox />
58 + </div>
59 + <p className="vp-mono mt-2.5 pl-1 text-[11px] uppercase tracking-[0.06em] text-ink-3">
60 + {t("dataNote")}
61 + </p>
62 + </section>
63 +
64 + {/* ---- Formulaire manuel ---- */}
65 + <section className="mt-14">
66 + <span className="kicker">{fr ? "Plan B" : "Plan B"}</span>
67 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
68 + {t("manualTitle")}
69 + </h2>
70 + <div className="vp-card mt-4 p-4 sm:p-6">
71 + <EstimateForm />
72 + </div>
73 + </section>
74 +
75 + {/* ---- Manifeste spécialiste ---- */}
76 + <section className="mt-14 grid gap-4 sm:grid-cols-3">
77 + {(lang === "fr"
78 + ? [
79 + ["01", "Spécialisé, pas généraliste", "Un modèle entraîné exclusivement sur des ventes de plex : valeur par porte, mixité commerciale, chambres locatives — les variables qui comptent vraiment pour un immeuble à revenus."],
80 + ["02", "Fourchette calibrée à 80 %", "Nos bornes P10-P90 sont calibrées par méthode conforme : 8 fois sur 10, le vrai prix de vente tombe dedans. Vérifié sur des ventes jamais vues, pas promis."],
81 + ["03", "Le calcul, montré", "Chaque comparable, chaque ajustement (marché, superficie, âge, portes) affiché en dollars. Zéro boîte noire."],
82 + ]
83 + : [
84 + ["01", "Specialized, not generalist", "A model trained exclusively on plex sales: value per door, commercial mix, rental rooms — the variables that actually matter for an income property."],
85 + ["02", "Range calibrated at 80%", "Our P10-P90 bounds are conformally calibrated: 8 times out of 10, the true sale price lands inside. Verified on unseen sales, not promised."],
86 + ["03", "The math, shown", "Every comparable, every adjustment (market, area, age, doors) displayed in dollars. Zero black box."],
87 + ]
88 + ).map(([num, title, body]) => (
89 + <div key={num} className="vp-card vp-card-hover p-5">
90 + <p className="vp-mono text-[11px] font-bold text-green">{num}</p>
91 + <h3 className="vp-display mt-1.5 text-[17px] font-bold uppercase tracking-[-0.01em]">
92 + {title}
93 + </h3>
94 + <p className="mt-2 text-[13.5px] text-ink-2">{body}</p>
95 + </div>
96 + ))}
97 + </section>
98 + </div>
99 + );
100 +}
added app/src/app/parc/page.tsx +358 −0
@@ -0,0 +1,358 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import SearchBox, { type Suggestion } from "@/components/SearchBox";
6 +import { CountUp, locNum, Sparkline, ValueSplit } from "@/components/MetricViz";
7 +import { useLang } from "@/components/LangContext";
8 +import type { PortfolioResult } from "@/lib/estimator";
9 +import type { TKey } from "@/lib/i18n";
10 +
11 +const KEY = "vp-parc";
12 +
13 +interface Saved {
14 + id: string;
15 + adresse: string;
16 + municipalite: string;
17 +}
18 +
19 +const fmt = (v: number | null | undefined, lang: string) =>
20 + v == null
21 + ? "—"
22 + : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
23 + style: "currency",
24 + currency: "CAD",
25 + maximumFractionDigits: 0,
26 + }).format(v);
27 +
28 +const CONF: Record<string, string> = {
29 + A: "pill-conf-A",
30 + B: "pill-conf-B",
31 + C: "pill-conf-C",
32 + D: "pill-conf-D",
33 +};
34 +
35 +export default function ParcPage() {
36 + const { lang, t } = useLang();
37 + const fr = lang === "fr";
38 + const [list, setList] = useState<Saved[]>([]);
39 + const [data, setData] = useState<PortfolioResult | null>(null);
40 + const [loading, setLoading] = useState(false);
41 + const [hydrated, setHydrated] = useState(false);
42 +
43 + useEffect(() => {
44 + const id = setTimeout(() => {
45 + try {
46 + const raw = window.localStorage.getItem(KEY);
47 + if (raw) setList(JSON.parse(raw) as Saved[]);
48 + } catch {}
49 + setHydrated(true);
50 + }, 0);
51 + return () => clearTimeout(id);
52 + }, []);
53 +
54 + const persist = (l: Saved[]) => {
55 + setList(l);
56 + window.localStorage.setItem(KEY, JSON.stringify(l));
57 + setData(null);
58 + };
59 +
60 + const add = (s: Suggestion) => {
61 + if (list.some((x) => x.id === s.id) || list.length >= 40) return;
62 + persist([
63 + ...list,
64 + {
65 + id: s.id,
66 + adresse: `${s.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`,
67 + municipalite: s.municipalite ?? "",
68 + },
69 + ]);
70 + };
71 +
72 + const evaluate = async () => {
73 + setLoading(true);
74 + try {
75 + const res = await fetch("/api/portfolio", {
76 + method: "POST",
77 + headers: { "Content-Type": "application/json" },
78 + body: JSON.stringify({ ids: list.map((x) => x.id) }),
79 + });
80 + if (res.ok) setData((await res.json()) as PortfolioResult);
81 + } finally {
82 + setLoading(false);
83 + }
84 + };
85 +
86 + const a = data?.aggregates;
87 +
88 + return (
89 + <div className="pb-6 pt-10 sm:pt-14">
90 + {/* héros */}
91 + <section>
92 + <span className="kicker">{fr ? "Investisseurs · propriétaires multiples" : "Investors · multi-owners"}</span>
93 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(30px,5.6vw,62px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">
94 + {fr ? (
95 + <>
96 + La valeur de votre <span className="hl">parc de plex</span>,
97 + <br />
98 + <span className="outline-txt">au complet.</span>
99 + </>
100 + ) : (
101 + <>
102 + Your <span className="hl">plex portfolio</span>,
103 + <br />
104 + <span className="outline-txt">fully valued.</span>
105 + </>
106 + )}
107 + </h1>
108 + <p className="mt-4 max-w-xl text-[16px] text-ink-2">
109 + {fr
110 + ? "Ajoutez chacun de vos plex. ValoPlex évalue chaque immeuble, agrège votre parc — valeur totale, fourchette, croissance 2021→2026 — et produit un rapport PDF consolidé."
111 + : "Add each of your plexes. ValoPlex values every building, aggregates your portfolio — total value, range, 2021→2026 growth — and produces a consolidated PDF report."}
112 + </p>
113 + </section>
114 +
115 + {/* constructeur de parc */}
116 + <section className="mt-9">
117 + <div className="vp-card p-4 sm:p-5">
118 + <p className="klabel mb-2 pl-0.5">
119 + {fr ? `Ajouter un plex (${list.length}/40)` : `Add a plex (${list.length}/40)`}
120 + </p>
121 + <SearchBox
122 + onPick={add}
123 + placeholder={fr ? "Tapez une adresse puis choisissez-la…" : "Type an address, then pick it…"}
124 + />
125 + {hydrated && list.length > 0 && (
126 + <div className="mt-4 flex flex-wrap gap-2">
127 + {list.map((p, i) => (
128 + <span
129 + key={p.id}
130 + className="stat-chip !py-1.5"
131 + style={{ animationDelay: `${i * 40}ms` }}
132 + >
133 + <b>{i + 1}</b> {p.adresse} · {p.municipalite}
134 + <button
135 + aria-label={fr ? "Retirer" : "Remove"}
136 + onClick={() => persist(list.filter((x) => x.id !== p.id))}
137 + className="vp-mono ml-1 cursor-pointer rounded-full border-[1.5px] border-ink px-1.5 text-[10px] font-bold hover:bg-ink hover:text-lime"
138 + >
139 +
140 + </button>
141 + </span>
142 + ))}
143 + </div>
144 + )}
145 + <div className="mt-4 flex flex-wrap gap-2.5">
146 + <button
147 + className="btn btn-primary"
148 + disabled={list.length === 0 || loading}
149 + onClick={evaluate}
150 + >
151 + {loading
152 + ? fr ? "Évaluation en cours…" : "Valuing…"
153 + : fr ? `Évaluer mon parc (${list.length})` : `Value my portfolio (${list.length})`}
154 + </button>
155 + {list.length > 0 && (
156 + <button className="btn btn-ghost" onClick={() => persist([])}>
157 + {fr ? "Vider la liste" : "Clear list"}
158 + </button>
159 + )}
160 + </div>
161 + </div>
162 + </section>
163 +
164 + {/* résultats */}
165 + {data && a && (
166 + <div className="mt-12 space-y-10">
167 + {/* total */}
168 + <section className="vp-card p-6 sm:p-8">
169 + <span className="kicker">{fr ? "Valeur totale estimée du parc" : "Total estimated portfolio value"}</span>
170 + <p className="vp-display mt-3 text-[clamp(40px,7vw,68px)] font-bold leading-none tracking-[-0.04em]">
171 + <CountUp
172 + value={a.totalEstimate}
173 + format={(v) =>
174 + new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {
175 + style: "currency",
176 + currency: "CAD",
177 + maximumFractionDigits: 0,
178 + }).format(Math.round(v / 100) * 100)
179 + }
180 + />
181 + </p>
182 + <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
183 + <div>
184 + <p className="klabel">{fr ? "Fourchette cumulée" : "Cumulative range"}</p>
185 + <p className="vp-display text-[18px] font-bold">
186 + {fmt(a.totalLow, lang)} <span className="text-ink-3">→</span> {fmt(a.totalHigh, lang)}
187 + </p>
188 + </div>
189 + <div>
190 + <p className="klabel">{fr ? "Confiance pondérée" : "Weighted confidence"}</p>
191 + <span className={`pill ${CONF[a.confidenceLevel]}`}>
192 + {a.confidenceLevel} · {a.confidencePct} %
193 + </span>
194 + </div>
195 + <div>
196 + <p className="klabel">{fr ? "Évaluation municipale" : "Municipal assessment"}</p>
197 + <p className="vp-display text-[18px] font-bold text-ink-2">{fmt(a.totalRole, lang)}</p>
198 + </div>
199 + </div>
200 + <a
201 + href={`/api/report/portfolio?ids=${data.items.map((i) => i.unit!.id).join(",")}`}
202 + className="btn btn-primary mt-6"
203 + download
204 + >
205 + {fr
206 + ? `Télécharger le rapport de parc (${1 + data.items.length} pages PDF)`
207 + : `Download the portfolio report (${1 + data.items.length} PDF pages)`}{" "}
208 +
209 + </a>
210 + </section>
211 +
212 + {/* tuiles registre */}
213 + <section>
214 + <span className="kicker">{fr ? "Votre parc, en chiffres du registre" : "Your portfolio, in registry numbers"}</span>
215 + <div className="mt-4 grid grid-cols-2 gap-3.5 sm:grid-cols-4">
216 + {(
217 + [
218 + [fr ? "Propriétés" : "Properties", String(a.count), true],
219 + [fr ? "Écart vs rôle" : "vs assessment", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false],
220 + [fr ? "Croissance 2021→2026" : "Growth 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true],
221 + [fr ? "Logements" : "Dwellings", String(a.totalDwellings), false],
222 + [fr ? "Aire habitable" : "Living area", `${a.totalFloorArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],
223 + [fr ? "Terrain total" : "Total land", `${a.totalLandArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],
224 + [fr ? "Année moyenne" : "Avg. year built", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false],
225 + [fr ? "Municipalités" : "Municipalities", String(a.municipalities.length), false],
226 + ] as [string, string, boolean][]
227 + ).map(([k, v, hero]) => (
228 + <div
229 + key={k}
230 + className={`vp-card vp-card-hover p-4 ${hero ? "!bg-ink" : ""}`}
231 + >
232 + <p className={`vp-display text-[clamp(20px,2.6vw,28px)] font-bold leading-tight tracking-[-0.03em] ${hero ? "text-lime" : "text-ink"}`}>
233 + {v}
234 + </p>
235 + <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>
236 + {k}
237 + </p>
238 + </div>
239 + ))}
240 + </div>
241 + </section>
242 +
243 + {/* historique du parc + répartitions */}
244 + <section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
245 + <div className="vp-card p-5 sm:p-6">
246 + <p className="klabel mb-3">{fr ? "Valeur du parc par année" : "Portfolio value by year"}</p>
247 + <div className="flex flex-col gap-2">
248 + {a.history.map((h) => {
249 + const max = Math.max(...a.history.map((x) => x.total), 1);
250 + return (
251 + <div key={h.year} className="hbar-row grid grid-cols-[46px_1fr_auto] items-center gap-3">
252 + <span className="vp-mono text-[12px] font-bold">{h.year}</span>
253 + <span className="hbar-track !h-[22px]">
254 + <span
255 + className="hbar-fill !rounded-r-[6px]"
256 + style={{ width: `${(h.total / max) * 100}%`, background: h.year === 2026 ? "var(--ink)" : undefined }}
257 + />
258 + </span>
259 + <span className="vp-mono text-[12px] font-bold">
260 + {fmt(h.total, lang)}
261 + {h.nCovered < a.count && (
262 + <span className="text-ink-3"> ({h.nCovered}/{a.count})</span>
263 + )}
264 + </span>
265 + </div>
266 + );
267 + })}
268 + </div>
269 + </div>
270 + <div className="vp-card p-5 sm:p-6">
271 + <p className="klabel mb-3">{fr ? "Répartition" : "Breakdown"}</p>
272 + <div className="space-y-2">
273 + {a.municipalities.slice(0, 4).map((m) => (
274 + <div key={m.name} className="flex items-baseline justify-between gap-3 border-b border-dashed border-[rgba(20,24,20,0.14)] pb-1.5">
275 + <span className="vp-display text-[14px] font-bold">{m.name}</span>
276 + <span className="vp-mono text-[11px] text-ink-2">
277 + {m.count} · {fmt(m.total, lang)}
278 + </span>
279 + </div>
280 + ))}
281 + {a.types.map((tp) => (
282 + <div key={tp.type} className="flex items-baseline justify-between gap-3 border-b border-dashed border-[rgba(20,24,20,0.14)] pb-1.5 last:border-0">
283 + <span className="text-[13px] font-semibold text-ink-2">{t(tp.type as TKey)}</span>
284 + <span className="vp-mono text-[11px] text-ink-2">
285 + {tp.count} · {fmt(tp.total, lang)}
286 + </span>
287 + </div>
288 + ))}
289 + </div>
290 + </div>
291 + </section>
292 +
293 + {/* cartes propriétés */}
294 + <section>
295 + <span className="kicker">{fr ? "Chaque plex du parc" : "Every plex in the portfolio"}</span>
296 + <div className="mt-4 grid gap-4 sm:grid-cols-2">
297 + {data.items.map((it, i) => {
298 + const u = it.unit!;
299 + const r = it.result;
300 + return (
301 + <Link
302 + key={u.id}
303 + href={`/estimation/${u.id}`}
304 + className="vp-card vp-card-hover rise block p-5"
305 + style={{ animationDelay: `${i * 70}ms` }}
306 + >
307 + <div className="flex items-start justify-between gap-3">
308 + <div>
309 + <p className="vp-mono text-[10px] font-bold text-green">
310 + {String(i + 1).padStart(2, "0")}
311 + </p>
312 + <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">
313 + {u.adresse}
314 + </p>
315 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
316 + {u.municipalite} · {u.anneeConstruction ?? "—"} ·{" "}
317 + {u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"}
318 + </p>
319 + </div>
320 + <span className={`pill ${CONF[r.confidenceLevel]} shrink-0`}>
321 + {r.confidenceLevel}
322 + </span>
323 + </div>
324 + <div className="mt-3 flex items-end justify-between gap-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3">
325 + <div>
326 + <p className="vp-display text-[22px] font-bold tracking-[-0.02em]">
327 + {fmt(r.estimate, lang)}
328 + </p>
329 + <p className="vp-mono text-[10.5px] text-ink-3">
330 + {fr ? "rôle" : "roll"} {fmt(u.valeurRole, lang)}
331 + </p>
332 + </div>
333 + <Sparkline history={u.history} />
334 + </div>
335 + <div className="mt-3">
336 + <ValueSplit
337 + land={u.specs.valeurTerrain}
338 + building={u.specs.valeurBatiment}
339 + previous={null}
340 + total={u.valeurRole}
341 + lang={lang}
342 + labels={{
343 + land: fr ? "Terrain" : "Land",
344 + building: fr ? "Bâtiment" : "Building",
345 + previous: "",
346 + }}
347 + />
348 + </div>
349 + </Link>
350 + );
351 + })}
352 + </div>
353 + </section>
354 + </div>
355 + )}
356 + </div>
357 + );
358 +}
added app/src/app/stats/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import StatsView, { type ProvStats } from "@/components/StatsView";
3 +import stats from "@/data/stats.json";
4 +
5 +export const metadata = {
6 + title: "ValoPlex — Statistiques provinciales",
7 + description:
8 + "La valeur totale de l'immobilier québécois, par municipalité et par type — 3,7 millions de propriétés estimées.",
9 +};
10 +
11 +export default function StatsPage() {
12 + return <StatsView s={stats as ProvStats} />;
13 +}
added app/src/components/CookieConsent.tsx +226 −0
@@ -0,0 +1,226 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Gestionnaire de consentement aux témoins (Loi 25) :
4 + * bandeau au premier passage + panneau de préférences réouvrable partout
5 + * (événement window « vp-open-cookie-prefs » ou bouton du pied de page).
6 + * Choix conservé 12 mois dans localStorage (vp-cookie-consent).
7 + */
8 +"use client";
9 +import { useEffect, useState } from "react";
10 +import { useLang } from "./LangContext";
11 +
12 +export interface ConsentState {
13 + essential: true;
14 + preferences: boolean;
15 + analytics: boolean;
16 + savedAt: string;
17 +}
18 +
19 +const KEY = "vp-cookie-consent";
20 +const MAX_AGE_DAYS = 365;
21 +
22 +export function readConsent(): ConsentState | null {
23 + if (typeof window === "undefined") return null;
24 + try {
25 + const raw = window.localStorage.getItem(KEY);
26 + if (!raw) return null;
27 + const c = JSON.parse(raw) as ConsentState;
28 + const age = (Date.now() - new Date(c.savedAt).getTime()) / 86400000;
29 + return age > MAX_AGE_DAYS ? null : c;
30 + } catch {
31 + return null;
32 + }
33 +}
34 +
35 +export function openCookiePrefs() {
36 + window.dispatchEvent(new Event("vp-open-cookie-prefs"));
37 +}
38 +
39 +const TXT = {
40 + fr: {
41 + banner:
42 + "ValoPlex utilise un minimum de témoins : des essentiels (fonctionnement du site) et, si vous l'acceptez, un témoin de préférences pour retenir votre langue. Aucun témoin publicitaire, aucun pistage tiers.",
43 + acceptAll: "Tout accepter",
44 + essentialOnly: "Essentiels seulement",
45 + customize: "Personnaliser",
46 + prefsTitle: "Préférences de témoins",
47 + essential: "Essentiels",
48 + essentialDesc: "Nécessaires au fonctionnement (sécurité, session). Toujours actifs.",
49 + prefs: "Préférences",
50 + prefsDesc: "Retient votre langue d'affichage (FR/EN) sur cet appareil, 12 mois.",
51 + analytics: "Mesure d'audience",
52 + analyticsDesc: "Aucun outil de mesure n'est actuellement utilisé. Ce réglage s'appliquera si un jour un outil respectueux est ajouté.",
53 + always: "Toujours actifs",
54 + save: "Enregistrer mes choix",
55 + more: "Politique de confidentialité",
56 + },
57 + en: {
58 + banner:
59 + "ValoPlex uses a minimum of cookies: essential ones (site operation) and, with your consent, a preference cookie to remember your language. No advertising cookies, no third-party tracking.",
60 + acceptAll: "Accept all",
61 + essentialOnly: "Essential only",
62 + customize: "Customize",
63 + prefsTitle: "Cookie preferences",
64 + essential: "Essential",
65 + essentialDesc: "Required for operation (security, session). Always on.",
66 + prefs: "Preferences",
67 + prefsDesc: "Remembers your display language (FR/EN) on this device for 12 months.",
68 + analytics: "Analytics",
69 + analyticsDesc: "No analytics tool is currently used. This setting will apply if a privacy-respecting tool is ever added.",
70 + always: "Always on",
71 + save: "Save my choices",
72 + more: "Privacy policy",
73 + },
74 +};
75 +
76 +function Toggle({
77 + on,
78 + disabled,
79 + onChange,
80 +}: {
81 + on: boolean;
82 + disabled?: boolean;
83 + onChange?: (v: boolean) => void;
84 +}) {
85 + return (
86 + <button
87 + type="button"
88 + role="switch"
89 + aria-checked={on}
90 + disabled={disabled}
91 + onClick={() => onChange?.(!on)}
92 + className={`relative h-7 w-12 shrink-0 rounded-full border-[1.5px] border-ink transition-colors ${
93 + on ? "bg-lime" : "bg-surface-2"
94 + } ${disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"}`}
95 + >
96 + <span
97 + className={`absolute top-[2.5px] h-[18px] w-[18px] rounded-full bg-ink transition-all ${
98 + on ? "left-[26px]" : "left-[3px]"
99 + }`}
100 + />
101 + </button>
102 + );
103 +}
104 +
105 +export default function CookieConsent() {
106 + const { lang } = useLang();
107 + const t = TXT[lang];
108 + const [ready, setReady] = useState(false);
109 + const [consent, setConsent] = useState<ConsentState | null>(null);
110 + const [panel, setPanel] = useState(false);
111 + const [prefs, setPrefs] = useState(true);
112 + const [analytics, setAnalytics] = useState(false);
113 +
114 + useEffect(() => {
115 + const id = setTimeout(() => {
116 + const c = readConsent();
117 + setConsent(c);
118 + if (c) {
119 + setPrefs(c.preferences);
120 + setAnalytics(c.analytics);
121 + }
122 + setReady(true);
123 + }, 0);
124 + const open = () => setPanel(true);
125 + window.addEventListener("vp-open-cookie-prefs", open);
126 + return () => {
127 + clearTimeout(id);
128 + window.removeEventListener("vp-open-cookie-prefs", open);
129 + };
130 + }, []);
131 +
132 + const persist = (preferences: boolean, analyticsOn: boolean) => {
133 + const c: ConsentState = {
134 + essential: true,
135 + preferences,
136 + analytics: analyticsOn,
137 + savedAt: new Date().toISOString(),
138 + };
139 + window.localStorage.setItem(KEY, JSON.stringify(c));
140 + if (!preferences) window.localStorage.removeItem("vp-lang");
141 + setConsent(c);
142 + setPanel(false);
143 + };
144 +
145 + if (!ready) return null;
146 + const showBanner = consent === null && !panel;
147 +
148 + return (
149 + <>
150 + {showBanner && (
151 + <div className="fixed inset-x-0 bottom-0 z-[1300] p-3 sm:p-5">
152 + <div className="mx-auto max-w-3xl rounded-[10px] border-[1.5px] border-ink bg-ink p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35),6px_6px_0_rgba(20,24,20,0.3)]">
153 + <p className="klabel !text-lime">🍪 {t.prefsTitle}</p>
154 + <p className="mt-2 text-[13.5px] leading-relaxed text-[rgba(245,243,238,0.85)]">
155 + {t.banner}{" "}
156 + <a href="/confidentialite" className="border-b border-lime text-lime">
157 + {t.more}
158 + </a>
159 + </p>
160 + <div className="mt-4 flex flex-wrap gap-2.5">
161 + <button className="btn border-lime bg-lime text-ink" onClick={() => persist(true, true)}>
162 + {t.acceptAll}
163 + </button>
164 + <button
165 + className="btn border-[rgba(245,243,238,0.5)] bg-transparent text-paper hover:bg-[rgba(245,243,238,0.12)]"
166 + onClick={() => persist(false, false)}
167 + >
168 + {t.essentialOnly}
169 + </button>
170 + <button
171 + className="btn border-[rgba(245,243,238,0.5)] bg-transparent text-paper hover:bg-[rgba(245,243,238,0.12)]"
172 + onClick={() => setPanel(true)}
173 + >
174 + {t.customize}
175 + </button>
176 + </div>
177 + </div>
178 + </div>
179 + )}
180 +
181 + {panel && (
182 + <div
183 + className="fixed inset-0 z-[1400] flex items-end justify-center bg-[rgba(16,18,16,0.55)] p-3 backdrop-blur-[2px] sm:items-center"
184 + onClick={() => setPanel(false)}
185 + >
186 + <div
187 + className="w-full max-w-lg rounded-[10px] border-[1.5px] border-ink bg-surface p-6 shadow-[8px_8px_0_rgba(20,24,20,0.3)]"
188 + onClick={(e) => e.stopPropagation()}
189 + role="dialog"
190 + aria-modal="true"
191 + aria-label={t.prefsTitle}
192 + >
193 + <h2 className="vp-display text-[19px] font-bold uppercase tracking-[-0.01em]">
194 + {t.prefsTitle}
195 + </h2>
196 + <div className="mt-4 space-y-3">
197 + {[
198 + { k: t.essential, d: t.essentialDesc, on: true, lock: true, set: undefined },
199 + { k: t.prefs, d: t.prefsDesc, on: prefs, lock: false, set: setPrefs },
200 + { k: t.analytics, d: t.analyticsDesc, on: analytics, lock: false, set: setAnalytics },
201 + ].map((row) => (
202 + <div key={row.k} className="flex items-start justify-between gap-4 rounded-lg border-[1.5px] border-[rgba(20,24,20,0.14)] bg-surface-2 p-4">
203 + <div>
204 + <p className="vp-display text-[14px] font-bold">
205 + {row.k}
206 + {row.lock && (
207 + <span className="vp-mono ml-2 text-[9px] font-bold uppercase tracking-[0.08em] text-green-deep">
208 + {t.always}
209 + </span>
210 + )}
211 + </p>
212 + <p className="mt-1 text-[12.5px] text-ink-2">{row.d}</p>
213 + </div>
214 + <Toggle on={row.on} disabled={row.lock} onChange={row.set} />
215 + </div>
216 + ))}
217 + </div>
218 + <button className="btn btn-primary mt-5 w-full" onClick={() => persist(prefs, analytics)}>
219 + {t.save}
220 + </button>
221 + </div>
222 + </div>
223 + )}
224 + </>
225 + );
226 +}
added app/src/components/EstimateForm.tsx +95 −0
@@ -0,0 +1,95 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useState } from "react";
4 +import { useRouter } from "next/navigation";
5 +import { useLang } from "./LangContext";
6 +
7 +export default function EstimateForm() {
8 + const { t } = useLang();
9 + const router = useRouter();
10 + const [municipality, setMunicipality] = useState("");
11 + const [portes, setPortes] = useState("3");
12 + const [floorArea, setFloorArea] = useState("");
13 + const [yearBuilt, setYearBuilt] = useState("");
14 + const [landArea, setLandArea] = useState("");
15 +
16 + const submit = (e: React.FormEvent) => {
17 + e.preventDefault();
18 + const p = new URLSearchParams({ municipality, typeProp: "plex", portes });
19 + if (floorArea) p.set("floorArea", floorArea);
20 + if (yearBuilt) p.set("yearBuilt", yearBuilt);
21 + if (landArea) p.set("landArea", landArea);
22 + router.push(`/estimation/manuelle?${p.toString()}`);
23 + };
24 +
25 + return (
26 + <form
27 + onSubmit={submit}
28 + className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4"
29 + >
30 + <label className="col-span-2 flex flex-col gap-1.5 sm:col-span-1">
31 + <span className="klabel pl-0.5">{t("municipality")} *</span>
32 + <input
33 + required
34 + value={municipality}
35 + onChange={(e) => setMunicipality(e.target.value)}
36 + className="vp-input"
37 + placeholder="Montréal, Québec, Trois-Rivières…"
38 + />
39 + </label>
40 + <label className="flex flex-col gap-1.5">
41 + <span className="klabel pl-0.5">{t("portes")} *</span>
42 + <input
43 + type="number"
44 + required
45 + min={2}
46 + max={40}
47 + value={portes}
48 + onChange={(e) => setPortes(e.target.value)}
49 + className="vp-input"
50 + placeholder="3"
51 + />
52 + </label>
53 + <label className="flex flex-col gap-1.5">
54 + <span className="klabel pl-0.5">{t("floorArea")}</span>
55 + <input
56 + type="number"
57 + min={40}
58 + max={5000}
59 + value={floorArea}
60 + onChange={(e) => setFloorArea(e.target.value)}
61 + className="vp-input"
62 + placeholder="240"
63 + />
64 + </label>
65 + <label className="flex flex-col gap-1.5">
66 + <span className="klabel pl-0.5">{t("yearBuilt")}</span>
67 + <input
68 + type="number"
69 + min={1700}
70 + max={2026}
71 + value={yearBuilt}
72 + onChange={(e) => setYearBuilt(e.target.value)}
73 + className="vp-input"
74 + placeholder="1955"
75 + />
76 + </label>
77 + <label className="flex flex-col gap-1.5">
78 + <span className="klabel pl-0.5">{t("landArea")}</span>
79 + <input
80 + type="number"
81 + min={0}
82 + value={landArea}
83 + onChange={(e) => setLandArea(e.target.value)}
84 + className="vp-input"
85 + placeholder="350"
86 + />
87 + </label>
88 + <div className="flex items-end">
89 + <button type="submit" className="btn btn-primary w-full">
90 + {t("estimateBtn")} →
91 + </button>
92 + </div>
93 + </form>
94 + );
95 +}
added app/src/components/FooterNav.tsx +34 −0
@@ -0,0 +1,34 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import Link from "next/link";
4 +import { useLang } from "./LangContext";
5 +import { openCookiePrefs } from "./CookieConsent";
6 +
7 +export default function FooterNav() {
8 + const { lang } = useLang();
9 + const fr = lang === "fr";
10 + const cls =
11 + "vp-mono text-[11px] uppercase tracking-[0.08em] text-[rgba(245,243,238,0.65)] border-b border-transparent hover:text-lime hover:border-lime transition-colors";
12 + return (
13 + <nav className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-2">
14 + <Link href="/parc" className={cls}>
15 + {fr ? "Parc immobilier" : "Portfolio"}
16 + </Link>
17 + <Link href="/stats" className={cls}>
18 + {fr ? "Statistiques" : "Statistics"}
19 + </Link>
20 + <Link href="/methodologie" className={cls}>
21 + {fr ? "Méthodologie" : "Methodology"}
22 + </Link>
23 + <Link href="/conditions" className={cls}>
24 + {fr ? "Conditions d'utilisation" : "Terms of service"}
25 + </Link>
26 + <Link href="/confidentialite" className={cls}>
27 + {fr ? "Politique de confidentialité" : "Privacy policy"}
28 + </Link>
29 + <button onClick={openCookiePrefs} className={`${cls} cursor-pointer`}>
30 + 🍪 {fr ? "Préférences de témoins" : "Cookie preferences"}
31 + </button>
32 + </nav>
33 + );
34 +}
added app/src/components/HeaderNav.tsx +153 −0
@@ -0,0 +1,153 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Navigation d'en-tête. Le menu mobile utilise le motif checkbox + peer-checked :
4 + * il s'ouvre et se ferme en PUR CSS — fonctionne même sans JavaScript (cache
5 + * périmé, hydratation cassée, JS bloqué). Le JS n'est qu'une amélioration
6 + * (fermeture au changement de page, verrou de défilement).
7 + */
8 +"use client";
9 +import { useEffect, useRef } from "react";
10 +import Link from "next/link";
11 +import { usePathname } from "next/navigation";
12 +import { useLang } from "./LangContext";
13 +
14 +export default function HeaderNav() {
15 + const { lang, setLang } = useLang();
16 + const path = usePathname();
17 + const fr = lang === "fr";
18 + const box = useRef<HTMLInputElement>(null);
19 +
20 + // améliorations JS (facultatives) : fermer à la navigation, verrouiller le scroll
21 + useEffect(() => {
22 + if (box.current) box.current.checked = false;
23 + document.body.style.overflow = "";
24 + }, [path]);
25 + const onToggle = () => {
26 + document.body.style.overflow = box.current?.checked ? "hidden" : "";
27 + };
28 +
29 + const items = [
30 + { href: "/", label: fr ? "Estimer" : "Estimate" },
31 + { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
32 + { href: "/stats", label: fr ? "Statistiques" : "Statistics" },
33 + { href: "/methodologie", label: fr ? "Méthodologie" : "Methodology" },
34 + ];
35 + const isActive = (href: string) =>
36 + href === "/" ? path === "/" : path.startsWith(href);
37 +
38 + return (
39 + <>
40 + {/* nav pilule — desktop */}
41 + <nav className="hidden items-center gap-1 md:flex">
42 + {items.map((it) => (
43 + <Link
44 + key={it.href}
45 + href={it.href}
46 + className={`vp-display inline-flex min-h-[38px] items-center rounded-full border-[1.5px] px-3.5 py-1.5 text-[13.5px] font-bold transition-all ${
47 + isActive(it.href)
48 + ? "border-ink bg-ink text-lime"
49 + : "border-transparent text-ink-2 hover:border-ink hover:text-ink"
50 + }`}
51 + >
52 + {it.label}
53 + </Link>
54 + ))}
55 + </nav>
56 +
57 + {/* ---- menu mobile : checkbox cachée + peer-checked, zéro JS requis ---- */}
58 + <div className="md:hidden">
59 + <input
60 + ref={box}
61 + type="checkbox"
62 + id="vp-menu"
63 + className="peer sr-only"
64 + onChange={onToggle}
65 + aria-label="Menu"
66 + />
67 + {/* hamburger (label = bouton) */}
68 + <label
69 + htmlFor="vp-menu"
70 + aria-hidden="true"
71 + className="flex h-11 w-11 cursor-pointer flex-col items-center justify-center gap-[5px] rounded-full border-[1.5px] border-ink bg-surface active:bg-lime-soft"
72 + >
73 + <span className="h-[2px] w-[18px] bg-ink" />
74 + <span className="h-[2px] w-[18px] bg-ink" />
75 + <span className="h-[2px] w-[18px] bg-ink" />
76 + </label>
77 +
78 + {/* panneau plein écran — sibling du checkbox, affiché via peer-checked */}
79 + <div
80 + className="fixed inset-0 z-[3000] hidden flex-col overflow-y-auto peer-checked:flex"
81 + style={{ background: "var(--paper)" }}
82 + role="dialog"
83 + aria-modal="true"
84 + aria-label="Menu"
85 + >
86 + {/* barre du haut : marque + fermer */}
87 + <div
88 + className="flex h-16 shrink-0 items-center justify-between border-b-2 border-ink px-4"
89 + style={{ background: "var(--paper)" }}
90 + >
91 + <Link
92 + href="/"
93 + className="vp-display flex items-center text-[22px] font-bold leading-none tracking-[-0.04em]"
94 + >
95 + Valo
96 + <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime">
97 + Plex
98 + </span>
99 + </Link>
100 + <label
101 + htmlFor="vp-menu"
102 + aria-label={fr ? "Fermer le menu" : "Close menu"}
103 + className="vp-display flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[17px] font-bold text-lime"
104 + >
105 +
106 + </label>
107 + </div>
108 +
109 + {/* liens géants */}
110 + <nav className="flex flex-1 flex-col justify-center px-6 py-8">
111 + {items.map((it, i) => (
112 + <Link
113 + key={it.href}
114 + href={it.href}
115 + className="group flex items-baseline gap-4 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.18)] py-5 last:border-0"
116 + >
117 + <span className="vp-mono text-[12px] font-bold text-green">
118 + 0{i + 1}
119 + </span>
120 + <span
121 + className={`vp-display text-[29px] font-bold uppercase leading-none tracking-[-0.03em] ${
122 + isActive(it.href)
123 + ? "rounded-md bg-ink px-2.5 py-1 text-lime"
124 + : "text-ink group-active:text-green-deep"
125 + }`}
126 + >
127 + {it.label}
128 + </span>
129 + </Link>
130 + ))}
131 + </nav>
132 +
133 + {/* pied du menu */}
134 + <div className="shrink-0 border-t-2 border-ink px-6 pb-[max(20px,env(safe-area-inset-bottom))] pt-4">
135 + <button
136 + onClick={() => setLang(fr ? "en" : "fr")}
137 + className="vp-mono mb-3 rounded-full border-[1.5px] border-ink bg-surface px-4 py-2 text-[12px] font-bold uppercase tracking-[0.08em]"
138 + >
139 + {fr ? "English version" : "Version française"}
140 + </button>
141 + <div className="vp-mono flex flex-wrap gap-x-5 gap-y-1.5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
142 + <Link href="/conditions">{fr ? "Conditions" : "Terms"}</Link>
143 + <Link href="/confidentialite">
144 + {fr ? "Confidentialité" : "Privacy"}
145 + </Link>
146 + <span>contact@spboucher.ai</span>
147 + </div>
148 + </div>
149 + </div>
150 + </div>
151 + </>
152 + );
153 +}
added app/src/components/LangContext.tsx +46 −0
@@ -0,0 +1,46 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { createContext, useContext, useEffect, useState } from "react";
4 +import { t as translate, type Lang, type TKey } from "@/lib/i18n";
5 +
6 +const Ctx = createContext<{
7 + lang: Lang;
8 + setLang: (l: Lang) => void;
9 + t: (k: TKey) => string;
10 +}>({ lang: "fr", setLang: () => {}, t: (k) => translate("fr", k) });
11 +
12 +export function LangProvider({ children }: { children: React.ReactNode }) {
13 + const [lang, setLangState] = useState<Lang>("fr");
14 + useEffect(() => {
15 + const saved = window.localStorage.getItem("vp-lang");
16 + if (saved !== "en" && saved !== "fr") return;
17 + const id = setTimeout(() => setLangState(saved), 0);
18 + return () => clearTimeout(id);
19 + }, []);
20 + const setLang = (l: Lang) => {
21 + setLangState(l);
22 + window.localStorage.setItem("vp-lang", l);
23 + };
24 + return (
25 + <Ctx.Provider value={{ lang, setLang, t: (k) => translate(lang, k) }}>
26 + {children}
27 + </Ctx.Provider>
28 + );
29 +}
30 +
31 +export function useLang() {
32 + return useContext(Ctx);
33 +}
34 +
35 +export function LangToggle() {
36 + const { lang, setLang } = useLang();
37 + return (
38 + <button
39 + onClick={() => setLang(lang === "fr" ? "en" : "fr")}
40 + className="vp-mono min-h-[36px] rounded-full border-[1.5px] border-ink bg-surface px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-ink transition-all hover:bg-ink hover:text-lime"
41 + aria-label="Changer de langue / Switch language"
42 + >
43 + {lang === "fr" ? "EN" : "FR"}
44 + </button>
45 + );
46 +}
added app/src/components/LeadForm.tsx +56 −0
@@ -0,0 +1,56 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useState } from "react";
4 +import { useLang } from "./LangContext";
5 +
6 +export default function LeadForm({
7 + unitId,
8 + estimate,
9 +}: {
10 + unitId: string | null;
11 + estimate: number;
12 +}) {
13 + const { t } = useLang();
14 + const [email, setEmail] = useState("");
15 + const [done, setDone] = useState(false);
16 + const [err, setErr] = useState(false);
17 +
18 + const submit = async (e: React.FormEvent) => {
19 + e.preventDefault();
20 + setErr(false);
21 + const res = await fetch("/api/lead", {
22 + method: "POST",
23 + headers: { "Content-Type": "application/json" },
24 + body: JSON.stringify({ email, unitId, estimate }),
25 + });
26 + if (res.ok) setDone(true);
27 + else setErr(true);
28 + };
29 +
30 + if (done)
31 + return (
32 + <p className="vp-mono rounded-md border border-lime bg-[rgba(255,159,69,0.12)] px-4 py-3 text-[12px] font-bold uppercase tracking-[0.05em] text-lime">
33 + ✓ {t("leadOk")}
34 + </p>
35 + );
36 + return (
37 + <form onSubmit={submit} className="flex flex-col gap-2.5 sm:flex-row">
38 + <input
39 + type="email"
40 + required
41 + value={email}
42 + onChange={(e) => setEmail(e.target.value)}
43 + placeholder={t("emailPlaceholder")}
44 + className={`min-h-[44px] flex-1 rounded-md border-[1.5px] bg-[rgba(245,243,238,0.08)] px-3.5 py-2.5 text-paper placeholder-[rgba(245,243,238,0.4)] outline-none transition-shadow focus:shadow-[3px_3px_0_var(--lime)] ${
45 + err ? "border-danger" : "border-[rgba(245,243,238,0.35)] focus:border-lime"
46 + }`}
47 + />
48 + <button
49 + type="submit"
50 + className="btn min-h-[44px] border-lime bg-lime text-ink shadow-[4px_4px_0_rgba(255,159,69,0.25)] hover:bg-paper"
51 + >
52 + {t("leadBtn")} →
53 + </button>
54 + </form>
55 + );
56 +}
added app/src/components/LegalPage.tsx +78 −0
@@ -0,0 +1,78 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import Link from "next/link";
4 +import { useLang } from "./LangContext";
5 +
6 +export interface LegalSection {
7 + title: string;
8 + paras: string[];
9 +}
10 +
11 +export default function LegalPage({
12 + crumb,
13 + kicker,
14 + title,
15 + updated,
16 + sections,
17 + children,
18 +}: {
19 + crumb: { fr: string; en: string };
20 + kicker: { fr: string; en: string };
21 + title: { fr: string; en: string };
22 + updated: string;
23 + sections: { fr: LegalSection[]; en: LegalSection[] };
24 + children?: React.ReactNode;
25 +}) {
26 + const { lang } = useLang();
27 + const fr = lang === "fr";
28 + const list = fr ? sections.fr : sections.en;
29 + return (
30 + <div className="py-10">
31 + <nav className="vp-mono mb-8 flex items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">
32 + <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">
33 + ValoPlex
34 + </Link>
35 + <span>/</span>
36 + <span>{fr ? crumb.fr : crumb.en}</span>
37 + </nav>
38 + <span className="kicker">{fr ? kicker.fr : kicker.en}</span>
39 + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(28px,4.6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
40 + {fr ? title.fr : title.en}
41 + </h1>
42 + <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.08em] text-ink-3">
43 + {fr ? "Dernière mise à jour" : "Last updated"} : {updated}
44 + </p>
45 + <div className="mt-8 space-y-6">
46 + {list.map((s, i) => (
47 + <section key={s.title} className="vp-card p-6 sm:p-7">
48 + <div className="flex items-baseline gap-4">
49 + <span className="vp-mono text-[12px] font-bold text-green">
50 + {String(i + 1).padStart(2, "0")}
51 + </span>
52 + <h2 className="vp-display text-[17px] font-bold uppercase tracking-[-0.01em]">
53 + {s.title}
54 + </h2>
55 + </div>
56 + <div className="mt-3 space-y-3">
57 + {s.paras.map((p, j) => (
58 + <p key={j} className="max-w-3xl text-[14px] leading-relaxed text-ink-2">
59 + {p}
60 + </p>
61 + ))}
62 + </div>
63 + </section>
64 + ))}
65 + </div>
66 + {children}
67 + <div className="vp-card mt-6 border-ink bg-ink p-6 text-paper">
68 + <p className="klabel !text-lime">
69 + {fr ? "Responsable et contact" : "Controller and contact"}
70 + </p>
71 + <p className="vp-display mt-2 text-[18px] font-bold">Simon-Pierre Boucher</p>
72 + <p className="vp-mono mt-1 text-[12px] tracking-[0.04em] text-[rgba(245,243,238,0.8)]">
73 + contact@spboucher.ai · www.valoplex.com
74 + </p>
75 + </div>
76 + </div>
77 + );
78 +}
added app/src/components/MetricViz.tsx +470 −0
@@ -0,0 +1,470 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * MetricViz — visualisations signature générées par les données du registre.
4 + * Chaque propriété produit ses propres dessins : façade SVG (étages, logements,
5 + * genre de construction), schéma de terrain à l'échelle (frontage × profondeur),
6 + * composition de valeur terrain/bâtiment, jauge de confiance, sparkline 2021-2026,
7 + * compteurs animés. SVG pur, palette encre/lime, aucun aléatoire : mêmes données,
8 + * même dessin.
9 + */
10 +"use client";
11 +import { useEffect, useState } from "react";
12 +
13 +/** Nombre localisé (« 1 206,6 » en FR, « 1,206.6 » en EN). */
14 +export function locNum(
15 + v: number | null | undefined,
16 + lang: string,
17 + opts?: { unit?: string; maxFrac?: number }
18 +): string {
19 + if (v == null) return "—";
20 + const s = v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", {
21 + maximumFractionDigits: opts?.maxFrac ?? 1,
22 + });
23 + return opts?.unit ? `${s} ${opts.unit}` : s;
24 +}
25 +
26 +/* ---------------------------- compteur animé ---------------------------- */
27 +/** Rendu serveur = valeur finale (jamais « 0 $ » à l'écran) ; anime 0 → valeur au montage. */
28 +export function CountUp({
29 + value,
30 + format,
31 + duration = 900,
32 +}: {
33 + value: number;
34 + format: (v: number) => string;
35 + duration?: number;
36 +}) {
37 + const [display, setDisplay] = useState(value);
38 +
39 + useEffect(() => {
40 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
41 + let raf = 0;
42 + const t0 = performance.now();
43 + const tick = (t: number) => {
44 + const p = Math.min(1, (t - t0) / duration);
45 + const eased = 1 - Math.pow(1 - p, 3);
46 + setDisplay(value * eased);
47 + if (p < 1) raf = requestAnimationFrame(tick);
48 + };
49 + raf = requestAnimationFrame(tick);
50 + return () => cancelAnimationFrame(raf);
51 + }, [value, duration]);
52 +
53 + return <span>{format(display)}</span>;
54 +}
55 +
56 +/* --------------------------- jauge de confiance --------------------------- */
57 +export function ConfidenceDial({
58 + pct,
59 + level,
60 + label,
61 +}: {
62 + pct: number;
63 + level: "A" | "B" | "C" | "D";
64 + label: string;
65 +}) {
66 + const [mounted, setMounted] = useState(false);
67 + useEffect(() => {
68 + const id = setTimeout(() => setMounted(true), 60);
69 + return () => clearTimeout(id);
70 + }, []);
71 + const R = 52;
72 + const CIRC = Math.PI * R; // demi-cercle
73 + const frac = mounted ? Math.max(0.02, Math.min(1, pct / 100)) : 0.02;
74 + const color =
75 + level === "A" ? "var(--lime)" : level === "B" ? "var(--green)" : level === "C" ? "var(--amber)" : "var(--danger)";
76 + return (
77 + <div className="flex flex-col items-center" aria-label={`${label} : ${level} ${pct} %`}>
78 + <svg viewBox="0 0 128 78" className="w-[128px]">
79 + <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--surface-2)" strokeWidth="12" strokeLinecap="round" />
80 + <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="rgba(20,24,20,0.85)" strokeWidth="13.5" strokeLinecap="round" opacity="0.12" />
81 + <path
82 + d="M 12 66 A 52 52 0 0 1 116 66"
83 + fill="none"
84 + stroke={color}
85 + strokeWidth="12"
86 + strokeLinecap="round"
87 + strokeDasharray={`${CIRC * frac} ${CIRC}`}
88 + style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.8,.2,1)" }}
89 + />
90 + {/* graduations */}
91 + {[0, 0.25, 0.5, 0.75, 1].map((g) => {
92 + const a = Math.PI * (1 - g);
93 + const x1 = 64 + Math.cos(a) * 40;
94 + const y1 = 66 - Math.sin(a) * 40;
95 + const x2 = 64 + Math.cos(a) * 45;
96 + const y2 = 66 - Math.sin(a) * 45;
97 + return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1.4" opacity="0.5" />;
98 + })}
99 + <text x="64" y="52" textAnchor="middle" fontSize="26" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)">
100 + {level}
101 + </text>
102 + <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="700" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)">
103 + {pct} %
104 + </text>
105 + </svg>
106 + <p className="klabel mt-1 text-center">{label}</p>
107 + </div>
108 + );
109 +}
110 +
111 +/* --------------------------- façade de bâtiment --------------------------- */
112 +/** Terrain sans bâtiment : borne d'arpenteur et conifères, pas de fausse maison. */
113 +function VacantGlyph({ lang }: { lang: string }) {
114 + const tree = (x: number, sc: number) => (
115 + <g transform={`translate(${x}, 128) scale(${sc})`}>
116 + <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
117 + <rect x="-2.5" y="8" width="5" height="9" fill="var(--ink)" />
118 + </g>
119 + );
120 + return (
121 + <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain sans bâtiment" : "Vacant land"}>
122 + {tree(52, 1)}
123 + {tree(96, 0.72)}
124 + {tree(134, 0.9)}
125 + {/* borne d'arpenteur */}
126 + <g transform="translate(24, 128)">
127 + <rect x="-2.5" y="-26" width="5" height="26" fill="var(--ink)" />
128 + <rect x="-8" y="-34" width="16" height="10" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" />
129 + </g>
130 + <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="2.2" />
131 + <text x="95" y="120" textAnchor="middle" fontSize="9" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">
132 + {lang === "fr" ? "SANS BÂTIMENT" : "NO BUILDING"}
133 + </text>
134 + </svg>
135 + );
136 +}
137 +
138 +/** Façade dessinée à partir du registre : étages, logements, genre de construction.
139 + * Sans bâtiment au registre (ni aire, ni année, ni étages) → borne d'arpenteur. */
140 +export function BuildingGlyph({
141 + floors,
142 + dwellings,
143 + genre,
144 + yearBuilt,
145 + floorAreaM2,
146 + lang = "fr",
147 +}: {
148 + floors: number | null;
149 + dwellings: number | null;
150 + genre: string | null;
151 + yearBuilt: number | null;
152 + floorAreaM2?: number | null;
153 + lang?: string;
154 +}) {
155 + if (!floorAreaM2 && !yearBuilt && !floors && !dwellings) {
156 + return <VacantGlyph lang={lang} />;
157 + }
158 + const nF = Math.max(1, Math.min(6, Math.round(floors ?? 1)));
159 + const nD = Math.max(1, Math.min(24, Math.round(dwellings ?? 1)));
160 + const winPerFloor = Math.max(1, Math.min(4, Math.ceil(nD / nF)));
161 + const W = 190;
162 + const bw = 64 + winPerFloor * 18;
163 + const bx = (W - bw) / 2;
164 + const fh = 24;
165 + const groundY = 128;
166 + const bodyTop = groundY - nF * fh;
167 + const mansard = (genre ?? "").toLowerCase().includes("mansard");
168 + const flat = (genre ?? "").toLowerCase().includes("plain-pied") && nF === 1;
169 +
170 + const windows: React.ReactNode[] = [];
171 + for (let f = 0; f < nF; f++) {
172 + for (let wi = 0; wi < winPerFloor; wi++) {
173 + const idx = f * winPerFloor + wi;
174 + windows.push(
175 + <rect
176 + key={`${f}-${wi}`}
177 + x={bx + 14 + wi * ((bw - 28) / winPerFloor) + ((bw - 28) / winPerFloor - 12) / 2}
178 + y={bodyTop + f * fh + 6}
179 + width="12"
180 + height="11"
181 + fill={idx < nD ? "var(--lime)" : "var(--surface-2)"}
182 + stroke="var(--ink)"
183 + strokeWidth="1.2"
184 + className="vv-win"
185 + style={{ animationDelay: `${idx * 60}ms` }}
186 + />
187 + );
188 + }
189 + }
190 +
191 + return (
192 + <svg viewBox={`0 0 ${W} 150`} className="w-full max-w-[210px]" aria-label="Silhouette du bâtiment selon le registre">
193 + {/* soleil-repère */}
194 + <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.5" />
195 + <circle cx={W - 22} cy="22" r="3.5" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1" />
196 + {/* ombre décalée signature */}
197 + <rect x={bx + 5} y={bodyTop + 5} width={bw} height={nF * fh} fill="rgba(20,24,20,0.12)" />
198 + {/* corps */}
199 + <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="2" />
200 + {/* séparations d'étages */}
201 + {Array.from({ length: nF - 1 }, (_, i) => (
202 + <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="1" strokeDasharray="3 3" opacity="0.4" />
203 + ))}
204 + {/* toit */}
205 + {flat ? (
206 + <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
207 + ) : mansard ? (
208 + <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
209 + ) : (
210 + <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
211 + )}
212 + {/* cheminée */}
213 + <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="9" height={flat ? 14 : 18} fill="var(--ink)" />
214 + {windows}
215 + {/* porte */}
216 + <rect x={bx + bw / 2 - 7} y={groundY - 17} width="14" height="17" fill="var(--ink)" />
217 + <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--lime)" />
218 + {/* sol */}
219 + <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="2.2" />
220 + <line x1="8" y1={groundY + 4} x2={W - 8} y2={groundY + 4} stroke="var(--ink)" strokeWidth="1" opacity="0.3" />
221 + {/* millésime gravé */}
222 + {yearBuilt && (
223 + <text x={W / 2} y="146" textAnchor="middle" fontSize="10" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">
224 + ANNO {yearBuilt}
225 + </text>
226 + )}
227 + </svg>
228 + );
229 +}
230 +
231 +/* ----------------------------- schéma du terrain ----------------------------- */
232 +/** Terrain à l'échelle réelle : frontage × profondeur déduite de la superficie. */
233 +export function LotDiagram({
234 + areaM2,
235 + frontageM,
236 + footprintM2,
237 + lang,
238 + isCondo,
239 +}: {
240 + areaM2: number | null;
241 + frontageM: number | null;
242 + footprintM2: number | null;
243 + lang: string;
244 + isCondo?: boolean;
245 +}) {
246 + if (!areaM2 || areaM2 <= 0) {
247 + return (
248 + <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain non applicable" : "Lot not applicable"}>
249 + <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" strokeDasharray="5 5" rx="6" />
250 + <text x="95" y="66" textAnchor="middle" fontSize="20" fill="var(--ink-3)">⌂</text>
251 + <text x="95" y="90" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="1">
252 + {isCondo
253 + ? lang === "fr" ? "QUOTE-PART DE COPROPRIÉTÉ" : "CONDOMINIUM SHARE"
254 + : lang === "fr" ? "TERRAIN NON INSCRIT AU RÔLE" : "LOT NOT ON ROLL"}
255 + </text>
256 + <text x="95" y="104" textAnchor="middle" fontSize="7" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
257 + {isCondo
258 + ? lang === "fr" ? "terrain commun indivis" : "undivided common land"
259 + : "—"}
260 + </text>
261 + </svg>
262 + );
263 + }
264 + const front = frontageM && frontageM > 0 ? frontageM : Math.sqrt(areaM2);
265 + const depth = areaM2 / front;
266 + const ratio = Math.max(0.25, Math.min(4, depth / front));
267 + const maxW = 150;
268 + const maxH = 108;
269 + let w = maxW;
270 + let h = w * ratio;
271 + if (h > maxH) {
272 + h = maxH;
273 + w = h / ratio;
274 + }
275 + const x = (190 - w) / 2;
276 + const y = 118 - h;
277 + // empreinte du bâtiment, à l'échelle de la superficie
278 + const fpFrac = footprintM2 ? Math.min(0.8, footprintM2 / areaM2) : 0;
279 + const fw = w * Math.sqrt(fpFrac) * 0.9;
280 + const fh = h * Math.sqrt(fpFrac) * 0.9;
281 + const fmtM = (v: number) => `${v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} m`;
282 +
283 + return (
284 + <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label="Schéma du terrain à l'échelle">
285 + <defs>
286 + <pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
287 + <line x1="0" y1="0" x2="0" y2="7" stroke="var(--green)" strokeWidth="1" opacity="0.35" />
288 + </pattern>
289 + </defs>
290 + {/* ombre + parcelle */}
291 + <rect x={x + 4} y={y + 4} width={w} height={h} fill="rgba(20,24,20,0.12)" />
292 + <rect x={x} y={y} width={w} height={h} fill="var(--lime-soft)" stroke="var(--ink)" strokeWidth="2" />
293 + <rect x={x} y={y} width={w} height={h} fill="url(#hatch)" />
294 + {/* empreinte bâtiment */}
295 + {fpFrac > 0.005 && (
296 + <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)" rx="2">
297 + <title>{lang === "fr" ? "Empreinte approximative du bâtiment" : "Approximate building footprint"}</title>
298 + </rect>
299 + )}
300 + {/* cotes : frontage */}
301 + <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1.3" />
302 + <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />
303 + <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />
304 + <text x={x + w / 2} y={y + h + 24} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">
305 + {frontageM ? fmtM(front) : `≈ ${fmtM(front)}`}
306 + </text>
307 + {/* cotes : profondeur */}
308 + <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />
309 + <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1.3" />
310 + <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />
311 + <text x={x - 16} y={y + h / 2} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)" transform={`rotate(-90 ${x - 16} ${y + h / 2})`}>
312 + ≈ {fmtM(depth)}
313 + </text>
314 + {/* rue */}
315 + <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="2.2" />
316 + <text x="95" y="147" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)" letterSpacing="3">
317 + {lang === "fr" ? "RUE" : "STREET"}
318 + </text>
319 + </svg>
320 + );
321 +}
322 +
323 +/* ------------------------- composition de la valeur ------------------------- */
324 +export function ValueSplit({
325 + land,
326 + building,
327 + previous,
328 + total,
329 + lang,
330 + labels,
331 +}: {
332 + land: number | null;
333 + building: number | null;
334 + previous: number | null;
335 + total: number | null;
336 + lang: string;
337 + labels: { land: string; building: string; previous: string };
338 +}) {
339 + const [on, setOn] = useState(false);
340 + useEffect(() => {
341 + const id = setTimeout(() => setOn(true), 80);
342 + return () => clearTimeout(id);
343 + }, []);
344 + const l = land ?? 0;
345 + const b = building ?? 0;
346 + const sum = l + b;
347 + if (sum <= 0) return null;
348 + const lPct = (l / sum) * 100;
349 + const fmt = (v: number) =>
350 + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
351 + const delta = previous && previous > 0 && total ? ((total / previous - 1) * 100) : null;
352 + return (
353 + <div>
354 + <div className="flex h-9 w-full overflow-hidden rounded-md border-[1.5px] border-ink">
355 + {lPct >= 1 && (
356 + <div
357 + className="flex items-center justify-center overflow-hidden bg-green transition-[width] duration-1000 ease-out"
358 + style={{ width: on ? `${lPct}%` : "2%" }}
359 + >
360 + {lPct >= 14 && (
361 + <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(lPct)} %</span>
362 + )}
363 + </div>
364 + )}
365 + {lPct <= 99 && (
366 + <div className="flex flex-1 items-center justify-center overflow-hidden bg-ink">
367 + {100 - lPct >= 14 && (
368 + <span className="vp-mono px-1 text-[10px] font-bold text-lime">{Math.round(100 - lPct)} %</span>
369 + )}
370 + </div>
371 + )}
372 + </div>
373 + <div className="vp-mono mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10.5px] uppercase tracking-[0.05em]">
374 + <span className="flex items-center gap-1.5 text-ink-2">
375 + <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-green" />
376 + {labels.land} {fmt(l)}
377 + </span>
378 + <span className="flex items-center gap-1.5 text-ink-2">
379 + <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-ink" />
380 + {labels.building} {fmt(b)}
381 + </span>
382 + </div>
383 + {delta != null && (
384 + <p className="vp-mono mt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
385 + {labels.previous} {fmt(previous!)}{" "}
386 + <span className={`ml-1 rounded-[4px] border border-ink px-1.5 py-0.5 font-bold ${delta >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
387 + {delta >= 0 ? "+" : ""}
388 + {delta.toFixed(1)} %
389 + </span>
390 + </p>
391 + )}
392 + </div>
393 + );
394 +}
395 +
396 +/* -------------------------------- sparkline -------------------------------- */
397 +export function Sparkline({
398 + history,
399 + height = 44,
400 +}: {
401 + history: { year: number; value: number | null }[];
402 + height?: number;
403 +}) {
404 + const pts = history.filter((h) => h.value != null) as { year: number; value: number }[];
405 + if (pts.length < 2) return null;
406 + const W = 132;
407 + const min = Math.min(...pts.map((p) => p.value));
408 + const max = Math.max(...pts.map((p) => p.value));
409 + const span = max - min || 1;
410 + const xy = pts.map((p, i) => [
411 + 6 + (i / (pts.length - 1)) * (W - 12),
412 + height - 8 - ((p.value - min) / span) * (height - 18),
413 + ]);
414 + const line = xy.map(([x, y]) => `${x},${y}`).join(" ");
415 + const area = `${xy[0][0]},${height - 4} ${line} ${xy[xy.length - 1][0]},${height - 4}`;
416 + const growth = ((pts[pts.length - 1].value / pts[0].value - 1) * 100);
417 + const [x2, y2] = xy[xy.length - 1];
418 + return (
419 + <div className="flex items-end gap-2">
420 + <svg viewBox={`0 0 ${W} ${height}`} width={W} height={height} aria-label={`Évolution ${pts[0].year}-${pts[pts.length - 1].year}`}>
421 + <polygon points={area} fill="var(--lime-soft)" />
422 + <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
423 + {xy.slice(0, -1).map(([x, y], i) => (
424 + <circle key={i} cx={x} cy={y} r="2" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.1" />
425 + ))}
426 + <circle cx={x2} cy={y2} r="3.6" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.5" />
427 + </svg>
428 + <span className={`vp-mono mb-0.5 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${growth >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
429 + {growth >= 0 ? "+" : ""}
430 + {growth.toFixed(0)} %
431 + </span>
432 + </div>
433 + );
434 +}
435 +
436 +/* ------------------------------ frise temporelle ------------------------------ */
437 +export function EraLine({ year, lang }: { year: number | null; lang: string }) {
438 + if (!year || year < 1750) return null;
439 + const min = Math.min(1900, Math.floor((year - 10) / 50) * 50);
440 + const max = 2026;
441 + const frac = Math.max(0, Math.min(1, (year - min) / (max - min)));
442 + const W = 190;
443 + const x = 12 + frac * (W - 24);
444 + const age = 2026 - year;
445 + return (
446 + <svg viewBox={`0 0 ${W} 40`} className="w-full max-w-[210px]" aria-label={`Construction ${year}`}>
447 + <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.6" />
448 + {[...new Set([min, Math.round((min + max) / 2 / 50) * 50, 2000])].map((yr) => {
449 + const gx = 12 + ((yr - min) / (max - min)) * (W - 24);
450 + return (
451 + <g key={yr}>
452 + <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1.2" opacity="0.5" />
453 + <text x={gx} y="38" textAnchor="middle" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
454 + {yr}
455 + </text>
456 + </g>
457 + );
458 + })}
459 + <g transform={`translate(${x}, 24)`}>
460 + <rect x="-7" y="-7" width="14" height="14" transform="rotate(45)" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.6" />
461 + <text y="-13" textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">
462 + {year}
463 + </text>
464 + </g>
465 + <text x={W - 12} y="12" textAnchor="end" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
466 + {age} {lang === "fr" ? "ANS" : "YRS"}
467 + </text>
468 + </svg>
469 + );
470 +}
added app/src/components/Proforma.tsx +212 −0
@@ -0,0 +1,212 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Pro forma interactif de l'investisseur — « avec les frais et tout le kit ».
4 + * Loyer moyen implicite qui justifie la valeur, cascade revenus → RNE,
5 + * financement hypothécaire canadien, cashflow par porte, DSCR, droits de
6 + * mutation et liquidités requises. Chaque hypothèse est ajustable en direct.
7 + */
8 +"use client";
9 +import { useState } from "react";
10 +import { buildProforma, DEFAULT_PARAMS, tgaReference } from "@/lib/proforma";
11 +import { useLang } from "./LangContext";
12 +
13 +const fmt = (v: number, lang: string, frac = 0) =>
14 + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
15 + style: "currency",
16 + currency: "CAD",
17 + maximumFractionDigits: frac,
18 + }).format(v);
19 +
20 +function Slider({
21 + label, value, min, max, step, unit, onChange,
22 +}: {
23 + label: string; value: number; min: number; max: number; step: number;
24 + unit: string; onChange: (v: number) => void;
25 +}) {
26 + return (
27 + <label className="flex flex-col gap-1">
28 + <span className="klabel flex items-baseline justify-between">
29 + {label}
30 + <span className="vp-display text-[14px] font-bold normal-case tracking-normal text-ink">
31 + {value.toLocaleString("fr-CA")} {unit}
32 + </span>
33 + </span>
34 + <input
35 + type="range"
36 + min={min}
37 + max={max}
38 + step={step}
39 + value={value}
40 + onChange={(e) => onChange(Number(e.target.value))}
41 + className="h-2 w-full cursor-pointer appearance-none rounded-full border-[1.5px] border-ink bg-surface-2 accent-[var(--green)]"
42 + />
43 + </label>
44 + );
45 +}
46 +
47 +function Row({ k, v, strong, neg }: { k: string; v: string; strong?: boolean; neg?: boolean }) {
48 + return (
49 + <div className={`flex items-baseline justify-between gap-3 py-1.5 ${strong ? "border-t-[1.5px] border-ink" : "border-t border-dashed border-[rgba(20,24,20,0.12)]"}`}>
50 + <span className={strong ? "vp-display text-[13.5px] font-bold uppercase" : "text-[13px] text-ink-2"}>{k}</span>
51 + <span className={`vp-mono text-[13px] font-bold ${neg ? "text-[var(--danger)]" : strong ? "text-ink" : "text-ink-2"}`}>{v}</span>
52 + </div>
53 + );
54 +}
55 +
56 +export default function Proforma({
57 + valeur, doors, valeurRole, municipalite,
58 +}: {
59 + valeur: number; doors: number; valeurRole: number | null; municipalite: string | null;
60 +}) {
61 + const { lang } = useLang();
62 + const fr = lang === "fr";
63 + const [taux, setTaux] = useState(DEFAULT_PARAMS.tauxHypoPct);
64 + const [mise, setMise] = useState(DEFAULT_PARAMS.miseDeFondsPct);
65 + const [amort, setAmort] = useState(DEFAULT_PARAMS.amortAns);
66 + const [tga, setTga] = useState(tgaReference(doors));
67 + const [appr, setAppr] = useState(DEFAULT_PARAMS.appreciationPct);
68 +
69 + const pf = buildProforma(valeur, doors, valeurRole, municipalite, {
70 + tauxHypoPct: taux, miseDeFondsPct: mise, amortAns: amort, tgaPct: tga,
71 + appreciationPct: appr,
72 + });
73 +
74 + const DEP_FR: Record<string, string> = {
75 + taxesMunicipales: fr ? "Taxes municipales (≈1,1 % du rôle)" : "Municipal taxes (≈1.1% of roll)",
76 + taxeScolaire: fr ? "Taxe scolaire (≈0,1 % du rôle)" : "School tax (≈0.1% of roll)",
77 + assurances: fr ? "Assurances" : "Insurance",
78 + entretien: fr ? "Entretien et réparations (5 % RB)" : "Maintenance & repairs (5% GI)",
79 + gestion: fr ? "Gestion (4 % RB)" : "Management (4% GI)",
80 + deneigementPelouse: fr ? "Déneigement et pelouse" : "Snow & lawn",
81 + energieCommuns: fr ? "Énergie des espaces communs" : "Common-area energy",
82 + reserveRemplacement: fr ? "Réserve de remplacement" : "Replacement reserve",
83 + };
84 +
85 + return (
86 + <section>
87 + <span className="kicker">{fr ? "Approche revenu — interactif" : "Income approach — interactive"}</span>
88 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
89 + {fr ? "Pro forma de l'investisseur" : "Investor pro forma"}
90 + </h2>
91 + <p className="mt-2 max-w-2xl text-[13.5px] text-ink-2">
92 + {fr
93 + ? `Quelle structure de revenus la valeur estimée suppose-t-elle ? Au TGA choisi, ce ${doors <= 6 ? "plex" : "immeuble"} doit générer un loyer moyen de `
94 + : `What income structure does the estimated value imply? At the chosen cap rate, this building must generate an average rent of `}
95 + <b className="vp-display text-green-deep">{fmt(pf.loyerMoyenMensuel, lang)}</b>
96 + {fr ? " par porte par mois. Ajustez les hypothèses — tout recalcule." : " per door per month. Adjust the assumptions — everything recomputes."}
97 + </p>
98 +
99 + {/* hypothèses ajustables */}
100 + <div className="vp-card mt-4 grid gap-x-8 gap-y-4 p-5 sm:grid-cols-2 lg:grid-cols-5">
101 + <Slider label={fr ? "Taux hypothécaire (5 ans)" : "Mortgage rate (5 yr)"} value={taux} min={2.5} max={8.5} step={0.05} unit="%" onChange={setTaux} />
102 + <Slider label={fr ? "Mise de fonds" : "Down payment"} value={mise} min={5} max={50} step={1} unit="%" onChange={setMise} />
103 + <Slider label={fr ? "Amortissement" : "Amortization"} value={amort} min={15} max={30} step={1} unit={fr ? "ans" : "yrs"} onChange={setAmort} />
104 + <Slider label={`TGA (${fr ? "réf. gabarit" : "size ref."} ${tgaReference(doors)} %)`} value={tga} min={3.5} max={8} step={0.05} unit="%" onChange={setTga} />
105 + <Slider label={fr ? "Appréciation annuelle" : "Annual appreciation"} value={appr} min={0} max={6} step={0.25} unit="%" onChange={setAppr} />
106 + </div>
107 +
108 + <div className="mt-4 grid items-start gap-4 lg:grid-cols-2">
109 + {/* état des résultats */}
110 + <div className="vp-card p-5 sm:p-6">
111 + <p className="klabel mb-2">{fr ? "État des résultats normalisé (annuel)" : "Normalized operating statement (annual)"}</p>
112 + <Row k={fr ? `Revenus bruts (${doors} portes × ${fmt(pf.loyerMoyenMensuel, lang)}/mois)` : `Gross income (${doors} doors × ${fmt(pf.loyerMoyenMensuel, lang)}/mo)`} v={fmt(pf.revenusBruts, lang)} />
113 + <Row k={fr ? `Vacance et mauvaises créances (${pf.params.vacancePct} %)` : `Vacancy & bad debt (${pf.params.vacancePct}%)`} v={fmt(pf.vacance, lang)} neg />
114 + <Row k={fr ? "Revenus effectifs" : "Effective income"} v={fmt(pf.revenusEffectifs, lang)} strong />
115 + {pf.depenses.map((d) => (
116 + <Row key={d.key} k={DEP_FR[d.key] ?? d.key} v={fmt(d.amount, lang)} neg />
117 + ))}
118 + <Row k={fr ? `Total des dépenses (${pf.ratioDepensesPct.toFixed(0)} % des RE)` : `Total expenses (${pf.ratioDepensesPct.toFixed(0)}% of EI)`} v={fmt(pf.totalDepenses, lang)} strong neg />
119 + <Row k={fr ? "Revenu net d'exploitation (RNE)" : "Net operating income (NOI)"} v={fmt(pf.rne, lang)} strong />
120 + <div className="vp-mono mt-3 flex flex-wrap gap-x-5 gap-y-1 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
121 + <span>TGA {fr ? "implicite" : "implied"} : <b className="text-ink">{pf.tgaImplicitePct.toFixed(2)} %</b></span>
122 + <span>MRB : <b className="text-ink">× {pf.mrb.toFixed(1)}</b></span>
123 + <span>{fr ? "RNE/porte" : "NOI/door"} : <b className="text-ink">{fmt(pf.rne / doors, lang)}</b></span>
124 + </div>
125 + </div>
126 +
127 + {/* financement + acquisition */}
128 + <div className="flex flex-col gap-4">
129 + <div className="vp-card p-5 sm:p-6">
130 + <p className="klabel mb-2">{fr ? "Financement" : "Financing"}</p>
131 + <Row k={fr ? `Hypothèque (${100 - pf.params.miseDeFondsPct} % de la valeur)` : `Mortgage (${100 - pf.params.miseDeFondsPct}% of value)`} v={fmt(pf.hypotheque, lang)} />
132 + <Row k={fr ? `Paiement mensuel (${pf.params.tauxHypoPct.toFixed(2)} %, ${pf.params.amortAns} ans)` : `Monthly payment (${pf.params.tauxHypoPct.toFixed(2)}%, ${pf.params.amortAns} yrs)`} v={fmt(pf.paiementMensuelHypo, lang)} />
133 + <Row k={fr ? "Service de la dette (annuel)" : "Debt service (annual)"} v={fmt(-pf.serviceDetteAnnuel, lang)} neg />
134 + <Row k={fr ? "Cashflow avant impôt" : "Pre-tax cashflow"} v={fmt(pf.cashflowAnnuel, lang)} strong neg={pf.cashflowAnnuel < 0} />
135 + <div className="mt-3 flex flex-wrap gap-2.5">
136 + <span className={`pill ${pf.dscr >= 1.2 ? "pill-conf-A" : pf.dscr >= 1.0 ? "pill-conf-C" : "pill-conf-D"}`}>
137 + DSCR {pf.dscr.toFixed(2)}
138 + </span>
139 + <span className={`pill ${pf.cashflowMensuelParPorte >= 0 ? "pill-conf-B" : "pill-conf-D"}`}>
140 + {fmt(pf.cashflowMensuelParPorte, lang)} / {fr ? "porte / mois" : "door / mo"}
141 + </span>
142 + <span className={`pill ${pf.cashOnCashPct >= 4 ? "pill-conf-A" : pf.cashOnCashPct >= 0 ? "pill-conf-C" : "pill-conf-D"}`}>
143 + {fr ? "Rend. liquidités" : "Cash-on-cash"} {pf.cashOnCashPct.toFixed(1)} %
144 + </span>
145 + </div>
146 + </div>
147 + <div className="vp-card border-ink bg-ink p-5 text-paper sm:p-6">
148 + <p className="klabel !text-lime mb-2">{fr ? "Liquidités requises — tout le kit" : "Cash required — the whole kit"}</p>
149 + <Row k={fr ? "Mise de fonds" : "Down payment"} v={fmt(pf.miseDeFonds, lang)} />
150 + <Row k={fr ? `Droits de mutation${(municipalite ?? "").toLowerCase().includes("montréal") ? " (barème Montréal)" : ""}` : "Transfer tax"} v={fmt(pf.droitsMutation, lang)} />
151 + <Row k={fr ? "Notaire" : "Notary"} v={fmt(pf.fraisNotaire, lang)} />
152 + <Row k={fr ? "Inspection" : "Inspection"} v={fmt(pf.fraisInspection, lang)} />
153 + <div className="mt-2 flex items-baseline justify-between border-t-[1.5px] border-lime pt-2">
154 + <span className="vp-display text-[14px] font-bold uppercase text-lime">{fr ? "Total à prévoir" : "Total to plan"}</span>
155 + <span className="vp-display text-[22px] font-bold text-lime">{fmt(pf.liquiditesRequises, lang)}</span>
156 + </div>
157 + </div>
158 + </div>
159 + </div>
160 + {/* projection 5 ans + marges de sécurité */}
161 + <div className="mt-4 grid items-start gap-4 lg:grid-cols-2">
162 + <div className="vp-card p-5 sm:p-6">
163 + <p className="klabel mb-2">
164 + {fr ? `Création de richesse — 5 ans (appréciation ${pf.params.appreciationPct} %/an)` : `Wealth creation — 5 years (${pf.params.appreciationPct}%/yr appreciation)`}
165 + </p>
166 + <Row k={fr ? "Capital remboursé — année 1" : "Principal paid — year 1"} v={fmt(pf.capitalAn1, lang)} />
167 + <Row k={fr ? "Intérêts — année 1" : "Interest — year 1"} v={fmt(-pf.interetsAn1, lang)} neg />
168 + <Row k={fr ? "Rendement global année 1 (cashflow + capital)" : "Total return year 1 (cashflow + principal)"} v={`${pf.rendementTotalAn1Pct.toFixed(1)} %`} strong />
169 + <Row k={fr ? "Valeur projetée à 5 ans" : "Projected value in 5 yrs"} v={fmt(pf.valeur5Ans, lang)} />
170 + <Row k={fr ? "Solde hypothécaire à 5 ans" : "Mortgage balance in 5 yrs"} v={fmt(-pf.soldeHypo5Ans, lang)} neg />
171 + <Row k={fr ? "Équité à 5 ans" : "Equity in 5 yrs"} v={fmt(pf.equite5Ans, lang)} strong />
172 + <Row k={fr ? "Gain total 5 ans (cashflows + capital + plus-value)" : "Total 5-yr gain (cashflows + principal + appreciation)"} v={fmt(pf.gainTotal5Ans, lang)} strong />
173 + <div className="mt-3">
174 + <span className={`pill ${pf.multipleLiquidites5Ans >= 1.8 ? "pill-conf-A" : pf.multipleLiquidites5Ans >= 1.2 ? "pill-conf-B" : "pill-conf-C"}`}>
175 + {fr ? "Multiple sur liquidités à 5 ans" : "5-yr equity multiple"} : × {pf.multipleLiquidites5Ans.toFixed(2)}
176 + </span>
177 + </div>
178 + </div>
179 + <div className="vp-card p-5 sm:p-6">
180 + <p className="klabel mb-2">{fr ? "Marges de sécurité et sensibilité" : "Safety margins & sensitivity"}</p>
181 + <Row k={fr ? "Loyer implicite / porte / mois" : "Implied rent / door / mo"} v={fmt(pf.loyerMoyenMensuel, lang)} />
182 + <Row k={fr ? "Loyer de point mort (cashflow = 0)" : "Breakeven rent (cashflow = 0)"} v={fmt(pf.loyerPointMort, lang)} />
183 + <Row
184 + k={fr ? "Marge de sécurité sur les loyers" : "Rent safety margin"}
185 + v={`${pf.margeSecuriteLoyerPct.toFixed(1)} %`}
186 + strong
187 + neg={pf.margeSecuriteLoyerPct < 0}
188 + />
189 + <Row
190 + k={fr ? "Taux hypothécaire de point mort" : "Breakeven mortgage rate"}
191 + v={pf.tauxPointMortPct != null ? `${pf.tauxPointMortPct.toFixed(2)} %` : fr ? "déjà négatif" : "already negative"}
192 + strong
193 + />
194 + <p className="klabel mb-1.5 mt-4">{fr ? "Cashflow selon le taux" : "Cashflow by rate"}</p>
195 + <div className="flex flex-wrap gap-2">
196 + {pf.sensibiliteTaux.map((sc) => (
197 + <span key={sc.tauxPct} className={`pill ${sc.cashflowAnnuel >= 0 ? "pill-conf-B" : "pill-conf-D"}`}>
198 + {sc.tauxPct.toFixed(2)} % → {fmt(sc.cashflowAnnuel, lang)}/{fr ? "an" : "yr"} · DSCR {sc.dscr.toFixed(2)}
199 + </span>
200 + ))}
201 + </div>
202 + </div>
203 + </div>
204 +
205 + <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
206 + {fr
207 + ? "Pro forma indicatif à hypothèses normalisées (loyer implicite dérivé de la valeur estimée et du TGA — pas des baux réels). Taxes approximées depuis la valeur au rôle. Ne constitue pas un conseil financier."
208 + : "Indicative pro forma with normalized assumptions (implied rent derived from estimated value and cap rate — not actual leases). Taxes approximated from assessed value. Not financial advice."}
209 + </p>
210 + </section>
211 + );
212 +}
added app/src/components/RadarMap.tsx +252 −0
@@ -0,0 +1,252 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * RadarMap — carte maison « plan d'arpenteur », construite from scratch (SVG pur).
4 + * Aucune tuile, aucune bibliothèque : le sujet est au centre, chaque comparable
5 + * est positionné par azimut et distance réels (projection locale équirectangulaire),
6 + * sur des anneaux de distance concentriques. Signature ValoPlex : encre, lime,
7 + * étiquettes mono, grille d'arpentage, flèche nord, échelle.
8 + */
9 +"use client";
10 +import { useMemo, useState } from "react";
11 +
12 +export interface RadarComp {
13 + id: string;
14 + lat: number;
15 + lng: number;
16 + label: string;
17 + price: string;
18 + adjusted: string;
19 + date: string;
20 + distanceM: number;
21 + weight: number;
22 +}
23 +
24 +interface Props {
25 + subject: { lat: number; lng: number; label: string };
26 + comps: RadarComp[];
27 + highlight?: string | null;
28 + onHover?: (id: string | null) => void;
29 +}
30 +
31 +const SIZE = 640; // viewBox
32 +const C = SIZE / 2;
33 +const PLOT_R = C - 46; // rayon utile
34 +
35 +/** Anneaux « ronds » couvrant la distance max. */
36 +function pickRings(maxM: number): number[] {
37 + const scales = [
38 + [100, 250, 500],
39 + [250, 500, 1000],
40 + [500, 1000, 2000],
41 + [1000, 2500, 5000],
42 + [2500, 5000, 10000],
43 + [5000, 10000, 20000],
44 + [10000, 25000, 50000],
45 + ];
46 + for (const s of scales) if (maxM <= s[2]) return s;
47 + return scales[scales.length - 1];
48 +}
49 +
50 +const fmtDist = (m: number) =>
51 + m < 1000 ? `${m} m` : `${m / 1000} km`.replace(".", ",");
52 +
53 +export default function RadarMap({ subject, comps, highlight, onHover }: Props) {
54 + const [hover, setHover] = useState<string | null>(null);
55 + const active = highlight ?? hover;
56 +
57 + const { pts, rings, scale } = useMemo(() => {
58 + const cos = Math.cos((subject.lat * Math.PI) / 180);
59 + const raw = comps.map((c) => ({
60 + ...c,
61 + x: (c.lng - subject.lng) * 111320 * cos,
62 + y: (c.lat - subject.lat) * 110574,
63 + }));
64 + const maxM = Math.max(120, ...raw.map((p) => Math.hypot(p.x, p.y)));
65 + const ringsM = pickRings(maxM);
66 + const outer = ringsM[ringsM.length - 1];
67 + const k = PLOT_R / outer;
68 + return {
69 + pts: raw.map((p) => ({ ...p, px: C + p.x * k, py: C - p.y * k })),
70 + rings: ringsM,
71 + scale: k,
72 + };
73 + }, [subject, comps]);
74 +
75 + const hovered = pts.find((p) => p.id === active) ?? null;
76 +
77 + return (
78 + <div className="relative">
79 + <svg
80 + viewBox={`0 0 ${SIZE} ${SIZE}`}
81 + role="img"
82 + aria-label={`Plan des comparables autour de ${subject.label}`}
83 + className="block w-full bg-[var(--surface)]"
84 + >
85 + <defs>
86 + <filter id="grain">
87 + <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" result="n" />
88 + <feColorMatrix in="n" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0" />
89 + <feComposite operator="over" in2="SourceGraphic" />
90 + </filter>
91 + </defs>
92 +
93 + {/* fond papier + grain */}
94 + <rect width={SIZE} height={SIZE} fill="var(--surface)" />
95 + <rect width={SIZE} height={SIZE} fill="#f5f3ee" opacity="0.6" filter="url(#grain)" />
96 +
97 + {/* grille d'arpentage */}
98 + {Array.from({ length: 15 }, (_, i) => (i + 1) * (SIZE / 16)).map((g) => (
99 + <g key={g} stroke="rgba(20,24,20,0.05)" strokeWidth="1">
100 + <line x1={g} y1="0" x2={g} y2={SIZE} />
101 + <line x1="0" y1={g} x2={SIZE} y2={g} />
102 + </g>
103 + ))}
104 +
105 + {/* anneaux de distance */}
106 + {rings.map((m, i) => (
107 + <g key={m}>
108 + <circle
109 + cx={C}
110 + cy={C}
111 + r={m * scale}
112 + fill="none"
113 + stroke={i === rings.length - 1 ? "rgba(20,24,20,0.85)" : "rgba(20,24,20,0.22)"}
114 + strokeWidth={i === rings.length - 1 ? 1.5 : 1}
115 + strokeDasharray={i === rings.length - 1 ? "none" : "5 6"}
116 + />
117 + <text
118 + x={C + m * scale * 0.7071 + 4}
119 + y={C - m * scale * 0.7071 - 4}
120 + fontSize="10.5"
121 + fontFamily="var(--font-jetbrains), monospace"
122 + fill="rgba(20,24,20,0.45)"
123 + style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}
124 + >
125 + {fmtDist(m)}
126 + </text>
127 + </g>
128 + ))}
129 +
130 + {/* croix cardinale */}
131 + <line x1={C} y1={C - PLOT_R} x2={C} y2={C + PLOT_R} stroke="rgba(20,24,20,0.14)" strokeWidth="1" />
132 + <line x1={C - PLOT_R} y1={C} x2={C + PLOT_R} y2={C} stroke="rgba(20,24,20,0.14)" strokeWidth="1" />
133 +
134 + {/* flèche nord */}
135 + <g transform={`translate(${SIZE - 34}, 38)`}>
136 + <path d="M0,-16 L7,8 L0,3 L-7,8 Z" fill="var(--ink)" />
137 + <text y="24" textAnchor="middle" fontSize="11" fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink)">
138 + N
139 + </text>
140 + </g>
141 +
142 + {/* échelle */}
143 + <g transform={`translate(28, ${SIZE - 30})`}>
144 + <line x1="0" y1="0" x2={rings[0] * scale} y2="0" stroke="var(--ink)" strokeWidth="2.5" />
145 + <line x1="0" y1="-4" x2="0" y2="4" stroke="var(--ink)" strokeWidth="2" />
146 + <line x1={rings[0] * scale} y1="-4" x2={rings[0] * scale} y2="4" stroke="var(--ink)" strokeWidth="2" />
147 + <text x={rings[0] * scale + 8} y="4" fontSize="10.5" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink-2)">
148 + {fmtDist(rings[0])}
149 + </text>
150 + </g>
151 +
152 + {/* rayons vers comparables (au survol) */}
153 + {hovered && (
154 + <line
155 + x1={C}
156 + y1={C}
157 + x2={hovered.px}
158 + y2={hovered.py}
159 + stroke="var(--green)"
160 + strokeWidth="1.5"
161 + strokeDasharray="3 4"
162 + />
163 + )}
164 +
165 + {/* comparables : cercles numérotés, taille = poids */}
166 + {pts.map((p, i) => {
167 + const r = 9 + p.weight * 7;
168 + const on = p.id === active;
169 + return (
170 + <g
171 + key={p.id}
172 + transform={`translate(${p.px}, ${p.py})`}
173 + onMouseEnter={() => {
174 + setHover(p.id);
175 + onHover?.(p.id);
176 + }}
177 + onMouseLeave={() => {
178 + setHover(null);
179 + onHover?.(null);
180 + }}
181 + style={{ cursor: "pointer" }}
182 + >
183 + <circle
184 + r={r}
185 + fill={on ? "var(--lime)" : "var(--green)"}
186 + stroke="var(--ink)"
187 + strokeWidth={on ? 2.5 : 1.5}
188 + />
189 + <text
190 + textAnchor="middle"
191 + dy="0.36em"
192 + fontSize={r > 12 ? 12 : 10.5}
193 + fontWeight="700"
194 + fontFamily="var(--font-jetbrains), monospace"
195 + fill={on ? "var(--ink)" : "#f5f3ee"}
196 + >
197 + {i + 1}
198 + </text>
199 + </g>
200 + );
201 + })}
202 +
203 + {/* sujet : losange lime pulsant au centre */}
204 + <g transform={`translate(${C}, ${C})`}>
205 + <rect
206 + x={-13}
207 + y={-13}
208 + width={26}
209 + height={26}
210 + transform="rotate(45)"
211 + fill="var(--lime)"
212 + stroke="var(--ink)"
213 + strokeWidth="2.5"
214 + >
215 + <animate attributeName="opacity" values="1;0.75;1" dur="2.4s" repeatCount="indefinite" />
216 + </rect>
217 + <rect x={-4} y={-4} width={8} height={8} transform="rotate(45)" fill="var(--ink)" />
218 + </g>
219 +
220 + {/* cartouche */}
221 + <g fontFamily="var(--font-jetbrains), monospace">
222 + <text x="28" y="34" fontSize="11" fontWeight="700" fill="var(--ink)" style={{ textTransform: "uppercase", letterSpacing: "0.12em" }}>
223 + ◆ PLAN DES COMPARABLES
224 + </text>
225 + <text x="28" y="50" fontSize="10" fill="rgba(20,24,20,0.5)" style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>
226 + {comps.length} ventes · azimut + distance réels
227 + </text>
228 + </g>
229 + </svg>
230 +
231 + {/* infobulle */}
232 + {hovered && (
233 + <div
234 + className="pointer-events-none absolute z-10 max-w-[230px] rounded-lg border border-lime bg-ink px-3.5 py-2.5 text-[12px] leading-relaxed text-paper shadow-[0_8px_24px_rgba(16,18,16,0.35)]"
235 + style={{
236 + left: `${(hovered.px / SIZE) * 100}%`,
237 + top: `${(hovered.py / SIZE) * 100}%`,
238 + transform: `translate(${hovered.px > SIZE * 0.62 ? "-108%" : "12px"}, ${hovered.py > SIZE * 0.7 ? "-108%" : "-8px"})`,
239 + }}
240 + >
241 + <p className="vp-display mb-0.5 font-bold text-lime">{hovered.label}</p>
242 + <p className="vp-mono text-[10.5px] uppercase tracking-[0.05em]">
243 + {hovered.date} · {fmtDist(Math.round(hovered.distanceM))}
244 + </p>
245 + <p>
246 + Vendu {hovered.price} → ajusté <b>{hovered.adjusted}</b>
247 + </p>
248 + </div>
249 + )}
250 + </div>
251 + );
252 +}
added app/src/components/ResultView.tsx +595 −0
@@ -0,0 +1,595 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useState } from "react";
4 +import Link from "next/link";
5 +import { useLang } from "./LangContext";
6 +import LeadForm from "./LeadForm";
7 +import RadarMap from "./RadarMap";
8 +import {
9 + BuildingGlyph,
10 + ConfidenceDial,
11 + CountUp,
12 + EraLine,
13 + locNum,
14 + LotDiagram,
15 + ValueSplit,
16 +} from "./MetricViz";
17 +import Proforma from "./Proforma";
18 +import type { EstimateResult } from "@/lib/engine";
19 +import type { UnitEstimate } from "@/lib/estimator";
20 +import type { TKey } from "@/lib/i18n";
21 +
22 +const fmt = (v: number | null | undefined, lang: string) =>
23 + v == null
24 + ? "—"
25 + : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
26 + style: "currency",
27 + currency: "CAD",
28 + maximumFractionDigits: 0,
29 + }).format(v);
30 +
31 +const signed = (v: number, lang: string) =>
32 + `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;
33 +
34 +const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(0)} %`;
35 +
36 +function doorsLabel(n: number | null | undefined, fr: boolean): string {
37 + if (!n) return "Plex";
38 + const map: Record<number, string> = {
39 + 2: "Duplex", 3: "Triplex", 4: "Quadruplex", 5: "Quintuplex", 6: "Sixplex",
40 + };
41 + return map[n] ?? (fr ? `Multi ${n} portes` : `${n}-door multi`);
42 +}
43 +
44 +function median(xs: number[]): number | null {
45 + if (!xs.length) return null;
46 + const s = [...xs].sort((a, b) => a - b);
47 + return s[Math.floor(s.length / 2)];
48 +}
49 +
50 +function Chip({ k, v }: { k: string; v: string | number }) {
51 + return (
52 + <div className="kv-cell">
53 + <p className="k">{k}</p>
54 + <p className="v break-words">{v}</p>
55 + </div>
56 + );
57 +}
58 +
59 +/** Frise des portes : une porte dessinée par logement (signature ValoPlex). */
60 +function DoorsRow({ doors, perDoor, lang }: { doors: number; perDoor: number | null; lang: string }) {
61 + const shown = Math.min(doors, 10);
62 + const extra = doors - shown;
63 + return (
64 + <div className="flex flex-wrap items-end gap-1.5" aria-label={`${doors} portes`}>
65 + {Array.from({ length: shown }, (_, i) => (
66 + <svg key={i} width="30" height="44" viewBox="0 0 30 44" className="rise" style={{ animationDelay: `${i * 60}ms` }}>
67 + <rect x="2" y="4" width="26" height="38" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.6" />
68 + <rect x="5.5" y="8" width="19" height="30" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.3" />
69 + <rect x="8" y="11" width="14" height="8" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1" />
70 + <circle cx="20.5" cy="25" r="1.6" fill="var(--ink)" />
71 + <text x="15" y="41.5" textAnchor="middle" fontSize="6.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
72 + {i + 1}
73 + </text>
74 + </svg>
75 + ))}
76 + {extra > 0 && (
77 + <span className="vp-mono mb-1 rounded-md border-[1.5px] border-ink bg-ink px-2 py-1 text-[11px] font-bold text-lime">
78 + +{extra}
79 + </span>
80 + )}
81 + {perDoor != null && (
82 + <span className="vp-mono mb-1 ml-2 text-[11px] font-bold uppercase tracking-[0.06em] text-green-deep">
83 + ≈ {fmt(Math.round(perDoor / 100) * 100, lang)} / {lang === "fr" ? "porte" : "door"}
84 + </span>
85 + )}
86 + </div>
87 + );
88 +}
89 +
90 +export default function ResultView({ data }: { data: UnitEstimate }) {
91 + const { lang, t } = useLang();
92 + const [activeComp, setActiveComp] = useState<string | null>(null);
93 + const r: EstimateResult = data.result;
94 + const u = data.unit;
95 + const s = u?.specs;
96 + const fr = lang === "fr";
97 + const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);
98 + const moneyFmt = (v: number) =>
99 + new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {
100 + style: "currency",
101 + currency: "CAD",
102 + maximumFractionDigits: 0,
103 + }).format(Math.round(v / 100) * 100);
104 +
105 + const doors = u?.nbLogements ?? null;
106 + const hasComps = r.nCompsUsed > 0;
107 + const perDoor = doors && doors >= 2 ? r.estimate / doors : null;
108 + const perM2 = u?.aireEtagesM2 ? r.estimate / u.aireEtagesM2 : null;
109 + const age = u?.anneeConstruction ? 2026 - u.anneeConstruction : null;
110 +
111 + // benchmark : médianes du marché local à partir des comparables retenus
112 + const mktPerDoor = median(
113 + r.comps.filter((c) => c.doors && c.doors >= 2).map((c) => c.amount / c.doors!)
114 + );
115 + const mktPerM2 = median(
116 + r.comps.filter((c) => c.floorArea && c.floorArea > 20).map((c) => c.amount / c.floorArea!)
117 + );
118 +
119 + return (
120 + <div className="space-y-10 py-8">
121 + {/* fil d'ariane */}
122 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">
123 + <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">
124 + ValoPlex
125 + </Link>
126 + <span>/</span>
127 + <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>
128 + </nav>
129 +
130 + {/* ---- Panneau prix ---- */}
131 + <section className="vp-card rise p-6 sm:p-8">
132 + <div className="flex flex-wrap items-start justify-between gap-6">
133 + <div className="min-w-0 flex-1">
134 + <div className="flex flex-wrap items-center gap-3">
135 + <span className="kicker">{t("estimatedValue")}</span>
136 + <span className="vp-mono rotate-[-1deg] rounded-md border-[1.5px] border-ink bg-lime px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink">
137 + {doorsLabel(doors, fr)}
138 + </span>
139 + </div>
140 + {u && (
141 + <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
142 + {u.adresse}
143 + {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}
144 + </h1>
145 + )}
146 + <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">
147 + <CountUp value={r.estimate} format={moneyFmt} />
148 + </p>
149 + {doors != null && doors >= 2 && (
150 + <div className="mt-4">
151 + <DoorsRow doors={doors} perDoor={perDoor} lang={lang} />
152 + </div>
153 + )}
154 + <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
155 + {perM2 != null && (
156 + <div>
157 + <p className="klabel">{fr ? "Prix au m²" : "Price / m²"}</p>
158 + <p className="vp-display text-[19px] font-bold">{fmt(Math.round(perM2), lang)}</p>
159 + </div>
160 + )}
161 + <div>
162 + <p className="klabel">{t("range")}</p>
163 + <p className="vp-display text-[19px] font-bold">
164 + {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}
165 + </p>
166 + </div>
167 + {u?.valeurRole != null && u.valeurRole > 0 && (
168 + <div>
169 + <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>
170 + <p className="vp-display text-[19px] font-bold text-green-deep">
171 + {pct((r.estimate / u.valeurRole - 1) * 100)}
172 + </p>
173 + </div>
174 + )}
175 + </div>
176 + {u && (
177 + <div className="mt-6 flex flex-wrap gap-2.5">
178 + <a href={`/api/report?id=${encodeURIComponent(u.id)}`} className="btn btn-primary" download>
179 + {fr ? "Rapport PDF" : "PDF report"} ↓
180 + </a>
181 + <a href={`/api/report/pro?id=${encodeURIComponent(u.id)}`} className="btn btn-ghost" download>
182 + {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓
183 + </a>
184 + </div>
185 + )}
186 + </div>
187 + <div className="shrink-0">
188 + <ConfidenceDial
189 + pct={r.confidencePct}
190 + level={r.confidenceLevel}
191 + label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}
192 + />
193 + </div>
194 + </div>
195 + <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
196 + {t("disclaimer")}
197 + </p>
198 + </section>
199 +
200 + {/* ---- L'économie du plex, porte par porte ---- */}
201 + {u && s && doors != null && doors >= 2 && (
202 + <section>
203 + <span className="kicker">{fr ? "Porte par porte" : "Door by door"}</span>
204 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
205 + {fr ? "L'économie du plex" : "The plex economics"}
206 + </h2>
207 + <div className="mt-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">
208 + {(
209 + [
210 + [fr ? "Valeur estimée / porte" : "Estimated / door", perDoor != null ? fmt(Math.round(perDoor / 100) * 100, lang) : "—", true],
211 + [fr ? "Rôle / porte" : "Assessed / door", u.valeurRole ? fmt(Math.round(u.valeurRole / doors), lang) : "—", false],
212 + [fr ? "Aire habitable / porte" : "Living area / door", u.aireEtagesM2 ? locNum(Math.round(u.aireEtagesM2 / doors), lang, { unit: "m²" }) : "—", false],
213 + [fr ? "Terrain / porte" : "Land / door", u.superficieTerrainM2 ? locNum(Math.round(u.superficieTerrainM2 / doors), lang, { unit: "m²" }) : "—", false],
214 + ] as [string, string, boolean][]
215 + ).map(([k, v, hero], i) => (
216 + <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>
217 + <p className={`vp-display text-[clamp(17px,2.2vw,24px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>
218 + <p className={`vp-mono mt-1.5 text-[9.5px] font-bold uppercase tracking-[0.09em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>{k}</p>
219 + </div>
220 + ))}
221 + </div>
222 + {/* benchmark vs marché local */}
223 + {(mktPerDoor != null || mktPerM2 != null) && (
224 + <div className="vp-card mt-3.5 flex flex-wrap items-center gap-x-6 gap-y-2 p-4">
225 + <p className="klabel">{fr ? "Vs le marché local (médiane des comparables)" : "Vs local market (comps median)"}</p>
226 + {mktPerDoor != null && perDoor != null && (
227 + <p className="vp-mono text-[12px]">
228 + {fr ? "$/porte marché" : "market $/door"} : <b>{fmt(Math.round(mktPerDoor), lang)}</b>
229 + <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perDoor >= mktPerDoor ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>
230 + {fr ? "votre plex" : "your plex"} {pct((perDoor / mktPerDoor - 1) * 100)}
231 + </span>
232 + </p>
233 + )}
234 + {mktPerM2 != null && perM2 != null && (
235 + <p className="vp-mono text-[12px]">
236 + {fr ? "$/m² marché" : "market $/m²"} : <b>{fmt(Math.round(mktPerM2), lang)}</b>
237 + <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perM2 >= mktPerM2 ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>
238 + {fr ? "votre plex" : "your plex"} {pct((perM2 / mktPerM2 - 1) * 100)}
239 + </span>
240 + </p>
241 + )}
242 + </div>
243 + )}
244 + </section>
245 + )}
246 +
247 + {/* ---- Pro forma de l'investisseur ---- */}
248 + {u && doors != null && doors >= 2 && r.estimate > 0 && (
249 + <Proforma
250 + valeur={r.estimate}
251 + doors={doors}
252 + valeurRole={u.valeurRole}
253 + municipalite={u.municipalite}
254 + />
255 + )}
256 +
257 + {/* ---- Portrait du plex (bento du registre) ---- */}
258 + {u && s && (
259 + <section>
260 + <span className="kicker">{fr ? "Registre officiel — dessiné par les données du plex" : "Official record — drawn by the plex data"}</span>
261 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
262 + {fr ? "Portrait du plex" : "Plex portrait"}
263 + </h2>
264 + {/* adresses officielles de chaque porte (table des adresses du rôle) */}
265 + {s.portesAdresses.length > 0 && (
266 + <div className="vp-card rise mt-4 p-5">
267 + <div className="flex flex-wrap items-baseline justify-between gap-2">
268 + <p className="klabel">
269 + {fr ? "Les portes officielles du plex (rôle foncier)" : "The plex's official doors (assessment roll)"}
270 + </p>
271 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">
272 + {s.nAdresses ?? s.portesAdresses.length} {fr ? "inscription(s) d'adresse" : "address record(s)"}
273 + </p>
274 + </div>
275 + <div className="mt-3 flex flex-wrap gap-2">
276 + {s.portesAdresses.map((a, i) => (
277 + <span key={a} className="stat-chip !py-1.5 rise" style={{ animationDelay: `${i * 50}ms` }}>
278 + <b>{i + 1}</b> {a}
279 + </span>
280 + ))}
281 + </div>
282 + <p className="vp-mono mt-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
283 + {fr
284 + ? "Le rôle public ne détaille pas l'intérieur de chaque logement (pièces, loyers) — voici les inscriptions d'adresses officielles de l'unité."
285 + : "The public roll does not detail each dwelling's interior (rooms, rents) — these are the unit's official address records."}
286 + </p>
287 + </div>
288 + )}
289 + <div className="mt-4 grid gap-4 lg:grid-cols-3">
290 + {/* Bâtiment */}
291 + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>
292 + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
293 + {fr ? `Bâtiment — ${doorsLabel(doors, fr).toLowerCase()}` : `Building — ${doorsLabel(doors, fr).toLowerCase()}`}
294 + </p>
295 + <div className="flex justify-center py-2">
296 + <BuildingGlyph
297 + floors={s.nbEtages}
298 + dwellings={doors}
299 + genre={s.genreConstruction}
300 + yearBuilt={u.anneeConstruction}
301 + floorAreaM2={u.aireEtagesM2}
302 + lang={lang}
303 + />
304 + </div>
305 + <EraLine year={u.anneeConstruction} lang={lang} />
306 + <div className="mt-3 grid grid-cols-2 gap-2">
307 + <Chip k={fr ? "Portes" : "Doors"} v={doors ?? "—"} />
308 + <Chip k={fr ? "Étages" : "Storeys"} v={s.nbEtages ?? "—"} />
309 + <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />
310 + <Chip k={fr ? "Âge" : "Age"} v={age != null ? `${age} ${fr ? "ans" : "yrs"} (${u.anneeConstruction})` : "—"} />
311 + <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />
312 + <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />
313 + <Chip
314 + k={fr ? "Mixité commerciale" : "Commercial mix"}
315 + v={s.nbLocauxNonResid ? `${s.nbLocauxNonResid} ${fr ? "local(aux)" : "unit(s)"}` : fr ? "Aucune" : "None"}
316 + />
317 + <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? (fr ? "Aucune" : "None")} />
318 + </div>
319 + </div>
320 + {/* Terrain */}
321 + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>
322 + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
323 + {fr ? "Terrain — à l'échelle" : "Lot — to scale"}
324 + </p>
325 + <div className="flex justify-center py-2">
326 + <LotDiagram
327 + areaM2={u.superficieTerrainM2}
328 + frontageM={s.frontTerrainM}
329 + footprintM2={u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2}
330 + lang={lang}
331 + isCondo={false}
332 + />
333 + </div>
334 + <div className="mt-3 grid grid-cols-2 gap-2">
335 + <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />
336 + <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />
337 + <Chip
338 + k={fr ? "Emprise au sol ≈" : "Ground coverage ≈"}
339 + v={
340 + u.superficieTerrainM2 && u.aireEtagesM2 && s.nbEtages
341 + ? `${Math.min(100, Math.round((u.aireEtagesM2 / Math.max(1, s.nbEtages) / u.superficieTerrainM2) * 100))} %`
342 + : "—"
343 + }
344 + />
345 + <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />
346 + </div>
347 + </div>
348 + {/* Valeur au rôle */}
349 + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>
350 + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
351 + {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}
352 + </p>
353 + <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">{fmt(u.valeurRole, lang)}</p>
354 + <ValueSplit
355 + land={s.valeurTerrain}
356 + building={s.valeurBatiment}
357 + previous={s.valeurAnterieure}
358 + total={u.valeurRole}
359 + lang={lang}
360 + labels={{
361 + land: fr ? "Terrain" : "Land",
362 + building: fr ? "Bâtiment" : "Building",
363 + previous: fr ? "Rôle antérieur" : "Previous roll",
364 + }}
365 + />
366 + <div className="mt-3 grid grid-cols-2 gap-2">
367 + <Chip
368 + k={fr ? "Bâtiment / m²" : "Building / m²"}
369 + v={s.valeurBatiment && u.aireEtagesM2 ? fmt(Math.round(s.valeurBatiment / u.aireEtagesM2), lang) : "—"}
370 + />
371 + <Chip k={fr ? "Rôle par porte" : "Assessed / door"} v={u.valeurRole && doors ? fmt(Math.round(u.valeurRole / doors), lang) : "—"} />
372 + <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />
373 + <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />
374 + </div>
375 + </div>
376 + </div>
377 + {/* registre brut complet */}
378 + <details className="vp-card mt-4 p-5">
379 + <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">
380 + {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}
381 + </summary>
382 + <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
383 + <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />
384 + <Chip k="CUBF" v={s.cubf ? `${s.cubf} — ${s.cubfLibelle ?? ""}` : "—"} />
385 + <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />
386 + <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />
387 + <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />
388 + <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />
389 + <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />
390 + <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />
391 + <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />
392 + <Chip k="ID provincial" v={u.id} />
393 + </div>
394 + </details>
395 + </section>
396 + )}
397 +
398 + {/* ---- Transparence du calcul ---- */}
399 + <section>
400 + <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>
401 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("whyTitle")}</h2>
402 + <div className="mt-4 grid gap-4 sm:grid-cols-2">
403 + <div className="vp-card vp-card-hover rise p-5">
404 + <p className="klabel">{t("modelPart")}</p>
405 + <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">
406 + {r.modelEstimate != null ? <CountUp value={r.modelEstimate} format={moneyFmt} /> : "—"}
407 + </p>
408 + <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">
409 + {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %
410 + </p>
411 + </div>
412 + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>
413 + <p className="klabel">{t("compsPart")}</p>
414 + {hasComps ? (
415 + <>
416 + <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">
417 + {r.compsEstimate != null ? <CountUp value={r.compsEstimate} format={moneyFmt} /> : "—"}
418 + </p>
419 + <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">
420 + {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} plex
421 + {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}
422 + </p>
423 + </>
424 + ) : (
425 + <>
426 + <p className="vp-display mt-2 text-[20px] font-bold tracking-[-0.02em] text-ink-3">
427 + {fr ? "Aucun plex comparable" : "No comparable plex"}
428 + </p>
429 + <p className="mt-1 max-w-sm text-[12.5px] text-ink-2">
430 + {fr
431 + ? "Immeuble hors gabarit pour son secteur : l'estimation repose à 100 % sur le modèle spécialisé, et la fourchette calibrée s'élargit en conséquence."
432 + : "Out-of-scale building for its area: the estimate relies 100% on the specialized model, and the calibrated range widens accordingly."}
433 + </p>
434 + </>
435 + )}
436 + </div>
437 + </div>
438 + </section>
439 +
440 + {/* ---- Historique ---- */}
441 + {u && u.history.some((h) => h.value != null) && (
442 + <section>
443 + <span className="kicker">2021 → 2026</span>
444 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("history")}</h2>
445 + <div className="vp-card rise mt-4 p-5 sm:p-6">
446 + <div className="flex flex-col gap-2">
447 + {u.history.map((h, i) => {
448 + const prev = i > 0 ? u.history[i - 1].value : null;
449 + const yoy = h.value != null && prev != null && prev > 0 ? (h.value / prev - 1) * 100 : null;
450 + return (
451 + <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">
452 + <span className="vp-mono text-[12px] font-bold">{h.year}</span>
453 + <span className="hbar-track">
454 + <span
455 + className="hbar-fill grow-x"
456 + style={{
457 + width: `${((h.value ?? 0) / histMax) * 100}%`,
458 + animationDelay: `${i * 90}ms`,
459 + background: h.year === 2026 ? "var(--ink)" : undefined,
460 + }}
461 + />
462 + </span>
463 + <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">
464 + {fmt(h.value, lang)}
465 + {doors != null && doors >= 2 && h.value != null && (
466 + <span className="hidden text-[10px] font-normal text-ink-3 sm:inline">
467 + ({fmt(Math.round(h.value / doors), lang)}/{fr ? "porte" : "door"})
468 + </span>
469 + )}
470 + {yoy != null && (
471 + <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>
472 + {pct(yoy)}
473 + </span>
474 + )}
475 + </span>
476 + </div>
477 + );
478 + })}
479 + </div>
480 + </div>
481 + </section>
482 + )}
483 +
484 + {/* ---- Plan des comparables OU état vide honnête ---- */}
485 + <section>
486 + <span className="kicker">
487 + {hasComps ? `${r.nCompsUsed} ${fr ? "ventes de plex" : "plex sales"}` : fr ? "Comparables" : "Comparables"}
488 + </span>
489 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("compsTitle")}</h2>
490 + {hasComps ? (
491 + <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">
492 + <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">
493 + <RadarMap
494 + subject={{
495 + lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,
496 + lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,
497 + label: u?.adresse ?? (fr ? "Plex évalué" : "Subject plex"),
498 + }}
499 + comps={r.comps.map((c) => ({
500 + id: c.id,
501 + lat: c.lat,
502 + lng: c.lng,
503 + label: `${c.street ?? ""}, ${c.city ?? ""}`,
504 + price: fmt(c.amount, lang),
505 + adjusted: fmt(Math.round(c.adjustedPrice), lang),
506 + date: c.date,
507 + distanceM: c.distanceM,
508 + weight: c.weight,
509 + }))}
510 + highlight={activeComp}
511 + onHover={setActiveComp}
512 + />
513 + </div>
514 + <div className="src-wrap max-h-[560px] overflow-y-auto">
515 + <table className="src-table">
516 + <thead className="sticky top-0 z-10">
517 + <tr>
518 + <th>#</th>
519 + <th>{fr ? "Vente" : "Sale"}</th>
520 + <th className="text-right">{t("adjusted")}</th>
521 + </tr>
522 + </thead>
523 + <tbody>
524 + {r.comps.map((c, i) => (
525 + <tr
526 + key={c.id}
527 + onMouseEnter={() => setActiveComp(c.id)}
528 + onMouseLeave={() => setActiveComp(null)}
529 + className={activeComp === c.id ? "!bg-lime-soft" : ""}
530 + >
531 + <td>
532 + <span
533 + className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${
534 + activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"
535 + }`}
536 + >
537 + {i + 1}
538 + </span>
539 + </td>
540 + <td>
541 + <p className="font-semibold leading-tight">
542 + {c.street}, {c.city}
543 + {c.doors != null && (
544 + <span className="vp-mono ml-1.5 rounded-[3px] border border-ink bg-surface-2 px-1 text-[9px] font-bold uppercase">
545 + {doorsLabel(c.doors, fr)}
546 + </span>
547 + )}
548 + </p>
549 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
550 + {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}
551 + {t("sold")} {fmt(c.amount, lang)}
552 + {c.doors ? ` (${fmt(Math.round(c.amount / c.doors), lang)}/${fr ? "porte" : "door"})` : ""}
553 + </p>
554 + <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">
555 + {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}
556 + {t("adjAge")} {signed(c.adjAge, lang)} · {t("adjPortes")} {signed(c.adjDoors, lang)}
557 + </p>
558 + </td>
559 + <td className="vp-display text-right font-bold">
560 + {fmt(Math.round(c.adjustedPrice), lang)}
561 + <p className="vp-mono text-[10px] font-normal text-ink-3">
562 + {t("weight")} {(c.weight * 100).toFixed(0)} %
563 + </p>
564 + </td>
565 + </tr>
566 + ))}
567 + </tbody>
568 + </table>
569 + </div>
570 + </div>
571 + ) : (
572 + <div className="vp-card mt-4 flex flex-col items-center gap-3 p-10 text-center">
573 + <p className="vp-display text-[40px]">⌂</p>
574 + <p className="vp-display text-[18px] font-bold uppercase tracking-[-0.01em]">
575 + {fr ? "Aucun plex comparable dans le secteur" : "No comparable plex in the area"}
576 + </p>
577 + <p className="max-w-lg text-[13.5px] text-ink-2">
578 + {fr
579 + ? `Aucune vente récente de plex de gabarit semblable (${doorsLabel(doors, fr).toLowerCase()}) n'a été trouvée à proximité. L'estimation repose entièrement sur le modèle hédonique spécialisé, entraîné sur 91 060 ventes de plex à travers le Québec — la fourchette calibrée reflète cette incertitude supplémentaire.`
580 + : `No recent sale of a similar-scale plex (${doorsLabel(doors, fr).toLowerCase()}) was found nearby. The estimate relies entirely on the specialized hedonic model, trained on 91,060 plex sales across Québec — the calibrated range reflects this added uncertainty.`}
581 + </p>
582 + </div>
583 + )}
584 + </section>
585 +
586 + {/* ---- Lead ---- */}
587 + <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8">
588 + <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">{t("leadTitle")}</h2>
589 + <div className="mt-4 max-w-xl">
590 + <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />
591 + </div>
592 + </section>
593 + </div>
594 + );
595 +}
added app/src/components/SearchBox.tsx +95 −0
@@ -0,0 +1,95 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useRef, useState } from "react";
4 +import { useRouter } from "next/navigation";
5 +import { useLang } from "./LangContext";
6 +
7 +export interface Suggestion {
8 + id: string;
9 + adresse: string | null;
10 + apt: string | null;
11 + municipalite: string | null;
12 + typeProp: string;
13 +}
14 +
15 +export default function SearchBox({
16 + onPick,
17 + placeholder,
18 +}: {
19 + /** Si fourni : appelé au choix d'une adresse (le champ se vide) au lieu de naviguer. */
20 + onPick?: (s: Suggestion) => void;
21 + placeholder?: string;
22 +}) {
23 + const { t } = useLang();
24 + const router = useRouter();
25 + const [q, setQ] = useState("");
26 + const [sugg, setSugg] = useState<Suggestion[]>([]);
27 + const [open, setOpen] = useState(false);
28 + const [searched, setSearched] = useState(false);
29 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
30 +
31 + const onChange = (value: string) => {
32 + setQ(value);
33 + if (timer.current) clearTimeout(timer.current);
34 + if (value.trim().length < 3) {
35 + setSugg([]);
36 + setSearched(false);
37 + return;
38 + }
39 + timer.current = setTimeout(async () => {
40 + const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
41 + const data = (await res.json()) as { results: Suggestion[] };
42 + setSugg(data.results);
43 + setSearched(true);
44 + setOpen(true);
45 + }, 200);
46 + };
47 +
48 + return (
49 + <div className="relative w-full">
50 + <input
51 + value={q}
52 + onChange={(e) => onChange(e.target.value)}
53 + onFocus={() => setOpen(true)}
54 + onBlur={() => setTimeout(() => setOpen(false), 200)}
55 + placeholder={placeholder ?? t("searchPlaceholder")}
56 + className="vp-input text-base"
57 + aria-label={placeholder ?? t("searchPlaceholder")}
58 + />
59 + {open && sugg.length > 0 && (
60 + <ul className="absolute z-[1200] mt-2 w-full overflow-hidden rounded-[10px] border-[1.5px] border-ink bg-surface shadow-[6px_6px_0_rgba(20,24,20,0.85)]">
61 + {sugg.map((s) => (
62 + <li key={s.id} className="border-b border-[rgba(20,24,20,0.1)] last:border-0">
63 + <button
64 + className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors hover:bg-lime-soft"
65 + onMouseDown={() => {
66 + if (onPick) {
67 + onPick(s);
68 + setQ("");
69 + setSugg([]);
70 + setSearched(false);
71 + } else {
72 + router.push(`/estimation/${s.id}`);
73 + }
74 + }}
75 + >
76 + <span className="vp-display font-bold">
77 + {s.adresse}
78 + {s.apt ? ` app. ${s.apt}` : ""}
79 + </span>
80 + <span className="vp-mono shrink-0 text-[11px] uppercase tracking-[0.05em] text-ink-3">
81 + {s.municipalite}
82 + </span>
83 + </button>
84 + </li>
85 + ))}
86 + </ul>
87 + )}
88 + {open && searched && sugg.length === 0 && q.trim().length >= 3 && (
89 + <div className="vp-mono absolute z-[1200] mt-2 w-full rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3 text-[12px] uppercase tracking-[0.04em] text-ink-2 shadow-[6px_6px_0_rgba(20,24,20,0.85)]">
90 + {t("searchNoResult")}
91 + </div>
92 + )}
93 + </div>
94 + );
95 +}
added app/src/components/StatsView.tsx +282 −0
@@ -0,0 +1,282 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import { useState } from "react";
4 +import { CountUp } from "./MetricViz";
5 +import { useLang } from "./LangContext";
6 +
7 +export interface ProvStats {
8 + generated: string;
9 + unites: number;
10 + valeur_totale_2026: number;
11 + valeur_role_totale: number;
12 + valeur_mediane_2026: number;
13 + croissance_2021_2026_pct: number;
14 + logements: number;
15 + aire_etages_km2: number;
16 + terrain_km2: number;
17 + municipalites: number;
18 + totaux_annee: { year: number; total: number; n: number }[];
19 + par_type: { type: string; n: number; total: number; mediane: number | null }[];
20 + par_ville: { ville: string; n: number; total: number; mediane: number | null }[];
21 +}
22 +
23 +const POP_QC = 9_111_000; // population du Québec (2026, ISQ)
24 +const BUDGET_QC = 165_800_000_000; // dépenses budget QC 2026-2027 (approx.)
25 +const PIB_QC = 640_000_000_000; // PIB nominal QC (approx. 2025)
26 +
27 +const money = (v: number, lang: string, frac = 0) =>
28 + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
29 + style: "currency",
30 + currency: "CAD",
31 + maximumFractionDigits: frac,
32 + }).format(v);
33 +
34 +const compact = (v: number, lang: string) => {
35 + const fr = lang === "fr";
36 + if (v >= 1e12) return `${(v / 1e12).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })} ${fr ? "billions $" : "T$"}`;
37 + if (v >= 1e9) return `${(v / 1e9).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} G$`;
38 + if (v >= 1e6) return `${(v / 1e6).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} M$`;
39 + return money(v, lang);
40 +};
41 +
42 +const num = (v: number, lang: string) =>
43 + v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA");
44 +
45 +export default function StatsView({ s }: { s: ProvStats }) {
46 + const { lang } = useLang();
47 + const fr = lang === "fr";
48 + const [q, setQ] = useState("");
49 +
50 + const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));
51 + const maxType = Math.max(...s.par_type.map((x) => x.total));
52 + const top10 = s.par_ville.slice(0, 10);
53 + const maxVille = Math.max(...top10.map((x) => x.total));
54 + const villes = s.par_ville.filter((v) =>
55 + v.ville.toLowerCase().includes(q.trim().toLowerCase())
56 + );
57 +
58 + return (
59 + <div className="pb-6 pt-10 sm:pt-14">
60 + {/* ---- LE CHIFFRE ---- */}
61 + <section>
62 + <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span>
63 + <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
64 + {fr ? (
65 + <>
66 + Tous les plex du Québec valent,
67 + <br />
68 + <span className="hl">au dollar près</span>…
69 + </>
70 + ) : (
71 + <>
72 + All Québec plexes are worth,
73 + <br />
74 + <span className="hl">to the dollar</span>…
75 + </>
76 + )}
77 + </h1>
78 + <div className="rise mt-6 rounded-[10px] border-[1.5px] border-ink bg-ink p-6 shadow-[10px_10px_0_rgba(20,24,20,0.25)] sm:p-10">
79 + <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime">
80 + <CountUp
81 + value={s.valeur_totale_2026}
82 + duration={2600}
83 + format={(v) => money(v, lang)}
84 + />
85 + </p>
86 + <p className="vp-mono mt-4 text-[11.5px] uppercase tracking-[0.1em] text-[rgba(255,159,69,0.75)]">
87 + {fr
88 + ? `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} plex · ${num(s.municipalites, lang)} municipalités · millésime 2026`
89 + : `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} plexes · ${num(s.municipalites, lang)} municipalities · 2026 vintage`}
90 + </p>
91 + <div className="mt-6 flex flex-wrap gap-2.5">
92 + {(
93 + [
94 + [fr ? "par Québécois·e" : "per Quebecer", money(s.valeur_totale_2026 / POP_QC, lang)],
95 + [fr ? "budgets annuels du Québec" : "annual Québec budgets", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`],
96 + [fr ? "fois le PIB du Québec" : "× Québec GDP", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],
97 + [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`],
98 + ] as [string, string][]
99 + ).map(([k, v]) => (
100 + <span key={k} className="stat-chip !border-[rgba(255,159,69,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none">
101 + <b className="!text-lime">{v}</b> {k}
102 + </span>
103 + ))}
104 + </div>
105 + </div>
106 + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
107 + {fr
108 + ? "Somme des 393 867 estimations ValoPlex (modèle hédonique plex, rôle 2026). Évaluation municipale totale correspondante : "
109 + : "Sum of 393,867 ValoPlex estimates (plex hedonic model, 2026 roll). Corresponding total municipal assessment: "}
110 + {compact(s.valeur_role_totale, lang)}.
111 + </p>
112 + <a href="/api/report/stats" className="btn btn-primary mt-5" download>
113 + {fr
114 + ? "Télécharger le rapport statistique provincial (PDF)"
115 + : "Download the provincial statistics report (PDF)"}{" "}
116 +
117 + </a>
118 + </section>
119 +
120 + {/* ---- tuiles physiques ---- */}
121 + <section className="mt-10">
122 + <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
123 + {(
124 + [
125 + [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true],
126 + [fr ? "Portes (logements)" : "Doors (dwellings)", num(s.logements, lang), false],
127 + [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false],
128 + [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false],
129 + ] as [string, string, boolean][]
130 + ).map(([k, v, hero], i) => (
131 + <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>
132 + <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>
133 + <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>{k}</p>
134 + </div>
135 + ))}
136 + </div>
137 + </section>
138 +
139 + {/* ---- valeur provinciale par année ---- */}
140 + <section className="mt-12">
141 + <span className="kicker">2021 → 2026</span>
142 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
143 + {fr ? "La province, année par année" : "The province, year by year"}
144 + </h2>
145 + <div className="vp-card rise mt-4 p-5 sm:p-6">
146 + <div className="flex flex-col gap-2">
147 + {s.totaux_annee.map((h, i) => {
148 + const prev = i > 0 ? s.totaux_annee[i - 1].total : null;
149 + const yoy = prev ? ((h.total / prev - 1) * 100) : null;
150 + return (
151 + <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">
152 + <span className="vp-mono text-[12px] font-bold">{h.year}</span>
153 + <span className="hbar-track !h-[24px]">
154 + <span
155 + className="hbar-fill grow-x !rounded-r-[6px]"
156 + style={{
157 + width: `${(h.total / maxYear) * 100}%`,
158 + animationDelay: `${i * 90}ms`,
159 + background: h.year === 2026 ? "var(--ink)" : undefined,
160 + }}
161 + />
162 + </span>
163 + <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">
164 + {compact(h.total, lang)}
165 + {yoy != null && (
166 + <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>
167 + {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} %
168 + </span>
169 + )}
170 + </span>
171 + </div>
172 + );
173 + })}
174 + </div>
175 + <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
176 + {fr
177 + ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)."
178 + : "Sums of units estimated each vintage — the stock grows every year (new construction included)."}
179 + </p>
180 + </div>
181 + </section>
182 +
183 + {/* ---- par type ---- */}
184 + <section className="mt-12">
185 + <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span>
186 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
187 + {fr ? "Valeur par taille de plex" : "Value by plex size"}
188 + </h2>
189 + <div className="vp-card rise mt-4 p-5 sm:p-6">
190 + <div className="flex flex-col gap-2.5">
191 + {s.par_type.map((tp, i) => (
192 + <div key={tp.type} className="hbar-row grid grid-cols-[110px_1fr_auto] items-center gap-2 sm:grid-cols-[150px_1fr_auto] sm:gap-3">
193 + <span className="vp-display truncate text-[13px] font-bold">{tp.type}</span>
194 + <span className="hbar-track !h-[22px]">
195 + <span
196 + className="hbar-fill grow-x"
197 + style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }}
198 + />
199 + </span>
200 + <span className="vp-mono text-right text-[11.5px] font-bold">
201 + {compact(tp.total, lang)}
202 + <span className="block text-[9.5px] font-normal text-ink-3">
203 + {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"}
204 + </span>
205 + </span>
206 + </div>
207 + ))}
208 + </div>
209 + </div>
210 + </section>
211 +
212 + {/* ---- par ville ---- */}
213 + <section className="mt-12">
214 + <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span>
215 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
216 + {fr ? "Valeur par municipalité" : "Value by municipality"}
217 + </h2>
218 + {/* top 10 en barres */}
219 + <div className="vp-card rise mt-4 p-5 sm:p-6">
220 + <div className="flex flex-col gap-2">
221 + {top10.map((v, i) => (
222 + <div key={v.ville} className="hbar-row grid grid-cols-[24px_96px_1fr_auto] items-center gap-2 sm:grid-cols-[28px_150px_1fr_auto] sm:gap-3">
223 + <span className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[10.5px] font-bold ${i < 3 ? "bg-ink text-lime" : "bg-surface text-ink"}`}>
224 + {i + 1}
225 + </span>
226 + <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span>
227 + <span className="hbar-track !h-[22px]">
228 + <span
229 + className="hbar-fill grow-x"
230 + style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }}
231 + />
232 + </span>
233 + <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span>
234 + </div>
235 + ))}
236 + </div>
237 + </div>
238 + {/* table complète filtrable */}
239 + <div className="mt-4">
240 + <input
241 + value={q}
242 + onChange={(e) => setQ(e.target.value)}
243 + placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"}
244 + className="vp-input max-w-md"
245 + />
246 + <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto">
247 + <table className="src-table">
248 + <thead className="sticky top-0 z-10">
249 + <tr>
250 + <th>#</th>
251 + <th>{fr ? "Municipalité" : "Municipality"}</th>
252 + <th className="text-right">{fr ? "Propriétés" : "Properties"}</th>
253 + <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th>
254 + <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th>
255 + </tr>
256 + </thead>
257 + <tbody>
258 + {villes.map((v) => {
259 + const rank = s.par_ville.indexOf(v) + 1;
260 + return (
261 + <tr key={v.ville}>
262 + <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td>
263 + <td className="font-semibold">{v.ville}</td>
264 + <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td>
265 + <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td>
266 + <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td>
267 + </tr>
268 + );
269 + })}
270 + </tbody>
271 + </table>
272 + </div>
273 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
274 + {fr
275 + ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.`
276 + : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`}
277 + </p>
278 + </div>
279 + </section>
280 + </div>
281 + );
282 +}
added app/src/lib/db.ts +144 −0
@@ -0,0 +1,144 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import Database from "better-sqlite3";
3 +import path from "path";
4 +
5 +let db: Database.Database | null = null;
6 +
7 +export function getDb(): Database.Database {
8 + if (!db) {
9 + const p =
10 + process.env.VALOPLEX_DB ?? path.join(process.cwd(), "data", "valoplex.db");
11 + db = new Database(p, { fileMustExist: true });
12 + db.pragma("journal_mode = WAL");
13 + }
14 + return db;
15 +}
16 +
17 +export interface UnitRow {
18 + id_provinc: string;
19 + adresse: string | null;
20 + apt: string | null;
21 + municipalite: string | null;
22 + arrond: string | null;
23 + code_mun: string | null;
24 + lat: number;
25 + lng: number;
26 + cubf: number | null;
27 + cubf_libelle: string | null;
28 + type_prop: string;
29 + annee_construction: number | null;
30 + annee_estimee: string | null;
31 + aire_etages_m2: number | null;
32 + superficie_terrain_m2: number | null;
33 + front_terrain_m: number | null;
34 + nb_etages: number | null;
35 + nb_logements: number | null;
36 + nb_locaux_non_resid: number | null;
37 + nb_chambres_locatives: number | null;
38 + lien_physique: string | null;
39 + genre_construction: string | null;
40 + matricule: string | null;
41 + unite_voisinage: string | null;
42 + n_adresses: number | null;
43 + dat_cond_marche: string | null;
44 + valeur_terrain: number | null;
45 + valeur_batiment: number | null;
46 + valeur_role: number | null;
47 + valeur_anterieure: number | null;
48 + adresses_json: string | null;
49 + est_2021: number | null;
50 + est_2022: number | null;
51 + est_2023: number | null;
52 + est_2024: number | null;
53 + est_2025: number | null;
54 + est_2026: number | null;
55 + p10: number | null;
56 + p90: number | null;
57 + est_hedo: number | null;
58 +}
59 +
60 +export interface TxRow {
61 + id: string;
62 + date: string;
63 + amount: number;
64 + street: string | null;
65 + city: string | null;
66 + lat: number;
67 + lng: number;
68 + property_type: string | null;
69 + year_built: number | null;
70 + floor_area: number | null;
71 + building_type: string | null;
72 + id_provinc: string | null;
73 + valeur_role: number | null;
74 + land_area: number | null;
75 + portes: number | null;
76 +}
77 +
78 +export function getUnit(id: string): UnitRow | undefined {
79 + return getDb()
80 + .prepare("SELECT * FROM units WHERE id_provinc = ?")
81 + .get(id) as UnitRow | undefined;
82 +}
83 +
84 +/** Candidats comparables dans une boîte englobante autour du point. */
85 +export function getCandidates(
86 + lat: number,
87 + lng: number,
88 + halfDeg: number,
89 + sinceDate: string,
90 + limit = 500
91 +): TxRow[] {
92 + return getDb()
93 + .prepare(
94 + `SELECT * FROM transactions
95 + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
96 + AND date >= ? AND amount >= 50000
97 + LIMIT ?`
98 + )
99 + .all(lat - halfDeg, lat + halfDeg, lng - halfDeg, lng + halfDeg, sinceDate, limit) as TxRow[];
100 +}
101 +
102 +export function getMarketIndex(typeProp: string): { month: string; idx: number }[] {
103 + return getDb()
104 + .prepare(
105 + "SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month"
106 + )
107 + .all(typeProp) as { month: string; idx: number }[];
108 +}
109 +
110 +export function searchUnits(q: string, limit = 8): (UnitRow & { score: number })[] {
111 + const cleaned = q
112 + .replace(/[^\p{L}\p{N}\s'-]/gu, " ")
113 + .trim()
114 + .split(/\s+/)
115 + .filter((t) => t.length > 0)
116 + .map((t) => `"${t}"*`)
117 + .join(" ");
118 + if (!cleaned) return [];
119 + return getDb()
120 + .prepare(
121 + `SELECT u.*, bm25(units_fts) AS score
122 + FROM units_fts JOIN units u ON u.rowid = units_fts.rowid
123 + WHERE units_fts MATCH ?
124 + ORDER BY score LIMIT ?`
125 + )
126 + .all(cleaned, limit) as (UnitRow & { score: number })[];
127 +}
128 +
129 +export function municipalityCenter(
130 + name: string
131 +): { lat: number; lng: number; n: number } | undefined {
132 + return getDb()
133 + .prepare(
134 + `SELECT AVG(lat) AS lat, AVG(lng) AS lng, COUNT(*) AS n
135 + FROM units WHERE municipalite = ? COLLATE NOCASE`
136 + )
137 + .get(name) as { lat: number; lng: number; n: number } | undefined;
138 +}
139 +
140 +export function saveLead(email: string, unitId: string | null, estimate: number | null): void {
141 + getDb()
142 + .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)")
143 + .run(email, unitId, estimate);
144 +}
added app/src/lib/engine.test.ts +155 −0
@@ -0,0 +1,155 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { describe, expect, it } from "vitest";
3 +import {
4 + adjustComps,
5 + ageAdjustment,
6 + areaAdjustment,
7 + doorsAdjustment,
8 + estimate,
9 + haversineM,
10 + timeFactor,
11 + weightedMedian,
12 + type CompInput,
13 + type MarketIndexPoint,
14 + type Subject,
15 +} from "./engine";
16 +
17 +const NOW = "2026-08-09";
18 +
19 +const INDEX: MarketIndexPoint[] = [
20 + { month: "2024-01", idx: 0.8 },
21 + { month: "2025-01", idx: 0.9 },
22 + { month: "2026-01", idx: 1.0 },
23 +];
24 +
25 +function comp(over: Partial<CompInput>): CompInput {
26 + return {
27 + id: "c1",
28 + date: "2026-02-15",
29 + amount: 900000,
30 + lat: 45.56,
31 + lng: -73.58,
32 + propertyType: "plex",
33 + doors: 3,
34 + yearBuilt: 1955,
35 + floorArea: 240,
36 + street: "4562 rue Chabot",
37 + city: "Montréal",
38 + ...over,
39 + };
40 +}
41 +
42 +const SUBJECT: Subject = {
43 + lat: 45.55,
44 + lng: -73.58,
45 + typeProp: "plex",
46 + doors: 3,
47 + floorArea: 240,
48 + yearBuilt: 1955,
49 + modelEstimate: 920000,
50 + modelP10: 760000,
51 + modelP90: 1100000,
52 +};
53 +
54 +describe("haversineM", () => {
55 + it("est nul à distance nulle et ~111 km par degré de latitude", () => {
56 + expect(haversineM(45.55, -73.58, 45.55, -73.58)).toBe(0);
57 + expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeGreaterThan(110000);
58 + expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeLessThan(112000);
59 + });
60 +});
61 +
62 +describe("timeFactor", () => {
63 + it("majore une vente ancienne selon l'indice", () => {
64 + expect(timeFactor("2024-01", INDEX)).toBeCloseTo(1.0 / 0.8, 5);
65 + expect(timeFactor("2026-03", INDEX)).toBe(1);
66 + });
67 +});
68 +
69 +describe("areaAdjustment", () => {
70 + it("ajuste à 50 % du $/m² du comparable", () => {
71 + expect(areaAdjustment(260, { floorArea: 240, amount: 900000 })).toBe(
72 + 20 * 0.5 * (900000 / 240)
73 + );
74 + });
75 +});
76 +
77 +describe("ageAdjustment", () => {
78 + it("0,5 % par année d'écart, borné à ±10 %", () => {
79 + expect(ageAdjustment(1965, { yearBuilt: 1955, amount: 900000 })).toBeCloseTo(45000);
80 + expect(ageAdjustment(1900, { yearBuilt: 1990, amount: 900000 })).toBeCloseTo(-90000);
81 + });
82 +});
83 +
84 +describe("doorsAdjustment (plex)", () => {
85 + it("ajuste à 50 % du prix par porte du comparable", () => {
86 + // comp triplex 900 000 $ => 300 000 $/porte ; sujet quadruplex => +150 000 $
87 + expect(doorsAdjustment(4, { doors: 3, amount: 900000 })).toBe(150000);
88 + expect(doorsAdjustment(2, { doors: 3, amount: 900000 })).toBe(-150000);
89 + });
90 + it("borne à ±30 % du prix du comparable", () => {
91 + expect(doorsAdjustment(12, { doors: 2, amount: 600000 })).toBe(180000);
92 + });
93 + it("retourne 0 si portes manquantes", () => {
94 + expect(doorsAdjustment(null, { doors: 3, amount: 900000 })).toBe(0);
95 + expect(doorsAdjustment(3, { doors: null, amount: 900000 })).toBe(0);
96 + });
97 +});
98 +
99 +describe("weightedMedian", () => {
100 + it("respecte les poids", () => {
101 + expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300);
102 + });
103 +});
104 +
105 +describe("adjustComps", () => {
106 + it("filtre par similarité de portes et pondère la proximité", () => {
107 + const candidates = [
108 + comp({ id: "proche", lat: 45.551 }),
109 + comp({ id: "loin", lat: 45.65 }),
110 + comp({ id: "tour-12portes", doors: 12, amount: 2500000 }),
111 + comp({ id: "a", doors: 2 }),
112 + comp({ id: "b", doors: 4 }),
113 + comp({ id: "c" }),
114 + comp({ id: "d", doors: 3 }),
115 + ];
116 + const res = adjustComps(SUBJECT, candidates, INDEX, NOW);
117 + const ids = res.map((c) => c.id);
118 + expect(ids).not.toContain("tour-12portes"); // ±1 porte suffit ici
119 + expect(res[0].id).toBe("proche");
120 + });
121 +
122 + it("intègre l'ajustement portes dans le prix ajusté", () => {
123 + const candidates = Array.from({ length: 6 }, (_, i) =>
124 + comp({ id: `c${i}`, doors: 2, amount: 600000 })
125 + );
126 + const res = adjustComps({ ...SUBJECT, doors: 3 }, candidates, INDEX, NOW);
127 + expect(res[0].adjDoors).toBeGreaterThan(0); // sujet a plus de portes
128 + });
129 +});
130 +
131 +describe("estimate", () => {
132 + const candidates = Array.from({ length: 8 }, (_, i) =>
133 + comp({ id: `c${i}`, lat: 45.55 + i * 0.001, amount: 850000 + i * 20000, date: "2026-01-10" })
134 + );
135 +
136 + it("combine modèle (65 %) et comparables (35 %)", () => {
137 + const r = estimate(SUBJECT, candidates, INDEX, NOW);
138 + expect(r.modelWeight).toBe(0.65);
139 + expect(r.compsEstimate).not.toBeNull();
140 + const expected = 0.65 * 920000 + 0.35 * r.compsEstimate!;
141 + expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100);
142 + });
143 +
144 + it("retombe sur le modèle seul sans comparables", () => {
145 + const r = estimate(SUBJECT, [], INDEX, NOW);
146 + expect(r.modelWeight).toBe(1);
147 + expect(r.estimate).toBe(920000);
148 + });
149 +
150 + it("jamais d'estimation sans niveau de confiance", () => {
151 + const r = estimate(SUBJECT, candidates, INDEX, NOW);
152 + expect(r.confidencePct).toBeGreaterThan(0);
153 + expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel);
154 + });
155 +});
added app/src/lib/engine.ts +312 −0
@@ -0,0 +1,312 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Moteur d'estimation ValoPlex — transparent par conception.
4 + *
5 + * Deux sources combinées :
6 + * 1. Modèle hédonique (LightGBM, entraîné sur ~690 k ventes QC 2021-2026,
7 + * pré-calculé pour chaque unité d'évaluation) — colonne est_2026 + P10/P90.
8 + * 2. Comparables : ventes réelles proches, ajustées (marché, superficie, âge),
9 + * médiane pondérée (distance, récence, similarité).
10 + *
11 + * Chaque ajustement est retourné en dollars pour affichage — aucune boîte noire.
12 + */
13 +
14 +export interface Subject {
15 + lat: number;
16 + lng: number;
17 + typeProp: string; // toujours « plex » dans ValoPlex
18 + doors?: number | null; // nombre de logements (portes)
19 + floorArea?: number | null;
20 + yearBuilt?: number | null;
21 + landArea?: number | null;
22 + modelEstimate?: number | null;
23 + modelP10?: number | null;
24 + modelP90?: number | null;
25 +}
26 +
27 +export interface CompInput {
28 + id: string;
29 + date: string; // YYYY-MM-DD
30 + amount: number;
31 + lat: number;
32 + lng: number;
33 + propertyType: string | null;
34 + doors: number | null;
35 + yearBuilt: number | null;
36 + floorArea: number | null;
37 + street: string | null;
38 + city: string | null;
39 +}
40 +
41 +export interface AdjustedComp extends CompInput {
42 + distanceM: number;
43 + monthsAgo: number;
44 + adjTime: number;
45 + adjArea: number;
46 + adjAge: number;
47 + adjDoors: number;
48 + adjustedPrice: number;
49 + weight: number;
50 +}
51 +
52 +export interface MarketIndexPoint {
53 + month: string; // YYYY-MM
54 + idx: number; // 1.0 = niveau actuel
55 +}
56 +
57 +export interface EstimateResult {
58 + estimate: number;
59 + low: number;
60 + high: number;
61 + confidencePct: number;
62 + confidenceLevel: "A" | "B" | "C" | "D";
63 + modelEstimate: number | null;
64 + compsEstimate: number | null;
65 + modelWeight: number;
66 + comps: AdjustedComp[];
67 + nCompsUsed: number;
68 + compsDispersionPct: number | null;
69 +}
70 +
71 +/** Correspondance type d'unité (rôle) -> types de transactions comparables. */
72 +export const TYPE_MATCH: Record<string, string[]> = {
73 + unifamilial: ["unifamilial"],
74 + plex: ["plex"],
75 + condo_ou_multi: ["condo", "plex"],
76 + chalet: ["unifamilial", "indéterminé"],
77 + maison_mobile: ["unifamilial", "indéterminé"],
78 + terrain: ["indéterminé"],
79 + autre: ["unifamilial", "condo", "plex", "indéterminé"],
80 +};
81 +
82 +export function haversineM(
83 + lat1: number,
84 + lng1: number,
85 + lat2: number,
86 + lng2: number
87 +): number {
88 + const R = 6371000;
89 + const toRad = (d: number) => (d * Math.PI) / 180;
90 + const dLat = toRad(lat2 - lat1);
91 + const dLng = toRad(lng2 - lng1);
92 + const a =
93 + Math.sin(dLat / 2) ** 2 +
94 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
95 + return 2 * R * Math.asin(Math.sqrt(a));
96 +}
97 +
98 +export function monthsBetween(fromISO: string, toISO: string): number {
99 + const a = new Date(fromISO);
100 + const b = new Date(toISO);
101 + return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44);
102 +}
103 +
104 +/** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */
105 +export function timeFactor(
106 + saleMonth: string,
107 + index: MarketIndexPoint[]
108 +): number {
109 + if (index.length === 0) return 1;
110 + const last = index[index.length - 1].idx;
111 + let saleIdx: number | null = null;
112 + for (const p of index) {
113 + if (p.month <= saleMonth) saleIdx = p.idx;
114 + }
115 + if (saleIdx === null) saleIdx = index[0].idx;
116 + if (saleIdx <= 0) return 1;
117 + return last / saleIdx;
118 +}
119 +
120 +/** Ajustement superficie : 50 % du $/m² marginal du comparable. */
121 +export function areaAdjustment(
122 + subjectArea: number | null | undefined,
123 + comp: { floorArea: number | null; amount: number }
124 +): number {
125 + if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0;
126 + const ppm2 = comp.amount / comp.floorArea;
127 + const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2;
128 + // borné à ±25 % du prix du comparable
129 + const cap = 0.25 * comp.amount;
130 + return Math.max(-cap, Math.min(cap, adj));
131 +}
132 +
133 +/** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */
134 +export function ageAdjustment(
135 + subjectYear: number | null | undefined,
136 + comp: { yearBuilt: number | null; amount: number }
137 +): number {
138 + if (!subjectYear || !comp.yearBuilt) return 0;
139 + const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005));
140 + return pct * comp.amount;
141 +}
142 +
143 +/** Ajustement portes (plex) : 50 % du prix par porte du comparable, borné à ±30 %. */
144 +export function doorsAdjustment(
145 + subjectDoors: number | null | undefined,
146 + comp: { doors: number | null; amount: number }
147 +): number {
148 + if (!subjectDoors || !comp.doors || comp.doors <= 0) return 0;
149 + const perDoor = comp.amount / comp.doors;
150 + const adj = (subjectDoors - comp.doors) * 0.5 * perDoor;
151 + const cap = 0.3 * comp.amount;
152 + return Math.max(-cap, Math.min(cap, adj));
153 +}
154 +
155 +export function weightedMedian(values: number[], weights: number[]): number {
156 + const order = values
157 + .map((v, i) => ({ v, w: weights[i] }))
158 + .sort((a, b) => a.v - b.v);
159 + const total = order.reduce((s, o) => s + o.w, 0);
160 + if (total <= 0) return NaN;
161 + let acc = 0;
162 + for (const o of order) {
163 + acc += o.w;
164 + if (acc >= total / 2) return o.v;
165 + }
166 + return order[order.length - 1].v;
167 +}
168 +
169 +export function adjustComps(
170 + subject: Subject,
171 + candidates: CompInput[],
172 + index: MarketIndexPoint[],
173 + nowISO: string
174 +): AdjustedComp[] {
175 + // tous les candidats sont des plex ; on filtre par similarité de portes
176 + let pool = candidates;
177 + if (subject.doors) {
178 + for (const tol of [1, 3]) {
179 + const filtered = candidates.filter(
180 + (c) => c.doors != null && Math.abs(c.doors - subject.doors!) <= tol
181 + );
182 + if (filtered.length >= 6) {
183 + pool = filtered;
184 + break;
185 + }
186 + }
187 + }
188 +
189 + // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu
190 + if (subject.floorArea) {
191 + for (const tol of [0.2, 0.4]) {
192 + const filtered = pool.filter(
193 + (c) =>
194 + c.floorArea != null &&
195 + Math.abs(c.floorArea - subject.floorArea!) <=
196 + tol * subject.floorArea!
197 + );
198 + if (filtered.length >= 6) {
199 + pool = filtered;
200 + break;
201 + }
202 + }
203 + }
204 +
205 + const comps = pool.map((c) => {
206 + const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng);
207 + const monthsAgo = monthsBetween(c.date, nowISO);
208 + const tf = timeFactor(c.date.slice(0, 7), index);
209 + const adjTime = c.amount * (tf - 1);
210 + const adjArea = areaAdjustment(subject.floorArea, c);
211 + const adjAge = ageAdjustment(subject.yearBuilt, c);
212 + const adjDoors = doorsAdjustment(subject.doors, c);
213 + const adjustedPrice = c.amount + adjTime + adjArea + adjAge + adjDoors;
214 + const areaDiffPct =
215 + subject.floorArea && c.floorArea
216 + ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea
217 + : 0.15;
218 + const doorsDiff =
219 + subject.doors && c.doors ? Math.abs(c.doors - subject.doors) : 0.5;
220 + const weight =
221 + Math.exp(-((distanceM / 1500) ** 2)) *
222 + Math.exp(-((monthsAgo / 24) ** 2)) *
223 + Math.exp(-((areaDiffPct / 0.25) ** 2)) *
224 + Math.exp(-((doorsDiff / 2.5) ** 2));
225 + return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjDoors, adjustedPrice, weight };
226 + });
227 +
228 + return comps
229 + .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4)
230 + .sort((a, b) => b.weight - a.weight)
231 + .slice(0, 12);
232 +}
233 +
234 +function pctDispersion(comps: AdjustedComp[], center: number): number | null {
235 + if (comps.length < 3 || center <= 0) return null;
236 + const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center);
237 + dev.sort((a, b) => a - b);
238 + return dev[Math.floor(dev.length / 2)] * 100;
239 +}
240 +
241 +export function estimate(
242 + subject: Subject,
243 + candidates: CompInput[],
244 + index: MarketIndexPoint[],
245 + nowISO: string
246 +): EstimateResult {
247 + const comps = adjustComps(subject, candidates, index, nowISO);
248 + const compsEstimate =
249 + comps.length >= 3
250 + ? weightedMedian(
251 + comps.map((c) => c.adjustedPrice),
252 + comps.map((c) => c.weight)
253 + )
254 + : null;
255 +
256 + const model = subject.modelEstimate ?? null;
257 + let modelWeight = 0;
258 + let estimateValue: number;
259 + if (model !== null && compsEstimate !== null) {
260 + // pondération dynamique : plus les comparables s'éloignent du sujet en
261 + // nombre de portes, plus le modèle spécialisé pèse lourd
262 + const doorGap =
263 + subject.doors && comps.length
264 + ? comps.reduce((a, c) => a + Math.abs((c.doors ?? subject.doors!) - subject.doors!), 0) / comps.length
265 + : 0;
266 + modelWeight = doorGap > 3 ? 0.85 : doorGap > 1.5 ? 0.75 : 0.65;
267 + estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate;
268 + } else if (model !== null) {
269 + modelWeight = 1;
270 + estimateValue = model;
271 + } else if (compsEstimate !== null) {
272 + estimateValue = compsEstimate;
273 + } else {
274 + estimateValue = NaN;
275 + }
276 +
277 + // fourchette : intervalle du modèle recentré, sinon dispersion des comparables
278 + let low: number, high: number;
279 + if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) {
280 + low = estimateValue * (subject.modelP10 / model);
281 + high = estimateValue * (subject.modelP90 / model);
282 + } else {
283 + const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25;
284 + low = estimateValue * (1 - disp / 100);
285 + high = estimateValue * (1 + disp / 100);
286 + }
287 +
288 + const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue);
289 + // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle
290 + let score = 35;
291 + score += Math.min(25, comps.length * 2.5);
292 + if (dispersion !== null) score += Math.max(0, 20 - dispersion);
293 + if (model !== null) score += 15;
294 + const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100;
295 + score -= Math.max(0, (widthPct - 30) / 3);
296 + score = Math.max(5, Math.min(97, score));
297 + const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D";
298 +
299 + return {
300 + estimate: Math.round(estimateValue / 100) * 100,
301 + low: Math.round(low / 100) * 100,
302 + high: Math.round(high / 100) * 100,
303 + confidencePct: Math.round(score),
304 + confidenceLevel: level,
305 + modelEstimate: model,
306 + compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null,
307 + modelWeight,
308 + comps,
309 + nCompsUsed: comps.length,
310 + compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null,
311 + };
312 +}
added app/src/lib/estimator.ts +304 −0
@@ -0,0 +1,304 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Service d'estimation : relie la base SQLite au moteur (engine.ts).
3 +import {
4 + getCandidates,
5 + getMarketIndex,
6 + getUnit,
7 + municipalityCenter,
8 + type TxRow,
9 + type UnitRow,
10 +} from "./db";
11 +import { estimate, type CompInput, type EstimateResult, type Subject } from "./engine";
12 +
13 +export interface UnitSpecs {
14 + cubf: number | null;
15 + cubfLibelle: string | null;
16 + anneeEstimee: string | null;
17 + frontTerrainM: number | null;
18 + nbEtages: number | null;
19 + nbLocauxNonResid: number | null;
20 + nbChambresLocatives: number | null;
21 + lienPhysique: string | null;
22 + genreConstruction: string | null;
23 + matricule: string | null;
24 + uniteVoisinage: string | null;
25 + nAdresses: number | null;
26 + datCondMarche: string | null;
27 + valeurTerrain: number | null;
28 + valeurBatiment: number | null;
29 + valeurAnterieure: number | null;
30 + arrond: string | null;
31 + apt: string | null;
32 + portesAdresses: string[];
33 +}
34 +
35 +export interface UnitEstimate {
36 + unit: {
37 + id: string;
38 + adresse: string | null;
39 + municipalite: string | null;
40 + typeProp: string;
41 + cubfLibelle: string | null;
42 + anneeConstruction: number | null;
43 + aireEtagesM2: number | null;
44 + superficieTerrainM2: number | null;
45 + nbLogements: number | null;
46 + valeurRole: number | null;
47 + lat: number;
48 + lng: number;
49 + history: { year: number; value: number | null }[];
50 + specs: UnitSpecs;
51 + } | null;
52 + result: EstimateResult;
53 +}
54 +
55 +/** Type de comparable attendu pour le type de tx du marché de l'indice. */
56 +export function indexType(): string {
57 + return "plex"; // ValoPlex : un seul segment de marché
58 +}
59 +
60 +function toComps(rows: TxRow[]): CompInput[] {
61 + return rows.map((r) => ({
62 + id: r.id,
63 + date: r.date,
64 + amount: r.amount,
65 + lat: r.lat,
66 + lng: r.lng,
67 + propertyType: r.property_type,
68 + doors: r.portes,
69 + yearBuilt: r.year_built,
70 + floorArea: r.floor_area,
71 + street: r.street,
72 + city: r.city,
73 + }));
74 +}
75 +
76 +function nowISO(): string {
77 + return new Date().toISOString().slice(0, 10);
78 +}
79 +
80 +function since(months: number): string {
81 + const d = new Date();
82 + d.setMonth(d.getMonth() - months);
83 + return d.toISOString().slice(0, 10);
84 +}
85 +
86 +function candidatesAround(lat: number, lng: number): TxRow[] {
87 + // ~2,2 km puis élargit à ~11 km si marché mince
88 + for (const halfDeg of [0.02, 0.1]) {
89 + const rows = getCandidates(lat, lng, halfDeg, since(30), 600);
90 + if (rows.length >= 30) return rows;
91 + }
92 + return getCandidates(lat, lng, 0.25, since(36), 600);
93 +}
94 +
95 +export function estimateByUnitId(id: string): UnitEstimate | null {
96 + const u = getUnit(id);
97 + if (!u) return null;
98 + const subject: Subject = {
99 + lat: u.lat,
100 + lng: u.lng,
101 + typeProp: "plex",
102 + doors: u.nb_logements,
103 + floorArea: u.aire_etages_m2,
104 + yearBuilt: u.annee_construction,
105 + landArea: u.superficie_terrain_m2,
106 + modelEstimate: u.est_2026,
107 + modelP10: u.p10,
108 + modelP90: u.p90,
109 + };
110 + const result = estimate(
111 + subject,
112 + toComps(candidatesAround(u.lat, u.lng)),
113 + getMarketIndex(indexType()),
114 + nowISO()
115 + );
116 + return {
117 + unit: {
118 + id: u.id_provinc,
119 + adresse: u.adresse,
120 + municipalite: u.municipalite,
121 + typeProp: u.type_prop,
122 + cubfLibelle: u.cubf_libelle,
123 + anneeConstruction: u.annee_construction,
124 + aireEtagesM2: u.aire_etages_m2,
125 + superficieTerrainM2: u.superficie_terrain_m2,
126 + nbLogements: u.nb_logements,
127 + valeurRole: u.valeur_role,
128 + lat: u.lat,
129 + lng: u.lng,
130 + history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({
131 + year: y,
132 + value: u[`est_${y}` as keyof UnitRow] as number | null,
133 + })),
134 + specs: {
135 + cubf: u.cubf,
136 + cubfLibelle: u.cubf_libelle,
137 + anneeEstimee: u.annee_estimee,
138 + frontTerrainM: u.front_terrain_m,
139 + nbEtages: u.nb_etages,
140 + nbLocauxNonResid: u.nb_locaux_non_resid,
141 + nbChambresLocatives: u.nb_chambres_locatives,
142 + lienPhysique: u.lien_physique,
143 + genreConstruction: u.genre_construction,
144 + matricule: u.matricule,
145 + uniteVoisinage: u.unite_voisinage,
146 + nAdresses: u.n_adresses,
147 + datCondMarche: u.dat_cond_marche,
148 + valeurTerrain: u.valeur_terrain,
149 + valeurBatiment: u.valeur_batiment,
150 + valeurAnterieure: u.valeur_anterieure,
151 + arrond: u.arrond,
152 + apt: u.apt,
153 + portesAdresses: (() => {
154 + try {
155 + return u.adresses_json ? (JSON.parse(u.adresses_json) as string[]) : [];
156 + } catch {
157 + return [];
158 + }
159 + })(),
160 + },
161 + },
162 + result,
163 + };
164 +}
165 +
166 +export interface PortfolioAggregates {
167 + count: number;
168 + totalEstimate: number;
169 + totalLow: number;
170 + totalHigh: number;
171 + totalRole: number;
172 + ecartRolePct: number | null;
173 + confidencePct: number; // moyenne pondérée par valeur
174 + confidenceLevel: "A" | "B" | "C" | "D";
175 + totalFloorArea: number;
176 + totalLandArea: number;
177 + totalDwellings: number;
178 + avgYearBuilt: number | null;
179 + municipalities: { name: string; count: number; total: number }[];
180 + types: { type: string; count: number; total: number }[];
181 + history: { year: number; total: number; nCovered: number }[];
182 + growthPct: number | null; // croissance du parc 2021→2026 (unités couvertes)
183 +}
184 +
185 +export interface PortfolioResult {
186 + items: UnitEstimate[];
187 + aggregates: PortfolioAggregates;
188 +}
189 +
190 +export function estimatePortfolio(ids: string[]): PortfolioResult {
191 + const items = ids
192 + .slice(0, 40)
193 + .map((id) => estimateByUnitId(id))
194 + .filter((x): x is UnitEstimate => x !== null && x.unit !== null);
195 +
196 + const val = (n: number | null | undefined) => n ?? 0;
197 + const totalEstimate = items.reduce((s, i) => s + val(i.result.estimate), 0);
198 + const totalRole = items.reduce((s, i) => s + val(i.unit!.valeurRole), 0);
199 + const wConf =
200 + totalEstimate > 0
201 + ? items.reduce((s, i) => s + i.result.confidencePct * val(i.result.estimate), 0) /
202 + totalEstimate
203 + : 0;
204 + const level = wConf >= 75 ? "A" : wConf >= 60 ? "B" : wConf >= 45 ? "C" : "D";
205 +
206 + const byKey = (key: (i: UnitEstimate) => string) => {
207 + const m = new Map<string, { count: number; total: number }>();
208 + for (const i of items) {
209 + const k = key(i);
210 + const e = m.get(k) ?? { count: 0, total: 0 };
211 + e.count += 1;
212 + e.total += val(i.result.estimate);
213 + m.set(k, e);
214 + }
215 + return [...m.entries()]
216 + .map(([name, v]) => ({ name, ...v }))
217 + .sort((a, b) => b.total - a.total);
218 + };
219 +
220 + const years = [2021, 2022, 2023, 2024, 2025, 2026] as const;
221 + const history = years.map((y) => {
222 + let total = 0;
223 + let n = 0;
224 + for (const i of items) {
225 + const h = i.unit!.history.find((hh) => hh.year === y);
226 + if (h?.value != null) {
227 + total += h.value;
228 + n += 1;
229 + }
230 + }
231 + return { year: y, total, nCovered: n };
232 + });
233 + // croissance : somme 2026 vs 2021 sur les unités couvertes aux deux bornes
234 + let g21 = 0;
235 + let g26 = 0;
236 + for (const i of items) {
237 + const a = i.unit!.history.find((h) => h.year === 2021)?.value;
238 + const b = i.unit!.history.find((h) => h.year === 2026)?.value;
239 + if (a != null && b != null) {
240 + g21 += a;
241 + g26 += b;
242 + }
243 + }
244 +
245 + const yb = items
246 + .map((i) => i.unit!.anneeConstruction)
247 + .filter((v): v is number => v != null && v > 1600);
248 +
249 + return {
250 + items,
251 + aggregates: {
252 + count: items.length,
253 + totalEstimate,
254 + totalLow: items.reduce((s, i) => s + val(i.result.low), 0),
255 + totalHigh: items.reduce((s, i) => s + val(i.result.high), 0),
256 + totalRole,
257 + ecartRolePct: totalRole > 0 ? (totalEstimate / totalRole - 1) * 100 : null,
258 + confidencePct: Math.round(wConf),
259 + confidenceLevel: level,
260 + totalFloorArea: Math.round(items.reduce((s, i) => s + val(i.unit!.aireEtagesM2), 0)),
261 + totalLandArea: Math.round(items.reduce((s, i) => s + val(i.unit!.superficieTerrainM2), 0)),
262 + totalDwellings: items.reduce((s, i) => s + val(i.unit!.nbLogements), 0),
263 + avgYearBuilt: yb.length ? Math.round(yb.reduce((s, v) => s + v, 0) / yb.length) : null,
264 + municipalities: byKey((i) => i.unit!.municipalite ?? "—"),
265 + types: byKey((i) => i.unit!.typeProp).map(({ name, ...v }) => ({ type: name, ...v })),
266 + history,
267 + growthPct: g21 > 0 ? (g26 / g21 - 1) * 100 : null,
268 + },
269 + };
270 +}
271 +
272 +export interface ManualParams {
273 + municipality: string;
274 + typeProp: string;
275 + portes?: number;
276 + floorArea?: number;
277 + yearBuilt?: number;
278 + landArea?: number;
279 +}
280 +
281 +export function estimateManual(p: ManualParams): UnitEstimate | null {
282 + const center = municipalityCenter(p.municipality);
283 + if (!center || !center.n) return null;
284 + const subject: Subject = {
285 + lat: center.lat,
286 + lng: center.lng,
287 + typeProp: "plex",
288 + doors: p.portes ?? null,
289 + floorArea: p.floorArea ?? null,
290 + yearBuilt: p.yearBuilt ?? null,
291 + landArea: p.landArea ?? null,
292 + modelEstimate: null,
293 + modelP10: null,
294 + modelP90: null,
295 + };
296 + const result = estimate(
297 + subject,
298 + toComps(candidatesAround(center.lat, center.lng)),
299 + getMarketIndex(indexType()),
300 + nowISO()
301 + );
302 + if (!Number.isFinite(result.estimate)) return null;
303 + return { unit: null, result };
304 +}
added app/src/lib/i18n.ts +117 −0
@@ -0,0 +1,117 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +export type Lang = "fr" | "en";
3 +
4 +const dict = {
5 + fr: {
6 + tagline: "La vraie valeur de votre plex, porte par porte",
7 + sub: "ValoPlex est le moteur d'évaluation spécialisé plex du Québec : 393 865 immeubles de 2 logements et plus, 94 000 ventes réelles de plex (2021-2026), un modèle hédonique avancé dédié. Pas de boîte noire : on vous montre le calcul.",
8 + searchPlaceholder: "Entrez l'adresse du plex (ex. 4562 rue Chabot, Montréal)",
9 + estimateBtn: "Évaluer",
10 + manualTitle: "Pas d'adresse ? Décrivez le plex",
11 + municipality: "Municipalité",
12 + propertyType: "Type de propriété",
13 + portes: "Nombre de logements (portes)",
14 + floorArea: "Superficie habitable (m²)",
15 + yearBuilt: "Année de construction",
16 + landArea: "Superficie du terrain (m²)",
17 + estimatedValue: "Valeur estimée",
18 + valeurParPorte: "Valeur par porte",
19 + range: "Fourchette calibrée (80 %)",
20 + confidence: "Indice de confiance",
21 + whyTitle: "Pourquoi ce prix ? Le calcul, sans boîte noire",
22 + modelPart: "Modèle hédonique plex en ratio (LightGBM, 91 000 ventes)",
23 + compsPart: "Plex comparables ajustés (médiane pondérée)",
24 + weightModel: "poids du modèle",
25 + weightComps: "poids des comparables",
26 + compsTitle: "Ventes de plex comparables",
27 + adjTime: "Ajust. marché",
28 + adjArea: "Ajust. superficie",
29 + adjAge: "Ajust. âge",
30 + adjPortes: "Ajust. portes",
31 + adjusted: "Prix ajusté",
32 + sold: "Vendu",
33 + distance: "Distance",
34 + weight: "Poids",
35 + history: "Valeur estimée par année",
36 + assessed: "Évaluation municipale (rôle 2026)",
37 + leadTitle: "Recevoir le rapport détaillé (PDF)",
38 + leadBtn: "M'envoyer le rapport",
39 + leadOk: "Merci ! Votre rapport sera envoyé sous peu.",
40 + emailPlaceholder: "votre@courriel.com",
41 + disclaimer: "Estimation statistique à titre indicatif — ne remplace pas une évaluation professionnelle.",
42 + unifamilial: "Unifamiliale",
43 + plex: "Plex",
44 + condo_ou_multi: "Condo / multirésidentiel",
45 + chalet: "Chalet",
46 + maison_mobile: "Maison mobile",
47 + terrain: "Terrain",
48 + autre: "Autre",
49 + notFound: "Plex introuvable",
50 + dataNote: "Données : rôles d'évaluation foncière du Québec (MAMH) et ventes de plex publiées.",
51 + confA: "Très fiable",
52 + confB: "Fiable",
53 + confC: "Indicative",
54 + confD: "Peu fiable — marché mince",
55 + searchNoResult: "Aucun plex trouvé — essayez « numéro rue municipalité »",
56 + loading: "Calcul en cours…",
57 + },
58 + en: {
59 + tagline: "Your plex's true value, door by door",
60 + sub: "ValoPlex is Québec's plex-specialized valuation engine: 393,865 buildings of 2+ dwellings, 94,000 real plex sales (2021-2026), a dedicated advanced hedonic model. No black box: we show the math.",
61 + searchPlaceholder: "Enter the plex address (e.g. 4562 rue Chabot, Montréal)",
62 + estimateBtn: "Value it",
63 + manualTitle: "No address? Describe the plex",
64 + municipality: "Municipality",
65 + propertyType: "Property type",
66 + portes: "Number of dwellings (doors)",
67 + floorArea: "Living area (m²)",
68 + yearBuilt: "Year built",
69 + landArea: "Lot area (m²)",
70 + estimatedValue: "Estimated value",
71 + valeurParPorte: "Value per door",
72 + range: "Calibrated range (80%)",
73 + confidence: "Confidence index",
74 + whyTitle: "Why this price? The math, no black box",
75 + modelPart: "Ratio-based plex hedonic model (LightGBM, 91k sales)",
76 + compsPart: "Adjusted comparable plexes (weighted median)",
77 + weightModel: "model weight",
78 + weightComps: "comparables weight",
79 + compsTitle: "Comparable plex sales",
80 + adjTime: "Market adj.",
81 + adjArea: "Area adj.",
82 + adjAge: "Age adj.",
83 + adjPortes: "Doors adj.",
84 + adjusted: "Adjusted price",
85 + sold: "Sold",
86 + distance: "Distance",
87 + weight: "Weight",
88 + history: "Estimated value by year",
89 + assessed: "Municipal assessment (2026 roll)",
90 + leadTitle: "Get the detailed report (PDF)",
91 + leadBtn: "Send me the report",
92 + leadOk: "Thanks! Your report is on its way.",
93 + emailPlaceholder: "you@email.com",
94 + disclaimer: "Statistical estimate for information only — not a substitute for a professional appraisal.",
95 + unifamilial: "Single-family",
96 + plex: "Plex",
97 + condo_ou_multi: "Condo / multi-residential",
98 + chalet: "Cottage",
99 + maison_mobile: "Mobile home",
100 + terrain: "Land",
101 + autre: "Other",
102 + notFound: "Plex not found",
103 + dataNote: "Data: Québec property assessment rolls (MAMH) and published plex sales.",
104 + confA: "Very reliable",
105 + confB: "Reliable",
106 + confC: "Indicative",
107 + confD: "Low reliability — thin market",
108 + searchNoResult: "No plex found — try “number street municipality”",
109 + loading: "Computing…",
110 + },
111 +} as const;
112 +
113 +export type TKey = keyof (typeof dict)["fr"];
114 +
115 +export function t(lang: Lang, key: TKey): string {
116 + return dict[lang][key] ?? dict.fr[key];
117 +}
added app/src/lib/proforma.test.ts +123 −0
@@ -0,0 +1,123 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { describe, expect, it } from "vitest";
3 +import {
4 + buildProforma,
5 + droitsMutation,
6 + paiementMensuel,
7 + soldeApresMois,
8 + tauxMensuelCanadien,
9 + tgaReference,
10 +} from "./proforma";
11 +
12 +describe("tauxMensuelCanadien", () => {
13 + it("composition semestrielle : 6 % annuel → ~0,4939 % mensuel", () => {
14 + expect(tauxMensuelCanadien(6)).toBeCloseTo(0.0049386, 6);
15 + });
16 +});
17 +
18 +describe("paiementMensuel (hypothèque canadienne)", () => {
19 + it("cas de référence : 100 000 $ à 6 % sur 25 ans ≈ 639,81 $", () => {
20 + expect(paiementMensuel(100000, 6, 25)).toBeCloseTo(639.81, 0);
21 + });
22 + it("principal nul → 0", () => {
23 + expect(paiementMensuel(0, 5, 25)).toBe(0);
24 + });
25 +});
26 +
27 +describe("droitsMutation", () => {
28 + it("barème QC standard : 500 000 $ hors Montréal", () => {
29 + // 61 500×0,5% + (307 800−61 500)×1% + (500 000−307 800)×1,5%
30 + const attendu = Math.round(61500 * 0.005 + (307800 - 61500) * 0.01 + (500000 - 307800) * 0.015);
31 + expect(droitsMutation(500000, false)).toBe(attendu);
32 + });
33 + it("Montréal > QC standard pour un prix élevé", () => {
34 + expect(droitsMutation(2000000, true)).toBeGreaterThan(droitsMutation(2000000, false));
35 + });
36 + it("identiques sous 307 800 $", () => {
37 + expect(droitsMutation(250000, true)).toBe(droitsMutation(250000, false));
38 + });
39 +});
40 +
41 +describe("tgaReference", () => {
42 + it("croît avec le gabarit", () => {
43 + expect(tgaReference(2)).toBeLessThan(tgaReference(6));
44 + expect(tgaReference(6)).toBeLessThan(tgaReference(20));
45 + });
46 +});
47 +
48 +describe("soldeApresMois", () => {
49 + it("décroît avec le temps et atteint ~0 à l'échéance", () => {
50 + const s12 = soldeApresMois(300000, 5, 25, 12);
51 + const s60 = soldeApresMois(300000, 5, 25, 60);
52 + expect(s12).toBeLessThan(300000);
53 + expect(s60).toBeLessThan(s12);
54 + expect(soldeApresMois(300000, 5, 25, 300)).toBeLessThan(1);
55 + });
56 +});
57 +
58 +describe("buildProforma", () => {
59 + const pf = buildProforma(1_095_300, 3, 2_160_600, "Montréal");
60 +
61 + it("le RNE implicite correspond au TGA de référence appliqué à la valeur", () => {
62 + expect(pf.rne).toBeCloseTo(1_095_300 * (pf.params.tgaPct / 100), 0);
63 + expect(pf.tgaImplicitePct).toBeCloseTo(pf.params.tgaPct, 1);
64 + });
65 +
66 + it("le loyer implicite reconstruit exactement les revenus bruts", () => {
67 + expect(pf.loyerMoyenMensuel * 12 * 3).toBeCloseTo(pf.revenusBruts, 6);
68 + });
69 +
70 + it("la cascade comptable est cohérente : RE + dépenses = RNE", () => {
71 + expect(pf.revenusEffectifs + pf.totalDepenses).toBeCloseTo(pf.rne, 6);
72 + });
73 +
74 + it("cashflow = RNE − service de la dette", () => {
75 + expect(pf.cashflowAnnuel).toBeCloseTo(pf.rne - pf.serviceDetteAnnuel, 6);
76 + });
77 +
78 + it("liquidités = mise de fonds + mutation + notaire + inspection", () => {
79 + expect(pf.liquiditesRequises).toBeCloseTo(
80 + pf.miseDeFonds + pf.droitsMutation + pf.fraisNotaire + pf.fraisInspection, 6);
81 + });
82 +
83 + it("un taux plus élevé réduit le cashflow et le DSCR", () => {
84 + const cher = buildProforma(1_095_300, 3, 2_160_600, "Montréal", { tauxHypoPct: 7 });
85 + expect(cher.cashflowAnnuel).toBeLessThan(pf.cashflowAnnuel);
86 + expect(cher.dscr).toBeLessThan(pf.dscr);
87 + });
88 +
89 + it("plus de mise de fonds améliore le cashflow", () => {
90 + const gros = buildProforma(1_095_300, 3, 2_160_600, "Montréal", { miseDeFondsPct: 40 });
91 + expect(gros.cashflowAnnuel).toBeGreaterThan(pf.cashflowAnnuel);
92 + });
93 +
94 + it("capital an 1 + intérêts an 1 = service de la dette", () => {
95 + expect(pf.capitalAn1 + pf.interetsAn1).toBeCloseTo(pf.serviceDetteAnnuel, 4);
96 + });
97 +
98 + it("équité 5 ans = valeur projetée − solde", () => {
99 + expect(pf.equite5Ans).toBeCloseTo(pf.valeur5Ans - pf.soldeHypo5Ans, 4);
100 + });
101 +
102 + it("au loyer de point mort, le cashflow est nul (reconstruit)", () => {
103 + // RB' au point mort → RNE' = service de dette
104 + const rbPM = pf.loyerPointMort * 12 * 3;
105 + const denom = 1 - pf.params.vacancePct / 100 - pf.params.gestionPct / 100 - pf.params.entretienPct / 100;
106 + const F = -pf.depenses.filter((d) => !["entretien", "gestion"].includes(d.key))
107 + .reduce((a, d) => a + d.amount, 0);
108 + const rnePM = rbPM * denom - F;
109 + expect(rnePM).toBeCloseTo(pf.serviceDetteAnnuel, 0);
110 + });
111 +
112 + it("le taux de point mort annule le cashflow", () => {
113 + if (pf.tauxPointMortPct != null) {
114 + const cf = pf.rne - 12 * paiementMensuel(pf.hypotheque, pf.tauxPointMortPct, pf.params.amortAns);
115 + expect(Math.abs(cf)).toBeLessThan(1);
116 + }
117 + });
118 +
119 + it("les taxes sont dérivées de la valeur au rôle", () => {
120 + const taxes = pf.depenses.find((d) => d.key === "taxesMunicipales")!;
121 + expect(-taxes.amount).toBeCloseTo(2_160_600 * 0.011, 2);
122 + });
123 +});
added app/src/lib/proforma.ts +290 −0
@@ -0,0 +1,290 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Pro forma d'investisseur ValoPlex — approche revenu inversée.
4 + *
5 + * Partant de la valeur estimée V du plex, on répond à la question de
6 + * l'investisseur : « quelle structure de revenus et de dépenses ce prix
7 + * suppose-t-il, et que donne le financement au taux d'aujourd'hui ? »
8 + *
9 + * 1. TGA (taux global d'actualisation) de référence par gabarit ;
10 + * 2. RNE implicite = V × TGA ;
11 + * 3. loyer moyen implicite par porte : forme fermée
12 + * RB = (V·TGA + F) / (1 − vacance − v) où F = dépenses fixes,
13 + * v = part des dépenses proportionnelles aux revenus ;
14 + * 4. dépenses détaillées « tout le kit » : taxes municipales et scolaires
15 + * (dérivées de la valeur au rôle), assurances, entretien, gestion,
16 + * déneigement/pelouse, réserve de remplacement ;
17 + * 5. financement : hypothèque canadienne (composition semestrielle),
18 + * PMT mensuel, cashflow, DSCR, cash-on-cash ;
19 + * 6. frais d'acquisition : droits de mutation (barème QC / barème bonifié
20 + * de Montréal), notaire, inspection.
21 + *
22 + * Toutes les hypothèses sont paramétrables et affichées — zéro boîte noire.
23 + */
24 +
25 +export interface ProformaParams {
26 + tauxHypoPct: number; // taux hypothécaire annuel (5 ans fixe), ex. 4.75
27 + amortAns: number; // amortissement, ex. 25
28 + miseDeFondsPct: number; // ex. 25
29 + vacancePct: number; // provision vacance + mauvaises créances, ex. 3
30 + gestionPct: number; // % des revenus bruts, ex. 4 (0 si autogéré)
31 + entretienPct: number; // % des revenus bruts, ex. 5
32 + appreciationPct: number; // appréciation annuelle supposée de la valeur, ex. 2.5
33 + tgaPct?: number; // TGA imposé ; sinon référence par gabarit
34 +}
35 +
36 +export const DEFAULT_PARAMS: ProformaParams = {
37 + tauxHypoPct: 4.75,
38 + amortAns: 25,
39 + miseDeFondsPct: 25,
40 + vacancePct: 3,
41 + gestionPct: 4,
42 + entretienPct: 5,
43 + appreciationPct: 2.5,
44 +};
45 +
46 +/** TGA de référence par gabarit (hypothèse de marché, ajustable). */
47 +export function tgaReference(doors: number): number {
48 + if (doors <= 3) return 4.75;
49 + if (doors <= 5) return 5.0;
50 + if (doors <= 11) return 5.25;
51 + return 5.5;
52 +}
53 +
54 +/** Taux hypothécaire canadien : composition semestrielle → taux mensuel effectif. */
55 +export function tauxMensuelCanadien(tauxAnnuelPct: number): number {
56 + const r = tauxAnnuelPct / 100;
57 + return Math.pow(1 + r / 2, 2 / 12) - 1;
58 +}
59 +
60 +/** Paiement mensuel d'une hypothèque canadienne. */
61 +export function paiementMensuel(principal: number, tauxAnnuelPct: number, amortAns: number): number {
62 + if (principal <= 0) return 0;
63 + const i = tauxMensuelCanadien(tauxAnnuelPct);
64 + const n = amortAns * 12;
65 + if (i <= 0) return principal / n;
66 + return (principal * i) / (1 - Math.pow(1 + i, -n));
67 +}
68 +
69 +/** Solde d'une hypothèque canadienne après n mois. */
70 +export function soldeApresMois(
71 + principal: number, tauxAnnuelPct: number, amortAns: number, mois: number
72 +): number {
73 + const i = tauxMensuelCanadien(tauxAnnuelPct);
74 + const pmt = paiementMensuel(principal, tauxAnnuelPct, amortAns);
75 + if (i <= 0) return Math.max(0, principal - pmt * mois);
76 + return Math.max(0, principal * Math.pow(1 + i, mois) - pmt * (Math.pow(1 + i, mois) - 1) / i);
77 +}
78 +
79 +/** Droits de mutation (« taxe de Bienvenue ») — barèmes 2026 approximatifs. */
80 +export function droitsMutation(prix: number, montreal: boolean): number {
81 + // tranches provinciales indexées (approx. 2026)
82 + const t1 = 61_500;
83 + const t2 = 307_800;
84 + let d = Math.min(prix, t1) * 0.005;
85 + if (prix > t1) d += (Math.min(prix, t2) - t1) * 0.01;
86 + if (prix > t2) {
87 + if (!montreal) {
88 + d += (prix - t2) * 0.015;
89 + } else {
90 + // barème bonifié de Montréal (approx. 2026)
91 + const m1 = 615_700, m2 = 1_231_400, m3 = 2_366_200, m4 = 3_113_000;
92 + d += (Math.min(prix, m1) - t2) * 0.015;
93 + if (prix > m1) d += (Math.min(prix, m2) - m1) * 0.02;
94 + if (prix > m2) d += (Math.min(prix, m3) - m2) * 0.025;
95 + if (prix > m3) d += (Math.min(prix, m4) - m3) * 0.035;
96 + if (prix > m4) d += (prix - m4) * 0.04;
97 + }
98 + }
99 + return Math.round(d);
100 +}
101 +
102 +export interface ProformaLine {
103 + key: string;
104 + amount: number; // annuel, $ (négatif = dépense)
105 +}
106 +
107 +export interface Proforma {
108 + // hypothèses effectives
109 + params: Required<ProformaParams>;
110 + tgaRefPct: number;
111 + // revenus
112 + loyerMoyenMensuel: number; // par porte
113 + revenusBruts: number; // annuel
114 + vacance: number; // annuel (négatif)
115 + revenusEffectifs: number;
116 + // dépenses détaillées (annuelles, négatives)
117 + depenses: ProformaLine[];
118 + totalDepenses: number;
119 + ratioDepensesPct: number; // dépenses / revenus effectifs
120 + // exploitation
121 + rne: number; // revenu net d'exploitation (NOI)
122 + tgaImplicitePct: number; // RNE / valeur
123 + mrb: number; // multiplicateur de revenus bruts (prix / RB)
124 + // financement
125 + miseDeFonds: number;
126 + hypotheque: number;
127 + paiementMensuelHypo: number;
128 + serviceDetteAnnuel: number;
129 + cashflowAnnuel: number;
130 + cashflowMensuelParPorte: number;
131 + dscr: number; // RNE / service de la dette
132 + // acquisition « tout le kit »
133 + droitsMutation: number;
134 + fraisNotaire: number;
135 + fraisInspection: number;
136 + liquiditesRequises: number; // mise de fonds + frais
137 + cashOnCashPct: number; // cashflow / liquidités requises
138 + // remboursement de capital et projection 5 ans
139 + capitalAn1: number; // capital remboursé la 1re année
140 + interetsAn1: number;
141 + rendementTotalAn1Pct: number; // (cashflow + capital an 1) / liquidités
142 + soldeHypo5Ans: number;
143 + valeur5Ans: number; // valeur projetée à l'appréciation supposée
144 + equite5Ans: number; // valeur 5 ans − solde
145 + gainTotal5Ans: number; // cashflows + capital remboursé + plus-value
146 + multipleLiquidites5Ans: number; // équité gagnée + cashflows / liquidités
147 + // marges de sécurité
148 + loyerPointMort: number; // loyer/porte/mois où cashflow = 0
149 + margeSecuriteLoyerPct: number; // (loyer implicite − point mort) / implicite
150 + tauxPointMortPct: number | null; // taux hypo où cashflow = 0 (même amort/mise)
151 + sensibiliteTaux: { tauxPct: number; cashflowAnnuel: number; dscr: number }[];
152 +}
153 +
154 +export function buildProforma(
155 + valeur: number,
156 + doors: number,
157 + valeurRole: number | null,
158 + municipalite: string | null,
159 + partial?: Partial<ProformaParams>
160 +): Proforma {
161 + const p: Required<ProformaParams> = {
162 + ...DEFAULT_PARAMS,
163 + tgaPct: partial?.tgaPct ?? tgaReference(doors),
164 + ...Object.fromEntries(
165 + Object.entries(partial ?? {}).filter(([, v]) => v !== undefined)
166 + ),
167 + } as Required<ProformaParams>;
168 +
169 + const vr = valeurRole && valeurRole > 0 ? valeurRole : valeur;
170 + const tga = p.tgaPct / 100;
171 + const vac = p.vacancePct / 100;
172 + const vGestion = p.gestionPct / 100;
173 + const vEntretien = p.entretienPct / 100;
174 +
175 + // dépenses fixes (indépendantes des revenus)
176 + const taxesMunicipales = vr * 0.011; // ~1,1 % de la valeur au rôle
177 + const taxeScolaire = vr * 0.001; // ~0,10 %
178 + const assurances = 900 + doors * 550;
179 + const deneigementPelouse = 1_100 + doors * 140;
180 + const energieCommuns = doors * 260; // éclairage/chauffage des communs
181 + const reserveRemplacement = doors * 500;
182 + const F =
183 + taxesMunicipales + taxeScolaire + assurances +
184 + deneigementPelouse + energieCommuns + reserveRemplacement;
185 +
186 + // loyer implicite : RNE = V·TGA = RB(1−vac) − F − (vGestion+vEntretien)·RB
187 + const denom = 1 - vac - vGestion - vEntretien;
188 + const revenusBruts = Math.max(0, (valeur * tga + F) / Math.max(0.2, denom));
189 + const loyerMoyenMensuel = revenusBruts / 12 / doors;
190 + const vacance = -revenusBruts * vac;
191 + const revenusEffectifs = revenusBruts + vacance;
192 + const gestion = -revenusBruts * vGestion;
193 + const entretien = -revenusBruts * vEntretien;
194 +
195 + const depenses: ProformaLine[] = [
196 + { key: "taxesMunicipales", amount: -taxesMunicipales },
197 + { key: "taxeScolaire", amount: -taxeScolaire },
198 + { key: "assurances", amount: -assurances },
199 + { key: "entretien", amount: entretien },
200 + { key: "gestion", amount: gestion },
201 + { key: "deneigementPelouse", amount: -deneigementPelouse },
202 + { key: "energieCommuns", amount: -energieCommuns },
203 + { key: "reserveRemplacement", amount: -reserveRemplacement },
204 + ];
205 + const totalDepenses = depenses.reduce((a, l) => a + l.amount, 0);
206 + const rne = revenusEffectifs + totalDepenses;
207 +
208 + const miseDeFonds = valeur * (p.miseDeFondsPct / 100);
209 + const hypotheque = valeur - miseDeFonds;
210 + const pmt = paiementMensuel(hypotheque, p.tauxHypoPct, p.amortAns);
211 + const serviceDette = pmt * 12;
212 + const cashflow = rne - serviceDette;
213 +
214 + // capital vs intérêts, année 1
215 + const solde12 = soldeApresMois(hypotheque, p.tauxHypoPct, p.amortAns, 12);
216 + const capitalAn1 = hypotheque - solde12;
217 + const interetsAn1 = serviceDette - capitalAn1;
218 + // projection 5 ans
219 + const app = p.appreciationPct / 100;
220 + const solde60 = soldeApresMois(hypotheque, p.tauxHypoPct, p.amortAns, 60);
221 + const valeur5 = valeur * Math.pow(1 + app, 5);
222 + const equite5 = valeur5 - solde60;
223 + const gainTotal5 = cashflow * 5 + (hypotheque - solde60) + (valeur5 - valeur);
224 + // point mort de loyer : RB' tel que RNE = service de dette
225 + const rbPointMort = (serviceDette + F) / Math.max(0.2, denom);
226 + const loyerPointMort = rbPointMort / 12 / doors;
227 + // taux de point mort (bissection sur le taux, mêmes mise/amortissement)
228 + let tauxPM: number | null = null;
229 + {
230 + let lo_ = 0.1, hi_ = 25;
231 + const cf = (tx: number) => rne - 12 * paiementMensuel(hypotheque, tx, p.amortAns);
232 + if (cf(lo_) > 0 && cf(hi_) < 0) {
233 + for (let k = 0; k < 60; k++) {
234 + const mid = (lo_ + hi_) / 2;
235 + if (cf(mid) > 0) lo_ = mid; else hi_ = mid;
236 + }
237 + tauxPM = (lo_ + hi_) / 2;
238 + }
239 + }
240 + const sensibiliteTaux = [-1, 0, 1].map((d) => {
241 + const tx = p.tauxHypoPct + d;
242 + const sd = 12 * paiementMensuel(hypotheque, tx, p.amortAns);
243 + return { tauxPct: tx, cashflowAnnuel: rne - sd, dscr: sd > 0 ? rne / sd : Infinity };
244 + });
245 +
246 + const dm = droitsMutation(valeur, (municipalite ?? "").toLowerCase().includes("montréal"));
247 + const notaire = 1_800;
248 + const inspection = 750 + doors * 120;
249 + const liquidites = miseDeFonds + dm + notaire + inspection;
250 +
251 + return {
252 + params: p,
253 + tgaRefPct: tgaReference(doors),
254 + loyerMoyenMensuel,
255 + revenusBruts,
256 + vacance,
257 + revenusEffectifs,
258 + depenses,
259 + totalDepenses,
260 + ratioDepensesPct: revenusEffectifs > 0 ? (-totalDepenses / revenusEffectifs) * 100 : 0,
261 + rne,
262 + tgaImplicitePct: valeur > 0 ? (rne / valeur) * 100 : 0,
263 + mrb: revenusBruts > 0 ? valeur / revenusBruts : 0,
264 + miseDeFonds,
265 + hypotheque,
266 + paiementMensuelHypo: pmt,
267 + serviceDetteAnnuel: serviceDette,
268 + cashflowAnnuel: cashflow,
269 + cashflowMensuelParPorte: cashflow / 12 / doors,
270 + dscr: serviceDette > 0 ? rne / serviceDette : Infinity,
271 + droitsMutation: dm,
272 + fraisNotaire: notaire,
273 + fraisInspection: inspection,
274 + liquiditesRequises: liquidites,
275 + cashOnCashPct: liquidites > 0 ? (cashflow / liquidites) * 100 : 0,
276 + capitalAn1,
277 + interetsAn1,
278 + rendementTotalAn1Pct: liquidites > 0 ? ((cashflow + capitalAn1) / liquidites) * 100 : 0,
279 + soldeHypo5Ans: solde60,
280 + valeur5Ans: valeur5,
281 + equite5Ans: equite5,
282 + gainTotal5Ans: gainTotal5,
283 + multipleLiquidites5Ans: liquidites > 0 ? (liquidites + gainTotal5) / liquidites : 0,
284 + loyerPointMort,
285 + margeSecuriteLoyerPct: loyerMoyenMensuel > 0
286 + ? ((loyerMoyenMensuel - loyerPointMort) / loyerMoyenMensuel) * 100 : 0,
287 + tauxPointMortPct: tauxPM,
288 + sensibiliteTaux,
289 + };
290 +}
added app/src/lib/report-portfolio.ts +308 −0
@@ -0,0 +1,308 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapport PDF de parc immobilier — couverture agrégée (tuiles, historique du parc,
4 + * répartitions, table des propriétés) puis une page condensée par propriété.
5 + * 100 % vectoriel, identité ValoPlex.
6 + */
7 +import PDFDocument from "pdfkit";
8 +import path from "path";
9 +import type { PortfolioResult, UnitEstimate } from "./estimator";
10 +
11 +const INK = "#141814";
12 +const INK3 = "#8b928c";
13 +const PAPER = "#f5f3ee";
14 +const SURFACE2 = "#faf9f5";
15 +const GREEN = "#b25f16";
16 +const LIME = "#ff9f45";
17 +const LIME_SOFT = "#ffedd9";
18 +const AMBER_SOFT = "#fdf3e2";
19 +const DANGER_SOFT = "#fbe9e7";
20 +const LINE = "#dedcd4";
21 +
22 +const W = 612;
23 +const H = 792;
24 +const M = 44;
25 +
26 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
27 +const fmt = (v: number | null | undefined) =>
28 + v == null
29 + ? "—"
30 + : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
31 +const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };
32 +const TYPE_FR: Record<string, string> = {
33 + unifamilial: "Unifamiliale", plex: "Plex", condo_ou_multi: "Condo/multi",
34 + chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",
35 +};
36 +
37 +type Doc = InstanceType<typeof PDFDocument>;
38 +
39 +function logo(doc: Doc, x: number, y: number, scale = 1) {
40 + doc.save();
41 + doc.font("SG-Bold").fontSize(24 * scale);
42 + doc.fillColor(PAPER).text("Valo", x, y, { lineBreak: false });
43 + const w = doc.widthOfString("Valo");
44 + const bx = x + w + 4 * scale;
45 + doc.save();
46 + doc.rotate(-3, { origin: [bx, y + 12 * scale] });
47 + const bw = doc.widthOfString("Plex") + 12 * scale;
48 + doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME);
49 + doc.fillColor(INK).text("Plex", bx + 6 * scale, y, { lineBreak: false });
50 + doc.restore();
51 + doc.restore();
52 +}
53 +
54 +function kicker(doc: Doc, txt: string, x: number, y: number) {
55 + doc.rect(x, y + 3.5, 20, 2).fill(GREEN);
56 + doc.font("JB-Bold").fontSize(8).fillColor(GREEN)
57 + .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });
58 +}
59 +
60 +function pageFrame(doc: Doc, page: number, total: number, generated: string) {
61 + doc.rect(0, 0, W, H).fill(PAPER);
62 + doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);
63 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
64 + "WWW.VALOPLEX.COM — RAPPORT DE PARC IMMOBILIER · ESTIMATION STATISTIQUE À TITRE INDICATIF · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",
65 + M, H - 38, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });
66 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
67 + .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });
68 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
69 + .text(generated, W - M - 160, H - 30, { width: 160, align: "right" });
70 +}
71 +
72 +function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {
73 + doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9");
74 + doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);
75 +}
76 +
77 +function tile(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string, hero = false) {
78 + card(doc, x, y, w, h, hero ? INK : "#ffffff");
79 + doc.font("SG-Bold").fontSize(16).fillColor(hero ? LIME : INK)
80 + .text(v, x + 12, y + 12, { width: w - 24, height: 22, ellipsis: true, lineBreak: false });
81 + doc.font("JB-Bold").fontSize(5.6).fillColor(hero ? "rgba(255,159,69,0.7)" : INK3)
82 + .text(k.toUpperCase(), x + 12, y + h - 16, { width: w - 24, characterSpacing: 0.7, lineBreak: false });
83 +}
84 +
85 +function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {
86 + doc.lineWidth(0.8);
87 + doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);
88 + doc.font("JB-Bold").fontSize(5.6).fillColor(INK3)
89 + .text(k.toUpperCase(), x + 6, y + 5.5, { width: w - 12, characterSpacing: 0.6, height: 8, ellipsis: true });
90 + doc.font("SG-Bold").fontSize(8.8).fillColor(INK)
91 + .text(v, x + 6, y + 15.5, { width: w - 12, height: h - 19, ellipsis: true });
92 + doc.lineWidth(1.3);
93 +}
94 +
95 +function hbars(doc: Doc, x: number, y: number, w: number, rows: [string, number, string][], accentLast = true) {
96 + const max = Math.max(...rows.map((r) => r[1]), 1);
97 + rows.forEach(([label, value, txt], i) => {
98 + const ly = y + i * 19;
99 + doc.font("JB-Bold").fontSize(7).fillColor(INK).text(label, x, ly + 2, { lineBreak: false });
100 + const tx = x + 50;
101 + const tw = w - 50 - 86;
102 + doc.rect(tx, ly, tw, 11).fill(SURFACE2);
103 + doc.rect(tx, ly, Math.max(2, (value / max) * tw), 11)
104 + .fill(accentLast && i === rows.length - 1 ? INK : GREEN);
105 + doc.font("JB-Bold").fontSize(7).fillColor(INK)
106 + .text(txt, x + w - 82, ly + 2, { width: 82, align: "right" });
107 + });
108 +}
109 +
110 +/** Page condensée d'une propriété du parc. */
111 +function propertyPage(doc: Doc, item: UnitEstimate, idx: number, total: number, pageNo: number, pageTotal: number, generated: string) {
112 + const u = item.unit!;
113 + const s = u.specs;
114 + const r = item.result;
115 + doc.addPage({ size: "LETTER", margin: 0 });
116 + pageFrame(doc, pageNo, pageTotal, generated);
117 + doc.rect(0, 0, W, 8).fill(INK);
118 + doc.rect(0, 8, W, 2.5).fill(LIME);
119 +
120 + let y = 34;
121 + // index de propriété
122 + doc.circle(M + 10, y + 12, 12).lineWidth(1.4).fillAndStroke(GREEN, INK);
123 + doc.font("JB-Bold").fontSize(10).fillColor(PAPER)
124 + .text(String(idx + 1), M - 2, y + 7.5, { width: 24, align: "center", lineBreak: false });
125 + doc.font("JB-Reg").fontSize(7).fillColor(INK3)
126 + .text(`PROPRIÉTÉ ${idx + 1} / ${total}`, M + 30, y + 2, { characterSpacing: 1 });
127 + doc.font("SG-Bold").fontSize(14).fillColor(INK)
128 + .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`,
129 + M + 30, y + 12, { width: W - 2 * M - 30 });
130 + y = Math.max(doc.y + 10, y + 44);
131 +
132 + doc.font("SG-Bold").fontSize(34).fillColor(INK).text(fmt(r.estimate), M, y);
133 + if (u.nbLogements && u.nbLogements >= 2) {
134 + doc.font("JB-Bold").fontSize(7.5).fillColor("#b25f16")
135 + .text(`${u.nbLogements} PORTES · ${fmt(Math.round(r.estimate / u.nbLogements))} / PORTE`,
136 + M + 250, y + 14, { characterSpacing: 0.8, lineBreak: false });
137 + }
138 + y = doc.y + 6;
139 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE", M, y, { lineBreak: false });
140 + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M, y + 10, { lineBreak: false });
141 + const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} %`;
142 + doc.font("JB-Bold").fontSize(7);
143 + const cw = doc.widthOfString(confTxt) + 16;
144 + doc.roundedRect(M + 220, y + 4, cw, 16, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK);
145 + doc.fillColor(INK).text(confTxt, M + 228, y + 8.5, { lineBreak: false });
146 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("RÔLE 2026", M + 380, y, { lineBreak: false });
147 + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(fmt(u.valeurRole), M + 380, y + 10, { lineBreak: false });
148 + y += 34;
149 +
150 + // fiche compacte
151 + const rows: [string, string][] = [
152 + ["Usage", s.cubfLibelle ?? "—"],
153 + ["Année constr.", u.anneeConstruction ? String(u.anneeConstruction) : "—"],
154 + ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"],
155 + ["Terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"],
156 + ["Frontage", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],
157 + ["Étages", s.nbEtages != null ? String(s.nbEtages) : "—"],
158 + ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"],
159 + ["Genre", s.genreConstruction ?? "—"],
160 + ["Lien phys.", s.lienPhysique ?? "—"],
161 + ["Val. terrain", fmt(s.valeurTerrain)],
162 + ["Val. bâtiment", fmt(s.valeurBatiment)],
163 + ["Matricule", s.matricule ?? "—"],
164 + ];
165 + kicker(doc, "Fiche du registre", M, y);
166 + y += 14;
167 + const cw2 = (W - 2 * M - 3 * 8) / 4;
168 + rows.forEach(([k, v], i) => {
169 + kvCell(doc, M + (i % 4) * (cw2 + 8), y + Math.floor(i / 4) * 36, cw2, 30, k, v);
170 + });
171 + y += 3 * 36 + 14;
172 +
173 + // historique
174 + kicker(doc, "Valeur estimée 2021 → 2026", M, y);
175 + y += 16;
176 + card(doc, M, y, W - 2 * M, u.history.length * 19 + 20);
177 + hbars(doc, M + 14, y + 12, W - 2 * M - 28,
178 + u.history.map((h) => [String(h.year), h.value ?? 0, fmt(h.value)]));
179 + y += u.history.length * 19 + 34;
180 +
181 + // top comparables
182 + kicker(doc, `Meilleurs comparables (${Math.min(5, r.comps.length)})`, M, y);
183 + y += 14;
184 + r.comps.slice(0, 5).forEach((c, i) => {
185 + const ly = y + i * 17;
186 + if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2);
187 + doc.font("SG-Bold").fontSize(7.5).fillColor(INK)
188 + .text(`${i + 1}. ${c.street ?? ""}, ${c.city ?? ""}`, M + 6, ly, { width: 300, height: 9, ellipsis: true, lineBreak: false });
189 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
190 + .text(`${c.date} · ${c.distanceM < 1000 ? Math.round(c.distanceM) + " m" : (c.distanceM / 1000).toFixed(1) + " km"}`,
191 + M + 316, ly + 1, { lineBreak: false });
192 + doc.font("SG-Bold").fontSize(8).fillColor(INK)
193 + .text(fmt(Math.round(c.adjustedPrice)), W - M - 90, ly, { width: 84, align: "right", lineBreak: false });
194 + });
195 +}
196 +
197 +export async function buildPortfolioReport(pf: PortfolioResult): Promise<Buffer> {
198 + const { items, aggregates: a } = pf;
199 + const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
200 + const pageTotal = 1 + items.length;
201 +
202 + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "ValoPlex — Rapport de parc immobilier" } });
203 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
204 + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));
205 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
206 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
207 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
208 +
209 + const chunks: Buffer[] = [];
210 + doc.on("data", (c: Buffer) => chunks.push(c));
211 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
212 +
213 + /* ------------------------------ COUVERTURE ------------------------------ */
214 + pageFrame(doc, 1, pageTotal, generated);
215 + doc.rect(0, 0, W, 110).fill(INK);
216 + logo(doc, M, 32);
217 + doc.font("JB-Reg").fontSize(7).fillColor(LIME)
218 + .text("RAPPORT DE PARC IMMOBILIER · QUÉBEC", M, 70, { characterSpacing: 1.6, lineBreak: false });
219 + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")
220 + .text(`${a.count} PROPRIÉTÉS`, W - M - 200, 38, { width: 200, align: "right" })
221 + .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 50, { width: 200, align: "right" });
222 + doc.rect(0, 110, W, 3).fill(LIME);
223 +
224 + let y = 134;
225 + kicker(doc, "Valeur totale estimée du parc", M, y);
226 + y += 16;
227 + doc.font("SG-Bold").fontSize(44).fillColor(INK).text(fmt(a.totalEstimate), M, y);
228 + y = doc.y + 8;
229 + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("FOURCHETTE CUMULÉE", M, y, { lineBreak: false });
230 + doc.font("SG-Bold").fontSize(12).fillColor(INK).text(`${fmt(a.totalLow)} → ${fmt(a.totalHigh)}`, M, y + 10, { lineBreak: false });
231 + const confTxt = `CONFIANCE PONDÉRÉE ${a.confidenceLevel} · ${a.confidencePct} %`;
232 + doc.font("JB-Bold").fontSize(7.5);
233 + const cw = doc.widthOfString(confTxt) + 20;
234 + doc.roundedRect(M + 260, y + 2, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[a.confidenceLevel], INK);
235 + doc.fillColor(INK).text(confTxt, M + 270, y + 7.5, { lineBreak: false });
236 + y += 38;
237 +
238 + // tuiles
239 + const tiles: [string, string, boolean][] = [
240 + ["Propriétés", String(a.count), true],
241 + ["Évaluation municipale", fmt(a.totalRole), false],
242 + ["Écart vs rôle", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false],
243 + ["Croissance 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true],
244 + ["Aire habitable totale", `${a.totalFloorArea.toLocaleString("fr-CA")} m²`, false],
245 + ["Terrain total", `${a.totalLandArea.toLocaleString("fr-CA")} m²`, false],
246 + ["Logements", String(a.totalDwellings), false],
247 + ["Année moyenne", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false],
248 + ];
249 + const tw = (W - 2 * M - 3 * 10) / 4;
250 + tiles.forEach(([k, v, hero], i) => {
251 + tile(doc, M + (i % 4) * (tw + 10), y + Math.floor(i / 4) * 58, tw, 50, k, v, hero);
252 + });
253 + y += 2 * 58 + 12;
254 +
255 + // historique du parc
256 + kicker(doc, "Valeur du parc par année (unités couvertes)", M, y);
257 + y += 14;
258 + card(doc, M, y, (W - 2 * M - 12) * 0.58, 6 * 19 + 20);
259 + hbars(doc, M + 12, y + 12, (W - 2 * M - 12) * 0.58 - 24,
260 + a.history.map((h) => [String(h.year), h.total, fmt(h.total)]));
261 +
262 + // répartitions
263 + const rx = M + (W - 2 * M - 12) * 0.58 + 12;
264 + const rw = W - M - rx;
265 + card(doc, rx, y, rw, 6 * 19 + 20);
266 + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR MUNICIPALITÉ", rx + 12, y + 10, { characterSpacing: 0.8 });
267 + a.municipalities.slice(0, 3).forEach((m, i) => {
268 + const ly = y + 22 + i * 15;
269 + doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(m.name, rx + 12, ly, { width: rw - 100, height: 9, ellipsis: true, lineBreak: false });
270 + doc.font("JB-Reg").fontSize(6.8).fillColor(INK3).text(`${m.count} · ${fmt(m.total)}`, rx + rw - 96, ly, { width: 86, align: "right", lineBreak: false });
271 + });
272 + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR TYPE", rx + 12, y + 72, { characterSpacing: 0.8 });
273 + a.types.slice(0, 3).forEach((tp, i) => {
274 + const ly = y + 84 + i * 15;
275 + doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(TYPE_FR[tp.type] ?? tp.type, rx + 12, ly, { width: rw - 100, height: 9, ellipsis: true, lineBreak: false });
276 + doc.font("JB-Reg").fontSize(6.8).fillColor(INK3).text(`${tp.count} · ${fmt(tp.total)}`, rx + rw - 96, ly, { width: 86, align: "right", lineBreak: false });
277 + });
278 + y += 6 * 19 + 34;
279 +
280 + // table des propriétés
281 + kicker(doc, "Sommaire des propriétés", M, y);
282 + y += 14;
283 + items.slice(0, 14).forEach((it, i) => {
284 + const u = it.unit!;
285 + const ly = y + i * 17;
286 + if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2);
287 + doc.circle(M + 8, ly + 5, 6).lineWidth(0.9).fillAndStroke(GREEN, INK);
288 + doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), M + 2, ly + 2.5, { width: 12, align: "center", lineBreak: false });
289 + doc.font("SG-Bold").fontSize(7.5).fillColor(INK)
290 + .text(`${u.adresse ?? ""} · ${u.municipalite ?? ""}`, M + 20, ly, { width: 280, height: 9, ellipsis: true, lineBreak: false });
291 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
292 + .text(`CONF. ${it.result.confidenceLevel}`, M + 310, ly + 1, { lineBreak: false });
293 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
294 + .text(`RÔLE ${fmt(u.valeurRole)}`, M + 360, ly + 1, { width: 90, lineBreak: false });
295 + doc.font("SG-Bold").fontSize(8).fillColor(INK)
296 + .text(fmt(it.result.estimate), W - M - 80, ly, { width: 76, align: "right", lineBreak: false });
297 + });
298 + if (items.length > 14) {
299 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
300 + .text(`+ ${items.length - 14} autres propriétés — voir pages suivantes`, M, y + 14 * 17 + 2);
301 + }
302 +
303 + /* --------------------------- PAGES PROPRIÉTÉS --------------------------- */
304 + items.forEach((it, i) => propertyPage(doc, it, i, items.length, i + 2, pageTotal, generated));
305 +
306 + doc.end();
307 + return done;
308 +}
added app/src/lib/report-pro.ts +448 −0
@@ -0,0 +1,448 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapport d'évaluation détaillé — format professionnel (présentation bancaire).
4 + * Structure calquée sur un rapport d'évaluation : page titre, sommaire exécutif,
5 + * mandat et portée, définition de la valeur marchande, méthodologie, description
6 + * de la propriété, grille d'ajustement des comparables, analyse hédonique,
7 + * réconciliation, hypothèses et conditions limitatives, certification.
8 + * Ton sobre, encadrés honnêtes : AVM statistique — n'est PAS un rapport OEAQ.
9 + */
10 +import PDFDocument from "pdfkit";
11 +import path from "path";
12 +import type { UnitEstimate } from "./estimator";
13 +import { buildProforma } from "./proforma";
14 +
15 +const INK = "#141814";
16 +const INK2 = "#4d5551";
17 +const INK3 = "#8b928c";
18 +const SURFACE2 = "#faf9f5";
19 +const GREEN = "#b25f16";
20 +const GREEN_DEEP = "#8a4710";
21 +const LIME = "#ff9f45";
22 +
23 +const W = 612;
24 +const H = 792;
25 +const M = 56;
26 +const CW = W - 2 * M;
27 +
28 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
29 +const fmt = (v: number | null | undefined) =>
30 + v == null
31 + ? "—"
32 + : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
33 +const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;
34 +const CONF_TXT: Record<string, string> = {
35 + A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",
36 +};
37 +
38 +type Doc = InstanceType<typeof PDFDocument>;
39 +
40 +interface Ctx {
41 + doc: Doc;
42 + page: number;
43 + dossier: string;
44 + generated: string;
45 +}
46 +
47 +function header(c: Ctx) {
48 + const { doc } = c;
49 + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Valo", M, 26, { lineBreak: false });
50 + const w = doc.widthOfString("Valo");
51 + doc.rect(M + w + 2, 25, doc.widthOfString("Plex") + 6, 13).fill(INK);
52 + doc.fillColor(LIME).text("Plex", M + w + 5, 26, { lineBreak: false });
53 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
54 + .text("RAPPORT D'ÉVALUATION DÉTAILLÉ — FORMAT PROFESSIONNEL", M + 70, 29, { characterSpacing: 0.8, lineBreak: false });
55 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
56 + .text(`DOSSIER ${c.dossier}`, W - M - 160, 29, { width: 160, align: "right" });
57 + doc.rect(M, 44, CW, 0.8).fill(INK);
58 +}
59 +
60 +function footer(c: Ctx, total: number) {
61 + const { doc } = c;
62 + doc.rect(M, H - 52, CW, 0.8).fill(INK);
63 + doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(
64 + "ESTIMATION STATISTIQUE AUTOMATISÉE (AVM) — NE CONSTITUE PAS UN RAPPORT D'ÉVALUATION AU SENS DES NORMES DE L'OEAQ · WWW.VALOPLEX.COM · CONTACT@SPBOUCHER.AI",
65 + M, H - 44, { width: CW - 50, characterSpacing: 0.3, lineBreak: false });
66 + doc.font("JB-Bold").fontSize(7).fillColor(INK)
67 + .text(`${c.page} / ${total}`, W - M - 40, H - 45, { width: 40, align: "right" });
68 +}
69 +
70 +function newPage(c: Ctx, total: number) {
71 + c.doc.addPage({ size: "LETTER", margin: 0 });
72 + c.page += 1;
73 + c.doc.rect(0, 0, W, H).fill("#ffffff");
74 + header(c);
75 + footer(c, total);
76 + return 62; // y de départ
77 +}
78 +
79 +function sectionTitle(c: Ctx, num: string, title: string, y: number): number {
80 + const { doc } = c;
81 + doc.font("JB-Bold").fontSize(8).fillColor(GREEN)
82 + .text(num, M, y + 2, { lineBreak: false });
83 + doc.font("SG-Bold").fontSize(13).fillColor(INK)
84 + .text(title.toUpperCase(), M + 30, y, { lineBreak: false });
85 + doc.rect(M, y + 19, CW, 1.4).fill(INK);
86 + doc.rect(M, y + 21.5, 60, 2.2).fill(LIME);
87 + return y + 32;
88 +}
89 +
90 +function para(c: Ctx, txt: string, y: number, opts?: { size?: number; color?: string; width?: number }): number {
91 + const { doc } = c;
92 + doc.font("Inter").fontSize(opts?.size ?? 8.6).fillColor(opts?.color ?? INK2)
93 + .text(txt, M, y, { width: opts?.width ?? CW, lineGap: 2.2, align: "justify" });
94 + return doc.y + 8;
95 +}
96 +
97 +function kvTable(c: Ctx, rows: [string, string][], y: number, colW = CW): number {
98 + const { doc } = c;
99 + const rh = 17;
100 + rows.forEach(([k, v], i) => {
101 + const ry = y + i * rh;
102 + if (i % 2 === 0) doc.rect(M, ry, colW, rh).fill(SURFACE2);
103 + doc.font("JB-Bold").fontSize(6.6).fillColor(INK3)
104 + .text(k.toUpperCase(), M + 8, ry + 5, { characterSpacing: 0.5, lineBreak: false });
105 + doc.font("SG-Bold").fontSize(8.6).fillColor(INK)
106 + .text(v, M + colW * 0.42, ry + 4, { width: colW * 0.58 - 10, height: 11, ellipsis: true });
107 + });
108 + doc.rect(M, y, colW, rows.length * rh).lineWidth(0.9).stroke(INK);
109 + return y + rows.length * rh + 10;
110 +}
111 +
112 +export async function buildProReport(
113 + data: UnitEstimate,
114 + marketIndex: { month: string; idx: number }[]
115 +): Promise<Buffer> {
116 + const u = data.unit!;
117 + const s = u.specs;
118 + const r = data.result;
119 + const generated = new Date().toISOString().slice(0, 10);
120 + const dossier = `VP-${u.id.slice(0, 8)}-${generated.replaceAll("-", "")}`;
121 + const TOTAL = 6;
122 +
123 + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `Rapport d'évaluation détaillé — ${u.adresse ?? ""}` } });
124 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
125 + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));
126 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
127 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
128 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
129 +
130 + const chunks: Buffer[] = [];
131 + doc.on("data", (ch: Buffer) => chunks.push(ch));
132 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
133 + const c: Ctx = { doc, page: 1, dossier, generated };
134 +
135 + /* ------------------------------- PAGE TITRE ------------------------------- */
136 + doc.rect(0, 0, W, H).fill("#ffffff");
137 + doc.rect(0, 0, W, 6).fill(INK);
138 + doc.rect(0, 6, W, 2).fill(LIME);
139 + // logo sobre
140 + doc.font("SG-Bold").fontSize(26).fillColor(INK).text("Valo", M, 70, { lineBreak: false });
141 + const lw = doc.widthOfString("Valo");
142 + doc.rect(M + lw + 4, 68, doc.widthOfString("Plex") + 14, 32).fill(INK);
143 + doc.fillColor(LIME).text("Plex", M + lw + 11, 70, { lineBreak: false });
144 + doc.font("JB-Reg").fontSize(7.5).fillColor(INK3)
145 + .text("MOTEUR D'ÉVALUATION IMMOBILIÈRE STATISTIQUE · QUÉBEC", M, 108, { characterSpacing: 1.2 });
146 +
147 + doc.rect(M, 168, CW, 1.4).fill(INK);
148 + doc.font("SG-Bold").fontSize(30).fillColor(INK)
149 + .text("RAPPORT D'ÉVALUATION\nDÉTAILLÉ", M, 188, { lineGap: 4 });
150 + doc.font("Inter").fontSize(10.5).fillColor(INK2)
151 + .text("Approche par comparaison directe et modélisation hédonique de masse", M, 262);
152 + doc.rect(M, 288, CW, 1.4).fill(INK);
153 +
154 + // bloc propriété
155 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK3).text("PROPRIÉTÉ ÉVALUÉE", M, 318, { characterSpacing: 1.2 });
156 + doc.font("SG-Bold").fontSize(17).fillColor(INK)
157 + .text(`${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`, M, 332);
158 + doc.font("SG-Med").fontSize(12).fillColor(INK2)
159 + .text(`${u.municipalite ?? ""} (Québec) · Matricule ${s.matricule ?? "—"}`, M, doc.y + 2);
160 +
161 + // encadré conclusion
162 + const by = 420;
163 + doc.roundedRect(M, by, CW, 128, 6).lineWidth(1.6).fillAndStroke(SURFACE2, INK);
164 + doc.font("JB-Bold").fontSize(7.5).fillColor(GREEN_DEEP)
165 + .text("CONCLUSION DE LA VALEUR MARCHANDE ESTIMÉE", M + 20, by + 16, { characterSpacing: 1.2 });
166 + doc.font("SG-Bold").fontSize(38).fillColor(INK).text(fmt(r.estimate), M + 20, by + 32);
167 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE (P10-P90)", M + 20, by + 84, { lineBreak: false });
168 + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M + 20, by + 96, { lineBreak: false });
169 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("DATE D'EFFET", M + 260, by + 84, { lineBreak: false });
170 + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(generated, M + 260, by + 96, { lineBreak: false });
171 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FIABILITÉ", M + 380, by + 84, { lineBreak: false });
172 + doc.font("SG-Bold").fontSize(11).fillColor(INK)
173 + .text(`${r.confidenceLevel} · ${r.confidencePct} %`, M + 380, by + 96, { lineBreak: false });
174 +
175 + // préparé pour / par
176 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ POUR", M, 585, { characterSpacing: 1 });
177 + doc.rect(M, 610, 220, 0.9).fill(INK2);
178 + doc.font("Inter").fontSize(7).fillColor(INK3).text("(destinataire / institution financière)", M, 615);
179 + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ PAR", M + 300, 585, { characterSpacing: 1 });
180 + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex — moteur d'évaluation statistique", M + 300, 597);
181 + doc.font("Inter").fontSize(8).fillColor(INK2).text("Simon-Pierre Boucher · contact@spboucher.ai", M + 300, 611);
182 + doc.font("Inter").fontSize(8).fillColor(INK2).text(`Dossier ${dossier} · Généré le ${generated}`, M + 300, 623);
183 +
184 + // avis
185 + doc.roundedRect(M, 668, CW, 54, 5).lineWidth(1).stroke(INK);
186 + doc.font("JB-Bold").fontSize(6.5).fillColor(INK).text("AVIS IMPORTANT", M + 12, 678, { characterSpacing: 1 });
187 + doc.font("Inter").fontSize(7.4).fillColor(INK2).text(
188 + "Ce rapport est produit automatiquement par un modèle d'évaluation de masse (AVM). Il est structuré selon les usages de la " +
189 + "pratique de l'évaluation afin d'en faciliter la lecture, mais il ne constitue pas un rapport d'évaluation préparé par un " +
190 + "évaluateur agréé (OEAQ) et aucune inspection de la propriété n'a été effectuée. Voir les hypothèses et conditions limitatives, section 7.",
191 + M + 12, 689, { width: CW - 24, lineGap: 1.5 });
192 + footer(c, TOTAL);
193 +
194 + /* ------------------------- PAGE 2 : SOMMAIRE EXÉCUTIF ------------------------- */
195 + let y = newPage(c, TOTAL);
196 + y = sectionTitle(c, "1.", "Sommaire exécutif", y);
197 + y = kvTable(c, [
198 + ["Adresse", `${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}, ${u.municipalite ?? ""}`],
199 + ["Identifiant provincial", u.id],
200 + ["Matricule", s.matricule ?? "—"],
201 + ["Usage prédominant (CUBF)", `${s.cubf ?? "—"} — ${s.cubfLibelle ?? "—"}`],
202 + ["Droit évalué (présumé)", "Pleine propriété, libre de toute charge"],
203 + ["Date d'effet de l'estimation", generated],
204 + ["Méthodes utilisées", "Comparaison directe plex (ajustée) et modèle hédonique spécialisé"],
205 + ["Valeur estimée", fmt(r.estimate)],
206 + ["Fourchette P10-P90", `${fmt(r.low)} → ${fmt(r.high)}`],
207 + ["Indice de fiabilité", `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}`],
208 + ["Évaluation municipale (rôle 2026)", fmt(u.valeurRole)],
209 + ["Écart estimation / rôle", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(1)} %` : "—"],
210 + ], y);
211 + y = para(c,
212 + "La valeur conclue résulte de la réconciliation pondérée de deux approches indépendantes : un modèle hédonique de " +
213 + `masse entraîné sur environ 690 000 transactions québécoises (pondération ${(r.modelWeight * 100).toFixed(0)} %) et l'analyse de ` +
214 + `${r.nCompsUsed} ventes comparables récentes situées à proximité du sujet, individuellement ajustées pour les conditions de ` +
215 + `marché, la superficie habitable et l'âge (pondération ${((1 - r.modelWeight) * 100).toFixed(0)} %). La dispersion des comparables ` +
216 + `ajustés est de ±${r.compsDispersionPct ?? "—"} %. L'indice de fiabilité intègre le nombre de comparables, leur cohérence et la ` +
217 + "largeur de la fourchette d'incertitude du modèle.", y);
218 +
219 + y = sectionTitle(c, "2.", "Mandat, portée et définition de la valeur", y + 4);
220 + y = para(c,
221 + "Mandat — Estimer la valeur marchande la plus probable de la propriété identifiée ci-dessus, à la date d'effet, aux fins " +
222 + "d'information et d'appui à une démarche de financement. Portée — Analyse automatisée des données publiques du rôle " +
223 + "d'évaluation foncière (MAMH) et des transactions publiées ; aucune inspection, aucune vérification de titres.", y);
224 + y = para(c,
225 + "Définition de la valeur marchande — Le prix le plus probable, à la date d'effet, moyennant paiement comptant ou " +
226 + "conditions équivalentes, pour lequel la propriété devrait se vendre sur un marché concurrentiel et ouvert, dans le cadre " +
227 + "d'une transaction sans lien de dépendance entre un acheteur et un vendeur prudents et informés, aucune des parties " +
228 + "n'étant contrainte d'agir et un délai de mise en marché raisonnable étant accordé.", y);
229 + y = para(c,
230 + "Approches écartées — L'approche du coût n'est pas appliquée (absence de données fiables sur le coût de remplacement " +
231 + "déprécié sans inspection). L'approche du revenu n'est pas appliquée (propriété à vocation principalement résidentielle ; " +
232 + "données de loyers non disponibles dans les sources publiques utilisées).", y);
233 +
234 + /* --------------------- PAGE 3 : DESCRIPTION DE LA PROPRIÉTÉ --------------------- */
235 + y = newPage(c, TOTAL);
236 + y = sectionTitle(c, "3.", "Description de la propriété", y);
237 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.1 Bâtiment", M, y); y += 16;
238 + y = kvTable(c, [
239 + ["Année de construction", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (estimée au rôle)" : " (réelle)"}` : "—"],
240 + ["Aire d'étages (habitable)", u.aireEtagesM2 ? `${u.aireEtagesM2} m² (${Math.round(u.aireEtagesM2 * 10.7639)} pi²)` : "—"],
241 + ["Nombre d'étages", s.nbEtages != null ? String(s.nbEtages) : "—"],
242 + ["Genre de construction", s.genreConstruction ?? "—"],
243 + ["Lien physique", s.lienPhysique ?? "—"],
244 + ["Nombre de logements", u.nbLogements != null ? String(u.nbLogements) : "—"],
245 + ["Locaux non résidentiels", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],
246 + ], y);
247 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.2 Terrain", M, y); y += 16;
248 + y = kvTable(c, [
249 + ["Superficie du terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m² (${Math.round(u.superficieTerrainM2 * 10.7639)} pi²)` : "—"],
250 + ["Mesure frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],
251 + ["Unité de voisinage (rôle)", s.uniteVoisinage || "—"],
252 + ["Arrondissement", s.arrond || "—"],
253 + ], y);
254 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.3 Données du rôle d'évaluation", M, y); y += 16;
255 + y = kvTable(c, [
256 + ["Valeur du terrain (rôle 2026)", fmt(s.valeurTerrain)],
257 + ["Valeur du bâtiment (rôle 2026)", fmt(s.valeurBatiment)],
258 + ["Valeur totale (rôle 2026)", fmt(u.valeurRole)],
259 + ["Valeur au rôle antérieur", fmt(s.valeurAnterieure)],
260 + ["Conditions du marché considérées au", s.datCondMarche ?? "—"],
261 + ], y);
262 + y = para(c,
263 + "Les caractéristiques ci-dessus proviennent du rôle d'évaluation foncière en vigueur (source : MAMH, données ouvertes). " +
264 + "Elles n'ont pas fait l'objet d'une vérification sur place.", y);
265 +
266 + /* --------------------- PAGE 4 : GRILLE DES COMPARABLES --------------------- */
267 + y = newPage(c, TOTAL);
268 + y = sectionTitle(c, "4.", "Approche par comparaison — grille d'ajustement", y);
269 + const comps = r.comps.slice(0, 4);
270 + const colW = (CW - 118) / comps.length;
271 + const rowsG: [string, (ci: number) => string][] = [
272 + ["Adresse", (i) => `${comps[i].street ?? ""}`],
273 + ["Municipalité", (i) => comps[i].city ?? "—"],
274 + ["Date de vente", (i) => comps[i].date],
275 + ["Prix de vente", (i) => fmt(comps[i].amount)],
276 + ["Distance du sujet", (i) => comps[i].distanceM < 1000 ? `${Math.round(comps[i].distanceM)} m` : `${(comps[i].distanceM / 1000).toFixed(1)} km`],
277 + ["Aire d'étages", (i) => comps[i].floorArea ? `${comps[i].floorArea} m²` : "—"],
278 + ["Année constr.", (i) => comps[i].yearBuilt ? String(comps[i].yearBuilt) : "—"],
279 + ["Ajust. conditions de marché", (i) => signed(comps[i].adjTime)],
280 + ["Ajust. superficie", (i) => signed(comps[i].adjArea)],
281 + ["Ajust. âge", (i) => signed(comps[i].adjAge)],
282 + ["PRIX AJUSTÉ", (i) => fmt(Math.round(comps[i].adjustedPrice))],
283 + ["Pondération", (i) => `${(comps[i].weight * 100).toFixed(0)} %`],
284 + ];
285 + const rh = 20;
286 + // en-têtes colonnes
287 + doc.rect(M, y, 118, rh).fill(INK);
288 + doc.font("JB-Bold").fontSize(6.4).fillColor("#ffffff").text("ÉLÉMENT", M + 6, y + 7, { lineBreak: false });
289 + comps.forEach((_, i) => {
290 + doc.rect(M + 118 + i * colW, y, colW, rh).fillAndStroke(i % 2 ? SURFACE2 : "#ffffff", INK);
291 + doc.font("JB-Bold").fontSize(6.6).fillColor(GREEN_DEEP)
292 + .text(`COMPARABLE ${i + 1}`, M + 118 + i * colW + 6, y + 7, { lineBreak: false });
293 + });
294 + y += rh;
295 + rowsG.forEach(([label, get]) => {
296 + const bold = label === "PRIX AJUSTÉ";
297 + doc.rect(M, y, 118, rh).fillAndStroke(bold ? LIME : SURFACE2, INK);
298 + doc.font("JB-Bold").fontSize(6).fillColor(INK)
299 + .text(label.toUpperCase(), M + 6, y + 7, { width: 108, height: 8, ellipsis: true, lineBreak: false });
300 + comps.forEach((_, ci) => {
301 + doc.rect(M + 118 + ci * colW, y, colW, rh).fillAndStroke(bold ? "#f6fbe4" : "#ffffff", INK);
302 + doc.font(bold ? "SG-Bold" : "Inter").fontSize(bold ? 8.4 : 7.4).fillColor(bold ? INK : INK2)
303 + .text(get(ci), M + 118 + ci * colW + 6, y + (bold ? 5.5 : 6.5), { width: colW - 12, height: 10, ellipsis: true, lineBreak: false });
304 + });
305 + y += rh;
306 + });
307 + y += 10;
308 + y = para(c,
309 + `${r.nCompsUsed} ventes comparables ont été retenues au total (les ${comps.length} plus pondérées sont détaillées ci-dessus ; la liste ` +
310 + "complète figure dans le rapport standard). Chaque prix de vente est ajusté : (i) aux conditions actuelles du marché au moyen " +
311 + "d'un indice mensuel du prix médian au mètre carré du segment, (ii) pour l'écart de superficie habitable à raison de 50 % du " +
312 + "prix unitaire au m² du comparable, plafonné à ±25 %, et (iii) pour l'écart d'âge à raison de 0,5 % par année, plafonné à ±10 %. " +
313 + `La médiane pondérée des prix ajustés s'établit à ${fmt(r.compsEstimate)}.`, y);
314 +
315 + // mini-indice de marché
316 + if (marketIndex.length > 3) {
317 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("4.1 Indice des conditions de marché du segment", M, y);
318 + y += 16;
319 + const gw = CW, gh = 84;
320 + doc.roundedRect(M, y, gw, gh, 5).lineWidth(1).fillAndStroke("#ffffff", INK);
321 + const vals = marketIndex.map((p) => p.idx);
322 + const mn = Math.min(...vals), mx = Math.max(...vals), span = mx - mn || 1;
323 + const pts = marketIndex.map((p, i) => [
324 + M + 14 + (i / (marketIndex.length - 1)) * (gw - 28),
325 + y + gh - 18 - ((p.idx - mn) / span) * (gh - 36),
326 + ]);
327 + doc.moveTo(pts[0][0], pts[0][1]);
328 + pts.slice(1).forEach(([px, py]) => doc.lineTo(px, py));
329 + doc.lineWidth(1.8).stroke(GREEN);
330 + const last = pts[pts.length - 1];
331 + doc.circle(last[0], last[1], 3).lineWidth(1.2).fillAndStroke(LIME, INK);
332 + doc.font("JB-Reg").fontSize(6).fillColor(INK3)
333 + .text(marketIndex[0].month, M + 14, y + gh - 12, { lineBreak: false })
334 + .text(marketIndex[marketIndex.length - 1].month, M + gw - 60, y + gh - 12, { width: 46, align: "right" });
335 + doc.font("JB-Reg").fontSize(6).fillColor(INK3)
336 + .text(`BASE 1,00 = ${marketIndex[marketIndex.length - 1].month} · MÉDIANE $/M² LISSÉE 3 MOIS`, M + 14, y + 8, { characterSpacing: 0.5 });
337 + }
338 +
339 + /* ------------------- PAGE 5 : HÉDONIQUE + RÉCONCILIATION ------------------- */
340 + y = newPage(c, TOTAL);
341 + y = sectionTitle(c, "5.", "Analyse hédonique de masse", y);
342 + y = para(c,
343 + "Un modèle de gradient boosting (LightGBM) en RATIO prix/valeur au rôle — insensible à l'échelle, du duplex à la tour — est entraîné exclusivement sur environ 91 000 ventes de plex québécois (2021-2026) appariées " +
344 + "au rôle d'évaluation foncière géoréférencé. Vingt-cinq variables décrivent chaque plex (portes, aire par porte, mixité, âge, " +
345 + "localisation fine, valeurs au rôle par porte, temps). Sur un échantillon réservé de 13 617 ventes jamais vues, l'erreur " +
346 + "médiane est de 14,0 % et 65 % des estimations tombent à ±20 % du prix réel (ratio médian 0,973 — " +
347 + "validation selon la norme IAAO). L'incertitude propre à l'immeuble est quantifiée par deux modèles de " +
348 + "régression quantile (10e et 90e centiles), puis CALIBRÉE par méthode conforme : couverture de 80 % vérifiée empiriquement.", y);
349 + y = kvTable(c, [
350 + ["Estimation du modèle hédonique", fmt(r.modelEstimate)],
351 + ["Intervalle P10-P90 du modèle", `${fmt(r.low)} → ${fmt(r.high)} (recentré)`],
352 + ["Erreur médiane du modèle (validation)", "14,0 % (MdAPE, holdout plex)"],
353 + ], y);
354 +
355 + // pro forma indicatif (approche revenu inversée)
356 + const doorsN = u.nbLogements ?? 0;
357 + if (doorsN >= 2 && r.estimate > 0) {
358 + const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite);
359 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK)
360 + .text("5.1 Pro forma indicatif (approche revenu inversée)", M, y);
361 + y += 16;
362 + y = kvTable(c, [
363 + ["Loyer moyen implicite / porte / mois", fmt(Math.round(pf.loyerMoyenMensuel))],
364 + [`Revenus bruts (${doorsN} portes)`, fmt(Math.round(pf.revenusBruts))],
365 + ["Revenu net d'exploitation (RNE)", fmt(Math.round(pf.rne))],
366 + ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % / × ${pf.mrb.toFixed(1)}`],
367 + [`Paiement hypothécaire (${pf.params.tauxHypoPct} %, ${pf.params.amortAns} ans, mise ${pf.params.miseDeFondsPct} %)`, `${fmt(Math.round(pf.paiementMensuelHypo))} / mois`],
368 + ["Cashflow avant impôt / DSCR", `${fmt(Math.round(pf.cashflowAnnuel))} / an · DSCR ${pf.dscr.toFixed(2)}`],
369 + ["Capital remboursé an 1 / rendement global an 1", `${fmt(Math.round(pf.capitalAn1))} · ${pf.rendementTotalAn1Pct.toFixed(1)} %`],
370 + [`Équité à 5 ans (appréciation ${pf.params.appreciationPct} %/an)`, `${fmt(Math.round(pf.equite5Ans))} · gain total ${fmt(Math.round(pf.gainTotal5Ans))}`],
371 + ["Loyer de point mort / marge de sécurité", `${fmt(Math.round(pf.loyerPointMort))} / porte · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`],
372 + ["Liquidités requises (mise + mutation + notaire + inspection)", fmt(Math.round(pf.liquiditesRequises))],
373 + ], y);
374 + y = para(c,
375 + "Pro forma normalisé : loyer implicite dérivé de la valeur conclue et du TGA de référence — pas des baux réels. " +
376 + "Taxes approximées depuis le rôle. Ne constitue pas un conseil financier.", y);
377 + }
378 +
379 + y = sectionTitle(c, "6.", "Réconciliation et conclusion", y + 4);
380 + const rh2 = 20;
381 + const recRows: [string, string, string][] = [
382 + ["Approche", "Indication de valeur", "Pondération"],
383 + ["Modèle hédonique de masse", fmt(r.modelEstimate), `${(r.modelWeight * 100).toFixed(0)} %`],
384 + ["Comparaison directe (médiane pondérée)", fmt(r.compsEstimate), `${((1 - r.modelWeight) * 100).toFixed(0)} %`],
385 + ["VALEUR CONCLUE", fmt(r.estimate), "100 %"],
386 + ];
387 + recRows.forEach(([a, b, cc], i) => {
388 + const head = i === 0;
389 + const last = i === recRows.length - 1;
390 + doc.rect(M, y, CW, rh2).fillAndStroke(head ? INK : last ? LIME : i % 2 ? SURFACE2 : "#ffffff", INK);
391 + doc.font(head || last ? "JB-Bold" : "Inter").fontSize(head ? 6.8 : last ? 8.6 : 8)
392 + .fillColor(head ? "#ffffff" : INK)
393 + .text(head ? a.toUpperCase() : a, M + 8, y + 6, { lineBreak: false });
394 + doc.text(b, M + CW * 0.52, y + 6, { lineBreak: false });
395 + doc.text(cc, M + CW * 0.84, y + 6, { lineBreak: false });
396 + y += rh2;
397 + });
398 + y += 10;
399 + y = para(c,
400 + `Compte tenu de la qualité de l'appariement des données, du nombre et de la cohérence des comparables (dispersion ±${r.compsDispersionPct ?? "—"} %) ` +
401 + `et de la performance validée du modèle, la valeur marchande estimée de la propriété au ${generated} est conclue à ` +
402 + `${fmt(r.estimate)}, à l'intérieur d'une fourchette raisonnable de ${fmt(r.low)} à ${fmt(r.high)}. Indice de fiabilité : ` +
403 + `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}.`, y);
404 +
405 + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("6.1 Évolution de la valeur estimée (2021-2026)", M, y);
406 + y += 16;
407 + const hist = u.history.filter((h) => h.value != null);
408 + y = kvTable(c, hist.map((h) => [String(h.year), fmt(h.value)] as [string, string]), y, CW * 0.6);
409 +
410 + /* ------------------- PAGE 6 : CONDITIONS + CERTIFICATION ------------------- */
411 + y = newPage(c, TOTAL);
412 + y = sectionTitle(c, "7.", "Hypothèses et conditions limitatives", y);
413 + const conds = [
414 + "Le présent rapport est généré automatiquement par le moteur d'évaluation statistique ValoPlex (modèle d'évaluation de masse, AVM). Il ne constitue pas un rapport d'évaluation au sens des normes de pratique professionnelle de l'Ordre des évaluateurs agréés du Québec (OEAQ) et ne peut être présenté comme tel.",
415 + "Aucune inspection physique de la propriété n'a été effectuée. L'état d'entretien, la qualité des finis, les rénovations, les dépendances et tout vice apparent ou caché ne sont pas considérés.",
416 + "Les caractéristiques utilisées proviennent du rôle d'évaluation foncière (MAMH, données ouvertes) et de transactions publiées. Leur exactitude et leur mise à jour ne sont pas garanties.",
417 + "Le droit évalué est présumé être la pleine propriété, libre de toute hypothèque, servitude, charge ou restriction inhabituelle. Aucune vérification de titres n'a été réalisée.",
418 + "La conformité aux lois, règlements de zonage et permis est présumée. Aucune étude environnementale n'a été effectuée ; la propriété est présumée exempte de contamination.",
419 + "La valeur conclue vaut à la date d'effet uniquement. Les marchés immobiliers évoluent ; l'estimation ne doit pas être utilisée à une autre date.",
420 + "La fourchette P10-P90 et l'indice de fiabilité font partie intégrante de la conclusion et doivent être communiqués avec elle.",
421 + "Ce rapport est destiné au destinataire indiqué en page titre, aux fins d'information et d'appui à une démarche de financement. Toute autre utilisation ou distribution engage la seule responsabilité de l'utilisateur.",
422 + "Pour une décision de financement finale, une confirmation par un évaluateur agréé (OEAQ) mandaté par l'institution financière est recommandée.",
423 + ];
424 + conds.forEach((txt, i) => {
425 + doc.font("JB-Bold").fontSize(7).fillColor(GREEN).text(`7.${i + 1}`, M, y + 1, { lineBreak: false });
426 + doc.font("Inter").fontSize(7.9).fillColor(INK2)
427 + .text(txt, M + 26, y, { width: CW - 26, lineGap: 1.6, align: "justify" });
428 + y = doc.y + 6;
429 + });
430 +
431 + y = sectionTitle(c, "8.", "Attestation", y + 2);
432 + y = para(c,
433 + "J'atteste que les données, méthodes et pondérations décrites dans ce rapport sont celles réellement employées par le moteur " +
434 + "ValoPlex pour produire la conclusion de valeur ; que la méthodologie complète, ses métriques de validation et ses limites " +
435 + "sont publiées à www.valoplex.com/methodologie ; et que ni ValoPlex ni son exploitant n'ont d'intérêt présent ou futur dans la " +
436 + "propriété évaluée, ni de lien de dépendance avec les parties à une éventuelle transaction.", y);
437 + y += 14;
438 + doc.rect(M, y + 26, 200, 0.9).fill(INK);
439 + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex", M, y + 32);
440 + doc.font("Inter").fontSize(8).fillColor(INK2)
441 + .text("Simon-Pierre Boucher · contact@spboucher.ai", M, y + 46)
442 + .text(`Dossier ${dossier} · ${generated}`, M, y + 58);
443 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
444 + .text("ANNEXE DISPONIBLE : RAPPORT STANDARD (PLAN DES COMPARABLES, LISTE COMPLÈTE DES VENTES) — WWW.VALOPLEX.COM", M, y + 82, { characterSpacing: 0.5 });
445 +
446 + doc.end();
447 + return done;
448 +}
added app/src/lib/report-stats.ts +206 −0
@@ -0,0 +1,206 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapport statistique provincial — PDF vectoriel : valeur totale du parc
4 + * immobilier québécois, évolution 2021-2026, répartition par type et
5 + * palmarès complet des 200 plus grandes municipalités.
6 + */
7 +import PDFDocument from "pdfkit";
8 +import path from "path";
9 +import type { ProvStats } from "@/components/StatsView";
10 +
11 +const INK = "#141814";
12 +const INK3 = "#8b928c";
13 +const PAPER = "#f5f3ee";
14 +const SURFACE2 = "#faf9f5";
15 +const GREEN = "#b25f16";
16 +const GREEN_DEEP = "#8a4710";
17 +const LIME = "#ff9f45";
18 +
19 +const W = 612;
20 +const H = 792;
21 +const M = 44;
22 +const CW = W - 2 * M;
23 +
24 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
25 +const money = (v: number) =>
26 + new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
27 +const compact = (v: number) => {
28 + if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;
29 + if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;
30 + if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;
31 + return money(v);
32 +};
33 +const num = (v: number) => v.toLocaleString("fr-CA");
34 +const TYPE_FR: Record<string, string> = {
35 + unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi",
36 + chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",
37 +};
38 +const POP_QC = 9_111_000;
39 +const BUDGET_QC = 165_800_000_000;
40 +const PIB_QC = 640_000_000_000;
41 +
42 +type Doc = InstanceType<typeof PDFDocument>;
43 +
44 +function logo(doc: Doc, x: number, y: number) {
45 + doc.font("SG-Bold").fontSize(24);
46 + doc.fillColor(PAPER).text("Valo", x, y, { lineBreak: false });
47 + const w = doc.widthOfString("Valo");
48 + const bx = x + w + 4;
49 + doc.save();
50 + doc.rotate(-3, { origin: [bx, y + 12] });
51 + doc.roundedRect(bx, y - 3, doc.widthOfString("Plex") + 12, 30, 5).fill(LIME);
52 + doc.fillColor(INK).text("Plex", bx + 6, y, { lineBreak: false });
53 + doc.restore();
54 +}
55 +
56 +function kicker(doc: Doc, txt: string, x: number, y: number) {
57 + doc.rect(x, y + 3.5, 20, 2).fill(GREEN);
58 + doc.font("JB-Bold").fontSize(8).fillColor(GREEN)
59 + .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });
60 +}
61 +
62 +function frame(doc: Doc, page: number, total: number, generated: string) {
63 + doc.rect(0, 0, W, H).fill(PAPER);
64 + doc.rect(M, H - 46, CW, 1.2).fill(INK);
65 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
66 + "WWW.VALOPLEX.COM — RAPPORT STATISTIQUE PROVINCIAL · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",
67 + M, H - 38, { width: CW - 60, characterSpacing: 0.4, lineBreak: false });
68 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
69 + .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });
70 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
71 + .text(generated, W - M - 160, H - 30, { width: 160, align: "right" });
72 +}
73 +
74 +function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {
75 + doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9");
76 + doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);
77 +}
78 +
79 +function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) {
80 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false });
81 + const tx = x + 88;
82 + const tw = w - 88 - 118;
83 + doc.rect(tx, y, tw, 13).fill(SURFACE2);
84 + doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN);
85 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" });
86 + if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" });
87 +}
88 +
89 +export async function buildStatsReport(s: ProvStats): Promise<Buffer> {
90 + const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
91 + const PER_PAGE = 42;
92 + const munPages = Math.ceil(s.par_ville.length / PER_PAGE);
93 + const TOTAL = 1 + munPages;
94 +
95 + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "ValoPlex — Rapport statistique provincial" } });
96 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
97 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
98 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
99 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
100 +
101 + const chunks: Buffer[] = [];
102 + doc.on("data", (c: Buffer) => chunks.push(c));
103 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
104 +
105 + /* ------------------------------ PAGE 1 : LE CHIFFRE ------------------------------ */
106 + frame(doc, 1, TOTAL, generated);
107 + doc.rect(0, 0, W, 96).fill(INK);
108 + logo(doc, M, 30);
109 + doc.font("JB-Reg").fontSize(7).fillColor(LIME)
110 + .text("RAPPORT STATISTIQUE PROVINCIAL · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });
111 + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")
112 + .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" });
113 + doc.rect(0, 96, W, 3).fill(LIME);
114 +
115 + let y = 122;
116 + kicker(doc, "Valeur totale du parc immobilier québécois", M, y);
117 + y += 18;
118 + card(doc, M, y, CW, 96, INK);
119 + doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, y + 18);
120 + doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,159,69,0.75)")
121 + .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`,
122 + M + 20, y + 62, { characterSpacing: 0.8 });
123 + y += 112;
124 +
125 + // comparaisons
126 + const comps: [string, string][] = [
127 + ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)],
128 + ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`],
129 + ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],
130 + ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`],
131 + ["Évaluation municipale totale", compact(s.valeur_role_totale)],
132 + ["Valeur médiane", money(s.valeur_mediane_2026)],
133 + ["Logements", num(s.logements)],
134 + ["Plancher bâti", `${num(s.aire_etages_km2)} km²`],
135 + ];
136 + const tw2 = (CW - 3 * 10) / 4;
137 + comps.forEach(([k, v], i) => {
138 + const tx = M + (i % 4) * (tw2 + 10);
139 + const ty = y + Math.floor(i / 4) * 56;
140 + card(doc, tx, ty, tw2, 48);
141 + doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, ty + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false });
142 + doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, ty + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 });
143 + });
144 + y += 2 * 56 + 8;
145 + doc.font("JB-Reg").fontSize(6).fillColor(INK3)
146 + .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, y, { characterSpacing: 0.5 });
147 + y += 18;
148 +
149 + // par année
150 + kicker(doc, "Valeur provinciale par millésime", M, y);
151 + y += 16;
152 + const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));
153 + card(doc, M, y, CW, s.totaux_annee.length * 19 + 22);
154 + s.totaux_annee.forEach((h, i) => {
155 + const prev = i > 0 ? s.totaux_annee[i - 1].total : null;
156 + const yoy = prev ? ` (+${(((h.total / prev) - 1) * 100).toFixed(1)} %)` : "";
157 + hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, String(h.year), h.total / maxYear,
158 + compact(h.total), `${num(h.n)} unités${yoy}`, h.year === 2026);
159 + });
160 + y += s.totaux_annee.length * 19 + 36;
161 +
162 + // par type
163 + kicker(doc, "Répartition par type de propriété", M, y);
164 + y += 16;
165 + const maxType = Math.max(...s.par_type.map((x) => x.total));
166 + card(doc, M, y, CW, s.par_type.length * 19 + 22);
167 + s.par_type.forEach((tp, i) => {
168 + hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, TYPE_FR[tp.type] ?? tp.type,
169 + tp.total / maxType, compact(tp.total),
170 + `${num(tp.n)} · méd. ${tp.mediane ? compact(tp.mediane) : "—"}`);
171 + });
172 +
173 + /* --------------------------- PAGES 2+ : MUNICIPALITÉS --------------------------- */
174 + for (let p = 0; p < munPages; p++) {
175 + doc.addPage({ size: "LETTER", margin: 0 });
176 + frame(doc, 2 + p, TOTAL, generated);
177 + doc.rect(0, 0, W, 8).fill(INK);
178 + doc.rect(0, 8, W, 2.5).fill(LIME);
179 + let yy = 32;
180 + kicker(doc, `Palmarès des municipalités (${p * PER_PAGE + 1} à ${Math.min((p + 1) * PER_PAGE, s.par_ville.length)} de ${s.par_ville.length})`, M, yy);
181 + yy += 18;
182 + // en-tête
183 + doc.rect(M, yy, CW, 16).fill(INK);
184 + doc.font("JB-Bold").fontSize(6).fillColor("#ffffff");
185 + doc.text("#", M + 8, yy + 5, { lineBreak: false });
186 + doc.text("MUNICIPALITÉ", M + 34, yy + 5, { lineBreak: false });
187 + doc.text("PROPRIÉTÉS", M + 268, yy + 5, { width: 70, align: "right", lineBreak: false });
188 + doc.text("VALEUR TOTALE", M + 348, yy + 5, { width: 90, align: "right", lineBreak: false });
189 + doc.text("VALEUR MÉDIANE", M + 438, yy + 5, { width: CW - 446, align: "right", lineBreak: false });
190 + yy += 16;
191 + s.par_ville.slice(p * PER_PAGE, (p + 1) * PER_PAGE).forEach((v, i) => {
192 + const rank = p * PER_PAGE + i + 1;
193 + if (i % 2 === 0) doc.rect(M, yy, CW, 15).fill(SURFACE2);
194 + doc.font("JB-Bold").fontSize(6.8).fillColor(rank <= 3 ? GREEN_DEEP : INK3).text(String(rank), M + 8, yy + 4.5, { lineBreak: false });
195 + doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(v.ville, M + 34, yy + 3.5, { width: 226, height: 10, ellipsis: true, lineBreak: false });
196 + doc.font("JB-Reg").fontSize(7).fillColor(INK).text(num(v.n), M + 268, yy + 4.5, { width: 70, align: "right", lineBreak: false });
197 + doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(compact(v.total), M + 348, yy + 3.5, { width: 90, align: "right", lineBreak: false });
198 + doc.font("JB-Reg").fontSize(7).fillColor(INK).text(v.mediane ? money(v.mediane) : "—", M + 438, yy + 4.5, { width: CW - 446, align: "right", lineBreak: false });
199 + yy += 15;
200 + });
201 + doc.rect(M, yy, CW, 0.9).fill(INK);
202 + }
203 +
204 + doc.end();
205 + return done;
206 +}
added app/src/lib/report.ts +454 −0
@@ -0,0 +1,454 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapport PDF ValoPlex — 100 % vectoriel (pdfkit), identité « éditorial sharp » :
4 + * logo encre/lime, fiche complète du rôle, historique en barres, plan radar des
5 + * comparables dessiné dans le PDF, tableau des ajustements. Format Lettre.
6 + */
7 +import PDFDocument from "pdfkit";
8 +import path from "path";
9 +import type { UnitEstimate } from "./estimator";
10 +import { buildProforma } from "./proforma";
11 +
12 +function doorsLabelPdf(n: number | null): string {
13 + if (!n) return "PLEX";
14 + const map: Record<number, string> = { 2: "DUPLEX", 3: "TRIPLEX", 4: "QUADRUPLEX", 5: "QUINTUPLEX", 6: "SIXPLEX" };
15 + return map[n] ?? `MULTI ${n} PORTES`;
16 +}
17 +
18 +const INK = "#141814";
19 +const INK3 = "#8b928c";
20 +const PAPER = "#f5f3ee";
21 +const SURFACE2 = "#faf9f5";
22 +const GREEN = "#b25f16";
23 +const GREEN_DEEP = "#8a4710";
24 +const LIME = "#ff9f45";
25 +const LIME_SOFT = "#ffedd9";
26 +const AMBER_SOFT = "#fdf3e2";
27 +const DANGER_SOFT = "#fbe9e7";
28 +const LINE = "#dedcd4";
29 +
30 +const W = 612;
31 +const CW3 = 612 - 2 * 44;
32 +const H = 792;
33 +const M = 44; // marge
34 +
35 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
36 +
37 +const fmt = (v: number | null | undefined) =>
38 + v == null
39 + ? "—"
40 + : new Intl.NumberFormat("fr-CA", {
41 + style: "currency",
42 + currency: "CAD",
43 + maximumFractionDigits: 0,
44 + }).format(v);
45 +
46 +const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;
47 +const fmtDist = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`);
48 +
49 +const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };
50 +const CONF_TXT: Record<string, string> = {
51 + A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",
52 +};
53 +
54 +type Doc = InstanceType<typeof PDFDocument>;
55 +
56 +function logo(doc: Doc, x: number, y: number, scale = 1) {
57 + doc.save();
58 + doc.font("SG-Bold").fontSize(24 * scale);
59 + doc.fillColor(PAPER).text("Valo", x, y, { lineBreak: false });
60 + const w = doc.widthOfString("Valo");
61 + const bx = x + w + 4 * scale;
62 + doc.save();
63 + doc.rotate(-3, { origin: [bx, y + 12 * scale] });
64 + const bw = doc.widthOfString("Plex") + 12 * scale;
65 + doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME);
66 + doc.fillColor(INK).text("Plex", bx + 6 * scale, y, { lineBreak: false });
67 + doc.restore();
68 + doc.restore();
69 +}
70 +
71 +function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) {
72 + doc.rect(x, y + 3.5, 20, 2).fill(color);
73 + doc.font("JB-Bold").fontSize(8).fillColor(color)
74 + .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });
75 +}
76 +
77 +function pageFrame(doc: Doc, page: number, total: number, generated: string) {
78 + doc.rect(0, 0, W, H).fill(PAPER);
79 + // filet de bas de page
80 + doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);
81 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
82 + "WWW.VALOPLEX.COM — ESTIMATION STATISTIQUE À TITRE INDICATIF · NE REMPLACE PAS UNE ÉVALUATION PROFESSIONNELLE · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",
83 + M, H - 38, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false }
84 + );
85 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
86 + .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });
87 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
88 + .text(generated, W - M - 160, H - 30, { width: 160, align: "right" });
89 +}
90 +
91 +function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) {
92 + if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9");
93 + doc.roundedRect(x, y, w, h, 8).fillAndStroke(opts?.fill ?? "#ffffff", INK);
94 + doc.lineWidth(1.3);
95 +}
96 +
97 +function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {
98 + doc.lineWidth(0.8);
99 + doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);
100 + doc.font("JB-Bold").fontSize(5.8).fillColor(INK3)
101 + .text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true });
102 + doc.font("SG-Bold").fontSize(9.2).fillColor(INK)
103 + .text(v, x + 7, y + 16.5, { width: w - 14, height: h - 20, ellipsis: true });
104 + doc.lineWidth(1.3);
105 +}
106 +
107 +/** Plan radar vectoriel : sujet au centre, comparables par azimut/distance. */
108 +function radar(doc: Doc, cx: number, cy: number, R: number, data: UnitEstimate) {
109 + const u = data.unit!;
110 + const comps = data.result.comps;
111 + const cos = Math.cos((u.lat * Math.PI) / 180);
112 + const pts = comps.map((c) => ({
113 + ...c,
114 + x: (c.lng - u.lng) * 111320 * cos,
115 + y: (c.lat - u.lat) * 110574,
116 + }));
117 + const maxM = Math.max(120, ...pts.map((p) => Math.hypot(p.x, p.y)));
118 + const ringSets = [
119 + [100, 250, 500], [250, 500, 1000], [500, 1000, 2000],
120 + [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000],
121 + ];
122 + const rings = ringSets.find((s) => maxM <= s[2]) ?? ringSets[ringSets.length - 1];
123 + const k = R / rings[2];
124 +
125 + // cadre + grille
126 + doc.save();
127 + doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).clip();
128 + doc.rect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16)).fill("#ffffff");
129 + doc.lineWidth(0.5).strokeColor("#eceae3");
130 + for (let g = -R - 16; g <= R + 16; g += 24) {
131 + doc.moveTo(cx + g, cy - R - 16).lineTo(cx + g, cy + R + 16).stroke();
132 + doc.moveTo(cx - R - 16, cy + g).lineTo(cx + R + 16, cy + g).stroke();
133 + }
134 + // anneaux
135 + rings.forEach((m, i) => {
136 + const rr = m * k;
137 + doc.lineWidth(i === 2 ? 1.3 : 0.8).strokeColor(i === 2 ? INK : "#b9b7ae");
138 + if (i !== 2) doc.dash(3, { space: 3 });
139 + doc.circle(cx, cy, rr).stroke();
140 + doc.undash();
141 + doc.font("JB-Reg").fontSize(5.5).fillColor(INK3)
142 + .text(fmtDist(m), cx + rr * 0.7071 + 2, cy - rr * 0.7071 - 7, { lineBreak: false });
143 + });
144 + // croix
145 + doc.lineWidth(0.6).strokeColor("#d5d3ca");
146 + doc.moveTo(cx, cy - R).lineTo(cx, cy + R).stroke();
147 + doc.moveTo(cx - R, cy).lineTo(cx + R, cy).stroke();
148 + // comparables numérotés
149 + pts.forEach((p, i) => {
150 + const px = cx + p.x * k;
151 + const py = cy - p.y * k;
152 + const rr = 6.5 + p.weight * 3.5;
153 + doc.circle(px, py, rr).lineWidth(1.1).fillAndStroke(GREEN, INK);
154 + doc.font("JB-Bold").fontSize(rr > 8.5 ? 7 : 6).fillColor(PAPER)
155 + .text(String(i + 1), px - rr, py - 3, { width: 2 * rr, align: "center", lineBreak: false });
156 + });
157 + // sujet : losange lime
158 + doc.save().translate(cx, cy).rotate(45);
159 + doc.rect(-8, -8, 16, 16).lineWidth(1.6).fillAndStroke(LIME, INK);
160 + doc.rect(-2.5, -2.5, 5, 5).fill(INK);
161 + doc.restore();
162 + // nord
163 + doc.save().translate(cx + R + 2, cy - R - 2);
164 + doc.path("M0,-9 L4,5 L0,2 L-4,5 Z").fill(INK);
165 + doc.font("JB-Bold").fontSize(6).fillColor(INK).text("N", -2.2, 8, { lineBreak: false });
166 + doc.restore();
167 + doc.restore();
168 + // bordure du cadre
169 + doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).lineWidth(1.3).stroke(INK);
170 +}
171 +
172 +export async function buildReport(data: UnitEstimate): Promise<Buffer> {
173 + const u = data.unit!;
174 + const s = u.specs;
175 + const r = data.result;
176 + const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
177 +
178 + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `ValoPlex — ${u.adresse ?? ""}` } });
179 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
180 + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));
181 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
182 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
183 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
184 +
185 + const chunks: Buffer[] = [];
186 + doc.on("data", (c: Buffer) => chunks.push(c));
187 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
188 +
189 + /* =============================== PAGE 1 =============================== */
190 + pageFrame(doc, 1, 3, generated);
191 +
192 + // bandeau encre
193 + doc.rect(0, 0, W, 96).fill(INK);
194 + logo(doc, M, 30);
195 + doc.font("JB-Reg").fontSize(7).fillColor(LIME)
196 + .text("RAPPORT D'ÉVALUATION · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });
197 + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")
198 + .text(`ID ${u.id}`, W - M - 200, 34, { width: 200, align: "right" })
199 + .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 46, { width: 200, align: "right" })
200 + .text(`MATRICULE ${s.matricule ?? "—"}`, W - M - 200, 58, { width: 200, align: "right" });
201 + doc.rect(0, 96, W, 3).fill(LIME);
202 +
203 + // adresse + estimation
204 + let y = 122;
205 + kicker(doc, "Valeur estimée au marché", M, y);
206 + y += 16;
207 + doc.font("SG-Bold").fontSize(15).fillColor(INK)
208 + .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`,
209 + M, y, { width: W - 2 * M });
210 + y = doc.y + 6;
211 + doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(r.estimate), M, y);
212 + y = doc.y + 10;
213 +
214 + // rangée fourchette / confiance / rôle / écart
215 + const rowY = y;
216 + const doorsN = u.nbLogements ?? 0;
217 + const cols = [
218 + ["GABARIT", doorsLabelPdf(doorsN)],
219 + ["VALEUR / PORTE", doorsN >= 2 ? fmt(Math.round(r.estimate / doorsN)) : "—"],
220 + ["FOURCHETTE CALIBRÉE (80 %)", `${fmt(r.low)} → ${fmt(r.high)}`],
221 + ["ÉVALUATION MUNICIPALE 2026", fmt(u.valeurRole)],
222 + ["ÉCART VS RÔLE", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %` : "—"],
223 + ] as const;
224 + let cx0 = M;
225 + for (const [k, v] of cols) {
226 + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(k, cx0, rowY, { characterSpacing: 0.8, lineBreak: false });
227 + const lw = doc.widthOfString(k) + cols.length * 0.8;
228 + doc.font("SG-Bold").fontSize(13).fillColor(INK).text(v, cx0, rowY + 11, { lineBreak: false });
229 + cx0 += Math.max(doc.widthOfString(v), lw) + 34;
230 + }
231 + // pastille confiance
232 + const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} % — ${CONF_TXT[r.confidenceLevel].toUpperCase()}`;
233 + doc.font("JB-Bold").fontSize(7.5);
234 + const cw = doc.widthOfString(confTxt) + 20;
235 + doc.roundedRect(M, rowY + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK);
236 + doc.fillColor(r.confidenceLevel === "D" ? "#b3423a" : INK).text(confTxt, M + 10, rowY + 39.5, { lineBreak: false });
237 +
238 + // fiche de la propriété
239 + y = rowY + 72;
240 + kicker(doc, "Fiche de la propriété — registre officiel (rôle 2026)", M, y);
241 + y += 16;
242 + const groups: [string, [string, string][]][] = [
243 + ["BÂTIMENT", [
244 + ["Année constr.", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (est.)" : ""}` : "—"],
245 + ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"],
246 + ["Étages", u.specs.nbEtages != null ? String(u.specs.nbEtages) : "—"],
247 + ["Genre", s.genreConstruction ?? "—"],
248 + ["Lien phys.", s.lienPhysique ?? "—"],
249 + ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"],
250 + ["Locaux n-rés.", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],
251 + ["Chambres loc.", s.nbChambresLocatives != null ? String(s.nbChambresLocatives) : "—"],
252 + ]],
253 + ["TERRAIN", [
254 + ["Superficie", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"],
255 + ["Mes. frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],
256 + ["CUBF", s.cubf ? String(s.cubf) : "—"],
257 + ["Usage", s.cubfLibelle ?? "—"],
258 + ["Unité voisin.", s.uniteVoisinage || "—"],
259 + ["Arrond.", s.arrond || "—"],
260 + ]],
261 + ["RÔLE D'ÉVALUATION", [
262 + ["Val. terrain", fmt(s.valeurTerrain)],
263 + ["Val. bâtiment", fmt(s.valeurBatiment)],
264 + ["Val. totale", fmt(u.valeurRole)],
265 + ["Rôle antér.", fmt(s.valeurAnterieure)],
266 + ["Cond. marché", s.datCondMarche ?? "—"],
267 + ["Adresses", s.nAdresses != null ? String(s.nAdresses) : "—"],
268 + ]],
269 + ];
270 + const gW = (W - 2 * M - 2 * 14) / 3;
271 + const cellH = 30;
272 + const gH = 4 * (cellH + 6) + 34;
273 + groups.forEach(([title, rows], gi) => {
274 + const gx = M + gi * (gW + 14);
275 + card(doc, gx, y, gW, gH);
276 + doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP)
277 + .text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });
278 + doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();
279 + const cw2 = (gW - 24 - 6) / 2;
280 + rows.forEach(([k, v], i) => {
281 + const col = i % 2, row = Math.floor(i / 2);
282 + kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);
283 + });
284 + });
285 + y += gH + 24;
286 +
287 + // adresses officielles des portes
288 + if (s.portesAdresses.length > 0) {
289 + doc.font("JB-Bold").fontSize(6.2).fillColor(GREEN_DEEP)
290 + .text("PORTES OFFICIELLES (RÔLE) : ", M, y, { characterSpacing: 0.6, lineBreak: false });
291 + doc.font("JB-Reg").fontSize(6.2).fillColor(INK3)
292 + .text(s.portesAdresses.slice(0, 8).join(" · ") +
293 + (s.portesAdresses.length > 8 ? ` (+${s.portesAdresses.length - 8})` : ""),
294 + M + 118, y, { width: W - 2 * M - 118, height: 14, ellipsis: true });
295 + y += 16;
296 + }
297 +
298 + // historique
299 + kicker(doc, "Valeur estimée par année — 2021 → 2026", M, y);
300 + y += 16;
301 + const hist = u.history;
302 + const hMax = Math.max(...hist.map((h) => h.value ?? 0), 1);
303 + const histH = hist.length * 21 + 24;
304 + card(doc, M, y, W - 2 * M, histH);
305 + hist.forEach((h, i) => {
306 + const ly = y + 14 + i * 21;
307 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(String(h.year), M + 14, ly + 2, { lineBreak: false });
308 + const trackX = M + 52, trackW = W - 2 * M - 52 - 100;
309 + doc.rect(trackX, ly, trackW, 12).fill(SURFACE2);
310 + doc.rect(trackX, ly, Math.max(2, ((h.value ?? 0) / hMax) * trackW), 12).fill(h.year === 2026 ? INK : GREEN);
311 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
312 + .text(fmt(h.value), W - M - 92, ly + 2, { width: 80, align: "right" });
313 + });
314 +
315 + /* =============================== PAGE 2 =============================== */
316 + doc.addPage({ size: "LETTER", margin: 0 });
317 + pageFrame(doc, 2, 3, generated);
318 + doc.rect(0, 0, W, 8).fill(INK);
319 + doc.rect(0, 8, W, 2.5).fill(LIME);
320 +
321 + y = 34;
322 + kicker(doc, "Pourquoi ce prix — le calcul, sans boîte noire", M, y);
323 + y += 16;
324 + const tileW = (W - 2 * M - 14) / 2;
325 + card(doc, M, y, tileW, 64);
326 + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("MODÈLE HÉDONIQUE PLEX EN RATIO (LIGHTGBM · 91 060 VENTES)", M + 14, y + 11, { characterSpacing: 0.6 });
327 + doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.modelEstimate), M + 14, y + 24);
328 + doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(`POIDS : ${(r.modelWeight * 100).toFixed(0)} %`, M + 14, y + 49);
329 + card(doc, M + tileW + 14, y, tileW, 64);
330 + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("COMPARABLES AJUSTÉS (MÉDIANE PONDÉRÉE)", M + tileW + 28, y + 11, { characterSpacing: 0.6 });
331 + doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.compsEstimate), M + tileW + 28, y + 24);
332 + doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(
333 + `POIDS : ${((1 - r.modelWeight) * 100).toFixed(0)} % · ${r.nCompsUsed} COMPS${r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}`,
334 + M + tileW + 28, y + 49);
335 + y += 88;
336 +
337 + // radar + tableau
338 + kicker(doc, `Plan des comparables — ${r.nCompsUsed} ventes réelles · azimut + distance`, M, y);
339 + y += 18;
340 + const R = 118;
341 + radar(doc, M + R + 16, y + R + 16, R, data);
342 +
343 + const tX = M + 2 * (R + 16) + 16;
344 + const tW = W - M - tX;
345 + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3);
346 + let ty = y + 2;
347 + r.comps.slice(0, 12).forEach((c, i) => {
348 + const rowH = 22.5;
349 + if (i % 2 === 0) doc.rect(tX, ty - 2, tW, rowH).fill(SURFACE2);
350 + doc.circle(tX + 8, ty + 7, 6).lineWidth(0.9).fillAndStroke(GREEN, INK);
351 + doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), tX + 2, ty + 4.5, { width: 12, align: "center", lineBreak: false });
352 + doc.font("SG-Bold").fontSize(7.3).fillColor(INK)
353 + .text(`${c.street ?? ""}`, tX + 19, ty, { width: tW - 90, height: 9, ellipsis: true, lineBreak: false });
354 + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)
355 + .text(`${c.date} · ${fmtDist(c.distanceM)} · VENDU ${fmt(c.amount)} · T ${signed(c.adjTime)} · S ${signed(c.adjArea)} · Â ${signed(c.adjAge)}`,
356 + tX + 19, ty + 10, { width: tW - 78, height: 8, ellipsis: true, lineBreak: false });
357 + doc.font("SG-Bold").fontSize(8).fillColor(INK)
358 + .text(fmt(Math.round(c.adjustedPrice)), tX + tW - 68, ty + 3, { width: 68, align: "right", lineBreak: false });
359 + ty += rowH;
360 + });
361 + // légende radar
362 + const legY = y + 2 * (R + 16) + 8;
363 + doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(
364 + "◆ = PROPRIÉTÉ ÉVALUÉE (CENTRE) · ● = VENTE COMPARABLE (TAILLE = POIDS) · T/S/Â = AJUSTEMENTS TEMPS / SUPERFICIE / ÂGE",
365 + M, legY, { width: W - 2 * M, characterSpacing: 0.4 });
366 +
367 + /* =============================== PAGE 3 : PRO FORMA =============================== */
368 + doc.addPage({ size: "LETTER", margin: 0 });
369 + pageFrame(doc, 3, 3, generated);
370 + doc.rect(0, 0, W, 8).fill(INK);
371 + doc.rect(0, 8, W, 2.5).fill(LIME);
372 + y = 34;
373 + if (doorsN >= 2 && r.estimate > 0) {
374 + const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite);
375 + kicker(doc, `Pro forma de l'investisseur — taux ${pf.params.tauxHypoPct} % · mise ${pf.params.miseDeFondsPct} % · TGA ${pf.params.tgaPct} %`, M, y);
376 + y += 18;
377 + const colW3 = (CW3 - 14) / 2;
378 + // état des résultats
379 + card(doc, M, y, colW3, 250);
380 + doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("ÉTAT DES RÉSULTATS NORMALISÉ (ANNUEL)", M + 12, y + 10, { characterSpacing: 0.8 });
381 + const lines1: [string, string][] = [
382 + [`Revenus bruts (${doorsN} portes × ${fmt(Math.round(pf.loyerMoyenMensuel))}/mois)`, fmt(Math.round(pf.revenusBruts))],
383 + [`Vacance (${pf.params.vacancePct} %)`, fmt(Math.round(pf.vacance))],
384 + ["Taxes municipales et scolaire", fmt(Math.round(pf.depenses[0].amount + pf.depenses[1].amount))],
385 + ["Assurances", fmt(Math.round(pf.depenses[2].amount))],
386 + ["Entretien + gestion", fmt(Math.round(pf.depenses[3].amount + pf.depenses[4].amount))],
387 + ["Déneigement, énergie, réserve", fmt(Math.round(pf.depenses[5].amount + pf.depenses[6].amount + pf.depenses[7].amount))],
388 + ["REVENU NET D'EXPLOITATION", fmt(Math.round(pf.rne))],
389 + ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % · × ${pf.mrb.toFixed(1)}`],
390 + ["RNE par porte", fmt(Math.round(pf.rne / doorsN))],
391 + ];
392 + lines1.forEach(([k, v], i) => {
393 + const ly = y + 26 + i * 23;
394 + const strong = k === "REVENU NET D'EXPLOITATION";
395 + if (i % 2 === 0) doc.rect(M + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2);
396 + doc.font(strong ? "JB-Bold" : "Inter").fontSize(strong ? 7.4 : 7.6).fillColor(strong ? INK : "#4d5551")
397 + .text(k, M + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true });
398 + doc.font("SG-Bold").fontSize(8.4).fillColor(INK)
399 + .text(v, M + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false });
400 + });
401 + // financement + 5 ans
402 + const x2 = M + colW3 + 14;
403 + card(doc, x2, y, colW3, 250);
404 + doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("FINANCEMENT ET PROJECTION 5 ANS", x2 + 12, y + 10, { characterSpacing: 0.8 });
405 + const lines2: [string, string][] = [
406 + [`Hypothèque (${100 - pf.params.miseDeFondsPct} %)`, fmt(Math.round(pf.hypotheque))],
407 + [`Paiement mensuel (${pf.params.amortAns} ans)`, fmt(Math.round(pf.paiementMensuelHypo))],
408 + ["Cashflow avant impôt / an", fmt(Math.round(pf.cashflowAnnuel))],
409 + ["Cashflow / porte / mois · DSCR", `${fmt(Math.round(pf.cashflowMensuelParPorte))} · ${pf.dscr.toFixed(2)}`],
410 + ["Capital remboursé an 1", fmt(Math.round(pf.capitalAn1))],
411 + ["Rendement global an 1", `${pf.rendementTotalAn1Pct.toFixed(1)} %`],
412 + [`Équité à 5 ans (appr. ${pf.params.appreciationPct} %/an)`, fmt(Math.round(pf.equite5Ans))],
413 + ["Gain total 5 ans", fmt(Math.round(pf.gainTotal5Ans))],
414 + ["Loyer de point mort / marge", `${fmt(Math.round(pf.loyerPointMort))} · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`],
415 + ];
416 + lines2.forEach(([k, v], i) => {
417 + const ly = y + 26 + i * 23;
418 + if (i % 2 === 0) doc.rect(x2 + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2);
419 + doc.font("Inter").fontSize(7.6).fillColor("#4d5551")
420 + .text(k, x2 + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true });
421 + doc.font("SG-Bold").fontSize(8.4).fillColor(INK)
422 + .text(v, x2 + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false });
423 + });
424 + y += 264;
425 + // liquidités requises
426 + card(doc, M, y, CW3, 58, { fill: INK });
427 + doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LIQUIDITÉS REQUISES — TOUT LE KIT", M + 14, y + 10, { characterSpacing: 1 });
428 + doc.font("Inter").fontSize(8).fillColor("#e8e6df").text(
429 + `Mise de fonds ${fmt(Math.round(pf.miseDeFonds))} · Droits de mutation ${fmt(pf.droitsMutation)} · Notaire ${fmt(pf.fraisNotaire)} · Inspection ${fmt(pf.fraisInspection)}`,
430 + M + 14, y + 24, { lineBreak: false });
431 + doc.font("SG-Bold").fontSize(14).fillColor(LIME)
432 + .text(`TOTAL ${fmt(Math.round(pf.liquiditesRequises))}`, M + 14, y + 37, { lineBreak: false });
433 + y += 72;
434 + doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(
435 + "PRO FORMA INDICATIF : LOYER IMPLICITE DÉRIVÉ DE LA VALEUR ESTIMÉE ET DU TGA — PAS DES BAUX RÉELS. TAXES APPROXIMÉES DEPUIS LE RÔLE. NE CONSTITUE PAS UN CONSEIL FINANCIER.",
436 + M, y, { width: CW3, characterSpacing: 0.3 });
437 + y += 22;
438 + }
439 + // méthodo
440 + const my = Math.max(legY + 20, ty + 14);
441 + card(doc, M, my, W - 2 * M, 112, { fill: INK, shadow: false });
442 + doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("MÉTHODOLOGIE", M + 16, my + 12, { characterSpacing: 1.4 });
443 + doc.font("Inter").fontSize(7.8).fillColor("#e8e6df").text(
444 + "L'estimation combine (1) un modèle hédonique spécialisé plex (gradient boosting en ratio prix/rôle, insensible à l'échelle) entraîné sur ~91 000 ventes de plex québécois " +
445 + "(2021-2026) appariées au rôle d'évaluation foncière géoréférencé du MAMH — erreur médiane de 14 % — et (2) une médiane " +
446 + "pondérée des ventes comparables voisines, chacune ajustée pour l'évolution du marché (indice mensuel), l'écart de " +
447 + "superficie (50 % du $/m²) et l'écart d'âge (0,5 %/an, plafonné à ±10 %) et l'écart de portes (50 % du prix par porte, plafonné à ±30 %). La fourchette correspond aux 10e et 90e " +
448 + "centiles du modèle, calibrés par méthode conforme (couverture 80 % vérifiée), recentrés sur l'estimation finale. L'indice de confiance (A-D) reflète le nombre de comparables, " +
449 + "leur dispersion et la largeur de la fourchette. Validation selon la norme IAAO sur études de ratios.",
450 + M + 16, my + 26, { width: W - 2 * M - 32, lineGap: 1.5 });
451 +
452 + doc.end();
453 + return done;
454 +}
added app/tsconfig.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2017",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noEmit": true,
9 + "esModuleInterop": true,
10 + "module": "esnext",
11 + "moduleResolution": "bundler",
12 + "resolveJsonModule": true,
13 + "isolatedModules": true,
14 + "jsx": "react-jsx",
15 + "incremental": true,
16 + "plugins": [
17 + {
18 + "name": "next"
19 + }
20 + ],
21 + "paths": {
22 + "@/*": ["./src/*"]
23 + }
24 + },
25 + "include": [
26 + "next-env.d.ts",
27 + "**/*.ts",
28 + "**/*.tsx",
29 + ".next/types/**/*.ts",
30 + ".next/dev/types/**/*.ts",
31 + "**/*.mts"
32 + ],
33 + "exclude": ["node_modules"]
34 +}
added app/vitest.config.ts +9 −0
@@ -0,0 +1,9 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { defineConfig } from "vitest/config";
3 +
4 +export default defineConfig({
5 + test: {
6 + environment: "node",
7 + include: ["src/**/*.test.ts"],
8 + },
9 +});
added scripts/build_valoplex_db.py +255 −0
@@ -0,0 +1,255 @@
1 +#!/usr/bin/env python3
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Construit la base SQLite de ValoPlex (plex seulement) + stats provinciales plex.
4 +
5 +units : 393 867 plex (CUBF 1000, 2+ logements, rôle 2026) + estimations
6 + 2021-2026, P10/P90 conformes.
7 +transactions : 91 060+ ventes du domaine plex (avec nombre de portes).
8 +market_index : indice mensuel $/m² des ventes de plex.
9 +Sortie : app/data/valoplex.db + app/src/data/stats.json
10 +"""
11 +import json
12 +import os
13 +import sqlite3
14 +
15 +import numpy as np
16 +import pandas as pd
17 +
18 +QC = "/Users/simon-pierreboucher/Desktop/qc_house_eval"
19 +BASE = "/Users/simon-pierreboucher/Desktop/valoplex"
20 +DB = f"{BASE}/app/data/valoplex.db"
21 +os.makedirs(os.path.dirname(DB), exist_ok=True)
22 +if os.path.exists(DB):
23 + os.remove(DB)
24 +
25 +con = sqlite3.connect(DB)
26 +con.executescript("""
27 +PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA temp_store=MEMORY;
28 +CREATE TABLE units (
29 + id_provinc TEXT PRIMARY KEY, adresse TEXT, apt TEXT, municipalite TEXT,
30 + arrond TEXT, code_mun TEXT, lat REAL, lng REAL, cubf INTEGER,
31 + cubf_libelle TEXT, type_prop TEXT, annee_construction INTEGER,
32 + annee_estimee TEXT, aire_etages_m2 REAL, superficie_terrain_m2 REAL,
33 + front_terrain_m REAL, nb_etages REAL, nb_logements INTEGER,
34 + nb_locaux_non_resid INTEGER, nb_chambres_locatives INTEGER,
35 + lien_physique TEXT, genre_construction TEXT,
36 + matricule TEXT, unite_voisinage TEXT, n_adresses INTEGER,
37 + dat_cond_marche TEXT,
38 + valeur_terrain REAL, valeur_batiment REAL, valeur_role REAL,
39 + valeur_anterieure REAL, adresses_json TEXT,
40 + est_2021 REAL, est_2022 REAL, est_2023 REAL, est_2024 REAL,
41 + est_2025 REAL, est_2026 REAL, p10 REAL, p90 REAL, est_hedo REAL
42 +);
43 +CREATE TABLE transactions (
44 + id TEXT PRIMARY KEY, date TEXT, amount REAL, street TEXT, city TEXT,
45 + lat REAL, lng REAL, property_type TEXT, year_built INTEGER,
46 + floor_area REAL, building_type TEXT, id_provinc TEXT, valeur_role REAL,
47 + land_area REAL, portes INTEGER
48 +);
49 +CREATE TABLE market_index (
50 + month TEXT, type_prop TEXT, ppm2 REAL, idx REAL, n INTEGER,
51 + PRIMARY KEY (month, type_prop)
52 +);
53 +CREATE TABLE leads (
54 + id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, unit_id TEXT,
55 + estimate REAL, created_at TEXT DEFAULT (datetime('now'))
56 +);
57 +""")
58 +
59 +# ---- units : plex du rôle 2026 + estimations ----------------------------------
60 +print("units plex...", flush=True)
61 +role = pd.read_parquet(f"{QC}/data/parquet/role_2026.parquet")
62 +cu = pd.to_numeric(role["rl0105a"], errors="coerce")
63 +lg = pd.to_numeric(role["rl0311a"], errors="coerce")
64 +role = role[(cu == 1000) & (lg >= 2)].reset_index(drop=True)
65 +
66 +est = pd.read_parquet(f"{BASE}/data/estimations/plex_estimes_2026.parquet",
67 + columns=["id_provinc", "municipalite", "valeur_role",
68 + "valeur_estimee", "valeur_estimee_p10",
69 + "valeur_estimee_p90"])
70 +df = role.merge(est, on="id_provinc", how="inner")
71 +for y in range(2021, 2026):
72 + e = pd.read_parquet(f"{BASE}/data/estimations/plex_estimes_{y}.parquet",
73 + columns=["id_provinc", "valeur_estimee"]) \
74 + .rename(columns={"valeur_estimee": f"est_{y}"})
75 + df = df.merge(e, on="id_provinc", how="left")
76 +
77 +# ---- adresses de chaque porte (table des adresses du rôle) --------------------
78 +print("adresses par porte...", flush=True)
79 +import pyogrio
80 +gen_dom = pyogrio.read_dataframe(
81 + f"{QC}/data/Role2026_geopackage/Domaines de valeurs/CODE_GENERIQUE_ADRESSE.dbf",
82 + read_geometry=False)
83 +gen_map = dict(zip(gen_dom.iloc[:, 0].astype(str).str.strip(),
84 + gen_dom.iloc[:, 1].astype(str).str.strip().str.title()))
85 +adr = pyogrio.read_dataframe(
86 + f"{QC}/data/Role2026_geopackage/Role_2026_2.gpkg",
87 + layer="b05v_adr_unite_evaln_2026",
88 + columns=["id_provinc", "no_seq_adr", "rl0101a", "rl0101c", "rl0101e",
89 + "rl0101g", "rl0101h", "rl0101i"],
90 + read_geometry=False, use_arrow=True)
91 +plex_ids = set(df["id_provinc"])
92 +adr = adr[adr["id_provinc"].isin(plex_ids)].copy()
93 +
94 +def s_(c):
95 + return adr[c].fillna("").astype(str).str.strip()
96 +
97 +civ = s_("rl0101a").str.lstrip("0")
98 +civ_sup = s_("rl0101c").str.lstrip("0")
99 +num = np.where((civ_sup != "") & (civ_sup != civ), civ + "-" + civ_sup, civ)
100 +generique = s_("rl0101e").map(lambda c: gen_map.get(c, c))
101 +voie = (generique + " " + s_("rl0101g").str.title() + " " + s_("rl0101h")).str.strip()
102 +apt = s_("rl0101i")
103 +full = pd.Series(num, index=adr.index).str.cat(voie, sep=" ").str.strip()
104 +full = np.where(apt != "", full + " app. " + apt, full)
105 +adr["ligne"] = full
106 +adr = adr.sort_values(["id_provinc", "no_seq_adr"])
107 +adr_lists = adr.groupby("id_provinc")["ligne"].apply(
108 + lambda x: json.dumps(list(dict.fromkeys(x))[:24], ensure_ascii=False))
109 +print(f" {len(adr_lists)} plex avec adresses détaillées", flush=True)
110 +
111 +lien_map = {"1": "Détaché", "2": "Jumelé", "3": "En rangée 1 côté",
112 + "4": "En rangée plus de 1 côté", "5": "Intégré"}
113 +genre_map = {"1": "De plain-pied", "2": "À étage mansardé", "3": "À étages entiers",
114 + "4": "À niveaux décalés", "5": "Unimodulaire"}
115 +
116 +out = pd.DataFrame({
117 + "id_provinc": df["id_provinc"], "adresse": df["adresse"], "apt": df["apt"],
118 + "municipalite": df["municipalite"], "arrond": df["arrond"],
119 + "code_mun": df["code_mun"], "lat": df["lat"], "lng": df["lng"],
120 + "cubf": pd.to_numeric(df["rl0105a"], errors="coerce"),
121 + "cubf_libelle": "Logement",
122 + "type_prop": "plex",
123 + "annee_construction": pd.to_numeric(df["rl0307a"], errors="coerce"),
124 + "annee_estimee": df["rl0307b"],
125 + "aire_etages_m2": pd.to_numeric(df["rl0308a"], errors="coerce"),
126 + "superficie_terrain_m2": pd.to_numeric(df["rl0302a"], errors="coerce"),
127 + "front_terrain_m": pd.to_numeric(df["rl0301a"], errors="coerce"),
128 + "nb_etages": pd.to_numeric(df["rl0306a"], errors="coerce"),
129 + "nb_logements": pd.to_numeric(df["rl0311a"], errors="coerce"),
130 + "nb_locaux_non_resid": pd.to_numeric(df["rl0312a"], errors="coerce"),
131 + "nb_chambres_locatives": pd.to_numeric(df["rl0313a"], errors="coerce"),
132 + "lien_physique": df["rl0309a"].astype(str).map(lien_map),
133 + "genre_construction": df["rl0310a"].astype(str).map(genre_map),
134 + "matricule": df["mat18"],
135 + "unite_voisinage": df["rl0106a"],
136 + "n_adresses": pd.to_numeric(df["n_adresses"], errors="coerce"),
137 + "dat_cond_marche": df["dat_cond_mrche"].astype(str).str[:10],
138 + "valeur_terrain": pd.to_numeric(df["rl0402a"], errors="coerce"),
139 + "valeur_batiment": pd.to_numeric(df["rl0403a"], errors="coerce"),
140 + "valeur_role": df["valeur_role"],
141 + "valeur_anterieure": pd.to_numeric(df["rl0405a"], errors="coerce"),
142 + "adresses_json": df["id_provinc"].map(adr_lists),
143 + "est_2021": df["est_2021"], "est_2022": df["est_2022"],
144 + "est_2023": df["est_2023"], "est_2024": df["est_2024"],
145 + "est_2025": df["est_2025"], "est_2026": df["valeur_estimee"],
146 + "p10": df["valeur_estimee_p10"], "p90": df["valeur_estimee_p90"],
147 + "est_hedo": None,
148 +})
149 +print(f"écriture de {len(out)} plex...", flush=True)
150 +out.to_sql("units", con, if_exists="append", index=False, chunksize=100_000)
151 +
152 +# ---- transactions du domaine plex ---------------------------------------------
153 +print("transactions plex...", flush=True)
154 +tx = pd.read_parquet(f"{QC}/province_transactions_enrichi.parquet")
155 +lgx = pd.to_numeric(tx["role_nb_logements"], errors="coerce")
156 +cux = pd.to_numeric(tx["role_cubf"], errors="coerce")
157 +good = (cux == 1000) & (lgx >= 2) & tx["role_id_provinc"].notna() & \
158 + (tx["match_valeur_exacte"] | (tx["match_dist_m"] <= 50))
159 +p = tx[good]
160 +txo = pd.DataFrame({
161 + "id": p["id"], "date": p["date"], "amount": p["amount"],
162 + "street": p["street"], "city": p["city"], "lat": p["lat"], "lng": p["lng"],
163 + "property_type": "plex",
164 + "year_built": pd.to_numeric(p["yearBuilt"], errors="coerce"),
165 + "floor_area": pd.to_numeric(p["floorArea"], errors="coerce"),
166 + "building_type": p["buildingType"],
167 + "id_provinc": p["role_id_provinc"],
168 + "valeur_role": pd.to_numeric(p["role_valeur_immeuble"], errors="coerce"),
169 + "land_area": pd.to_numeric(p["role_superficie_terrain_m2"], errors="coerce"),
170 + "portes": lgx[good].astype(int),
171 +})
172 +txo.to_sql("transactions", con, if_exists="append", index=False, chunksize=100_000)
173 +
174 +# ---- indice de marché plex -----------------------------------------------------
175 +print("market_index...", flush=True)
176 +t = txo.dropna(subset=["floor_area"])
177 +t = t[(t["floor_area"] > 40) & (t["amount"] > 60_000)]
178 +t = t.assign(month=t["date"].str[:7], ppm2=t["amount"] / t["floor_area"])
179 +gi = t.groupby("month").agg(ppm2=("ppm2", "median"), n=("ppm2", "size")).reset_index()
180 +gi = gi[gi["n"] >= 5].sort_values("month")
181 +gi["ppm2"] = gi["ppm2"].rolling(3, min_periods=1, center=True).median()
182 +gi["idx"] = gi["ppm2"] / gi["ppm2"].iloc[-1]
183 +gi["type_prop"] = "plex"
184 +gi.to_sql("market_index", con, if_exists="append", index=False)
185 +
186 +con.executescript("""
187 +CREATE INDEX idx_units_geo ON units(lat, lng);
188 +CREATE INDEX idx_units_mun ON units(municipalite);
189 +CREATE INDEX idx_tx_geo ON transactions(lat, lng);
190 +CREATE VIRTUAL TABLE units_fts USING fts5(
191 + adresse, municipalite, content='units', content_rowid='rowid',
192 + tokenize='unicode61 remove_diacritics 2');
193 +INSERT INTO units_fts(rowid, adresse, municipalite)
194 + SELECT rowid, adresse, municipalite FROM units;
195 +""")
196 +con.commit()
197 +
198 +# ---- stats plex provinciales ---------------------------------------------------
199 +print("stats.json...", flush=True)
200 +u = pd.read_sql("""SELECT municipalite, nb_logements, valeur_role,
201 + aire_etages_m2, superficie_terrain_m2,
202 + est_2021, est_2022, est_2023, est_2024, est_2025, est_2026
203 + FROM units""", con)
204 +years = [f"est_{y}" for y in range(2021, 2027)]
205 +both = u[["est_2021", "est_2026"]].dropna()
206 +growth = float(both["est_2026"].sum() / both["est_2021"].sum() - 1) * 100
207 +
208 +bands = pd.cut(u["nb_logements"], [1, 2, 3, 5, 11, 10_000],
209 + labels=["Duplex (2)", "Triplex (3)", "4-5 portes",
210 + "6-11 portes", "12+ portes"])
211 +by_band = u.groupby(bands, observed=True).agg(
212 + n=("est_2026", "count"), total=("est_2026", "sum"),
213 + mediane=("est_2026", "median")).reset_index()
214 +by_mun = u.groupby("municipalite").agg(
215 + n=("est_2026", "count"), total=("est_2026", "sum"),
216 + mediane=("est_2026", "median")).reset_index().dropna() \
217 + .sort_values("total", ascending=False)
218 +
219 +stats = {
220 + "generated": "2026-08-09",
221 + "unites": int(len(u)),
222 + "valeur_totale_2026": float(u["est_2026"].sum()),
223 + "valeur_role_totale": float(u["valeur_role"].sum()),
224 + "valeur_mediane_2026": float(u["est_2026"].median()),
225 + "croissance_2021_2026_pct": round(growth, 1),
226 + "logements": int(u["nb_logements"].sum()),
227 + "aire_etages_km2": round(float(u["aire_etages_m2"].sum()) / 1e6, 1),
228 + "terrain_km2": round(float(u["superficie_terrain_m2"].sum()) / 1e6, 1),
229 + "municipalites": int(by_mun["municipalite"].nunique()),
230 + "totaux_annee": [
231 + {"year": int(c[4:]), "total": float(u[c].sum()),
232 + "n": int(u[c].notna().sum())} for c in years
233 + ],
234 + "par_type": [
235 + {"type": str(r.nb_logements), "n": int(r.n), "total": float(r.total),
236 + "mediane": float(r.mediane) if pd.notna(r.mediane) else None}
237 + for r in by_band.itertuples()
238 + ],
239 + "par_ville": [
240 + {"ville": r.municipalite, "n": int(r.n), "total": float(r.total),
241 + "mediane": float(r.mediane) if pd.notna(r.mediane) else None}
242 + for r in by_mun.head(200).itertuples()
243 + ],
244 +}
245 +os.makedirs(f"{BASE}/app/src/data", exist_ok=True)
246 +with open(f"{BASE}/app/src/data/stats.json", "w") as f:
247 + json.dump(stats, f, ensure_ascii=False)
248 +
249 +n_u = con.execute("SELECT COUNT(*) FROM units").fetchone()[0]
250 +n_t = con.execute("SELECT COUNT(*) FROM transactions").fetchone()[0]
251 +print(f"OK — units={n_u}, tx={n_t}")
252 +print(f"VALEUR TOTALE PLEX QC 2026 : {stats['valeur_totale_2026']:,.0f} $ | portes: {stats['logements']:,}")
253 +con.execute("VACUUM")
254 +con.close()
255 +print("DB_DONE")
added scripts/hedonic_plex.py +363 −0
@@ -0,0 +1,363 @@
1 +#!/usr/bin/env python3
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +"""ValoPlex — modèle hédonique avancé spécialisé plex (2+ logements, CUBF 1000).
4 +
5 +Avancées vs modèle généraliste Vrai-Prix :
6 + · CIBLE EN RATIO : le modèle prédit log(prix / valeur au rôle), pas le prix.
7 + Invariant d'échelle, il évalue aussi justement le duplex de 400 k$ que la
8 + tour au rôle de 700 M$ (un modèle en prix brut compresse les gros
9 + immeubles vers la masse des duplex — bogue observé et corrigé) ;
10 + · domaine restreint aux plex, features « par porte » (aire/porte,
11 + valeur au rôle/porte, bâtiment $/m²) et signaux de mixité ;
12 + · fourchette P10-P90 par régression quantile PUIS calibration conforme
13 + (split conformal) : la couverture 80 % est garantie empiriquement sur un
14 + ensemble de calibration jamais vu ;
15 + · validation IAAO segmentée (par nombre de portes, par année, temporel 2026).
16 +
17 +Usage : python3 hedonic_plex.py train | predict
18 +"""
19 +import json
20 +import os
21 +import sys
22 +
23 +import joblib
24 +import lightgbm as lgb
25 +import numpy as np
26 +import pandas as pd
27 +
28 +QC = "/Users/simon-pierreboucher/Desktop/qc_house_eval"
29 +BASE = "/Users/simon-pierreboucher/Desktop/valoplex"
30 +MODELS = f"{BASE}/models"
31 +ESTIM = f"{BASE}/data/estimations"
32 +os.makedirs(MODELS, exist_ok=True)
33 +os.makedirs(ESTIM, exist_ok=True)
34 +
35 +T0 = pd.Timestamp("2021-01-01")
36 +
37 +NUM = ["portes", "aire_etages_m2", "aire_par_porte", "superficie_terrain_m2",
38 + "front_terrain_m", "nb_etages", "nb_chambres_locatives",
39 + "nb_locaux_non_resid", "age", "lat", "lng", "n_adresses",
40 + "t_mois", "mois", "anciennete_role_mois",
41 + "log_val_terrain", "log_val_batiment", "log_val_immeuble",
42 + "val_par_porte", "bat_par_m2", "part_terrain", "croissance_role"]
43 +CAT = ["lien_physique", "genre_construction", "code_mun_i", "region"]
44 +FEATS = NUM + CAT
45 +LGB = dict(objective="regression", metric="l1", num_leaves=192,
46 + learning_rate=0.045, n_estimators=4000, min_child_samples=30,
47 + colsample_bytree=0.85, subsample=0.85, subsample_freq=1,
48 + reg_lambda=1.5, n_jobs=16, verbose=-1)
49 +
50 +
51 +def c_tx(name): # colonnes côté transactions enrichies
52 + m = {"rl0308a": "role_aire_etages_m2", "rl0302a": "role_superficie_terrain_m2",
53 + "rl0301a": "role_front_terrain_m", "rl0306a": "role_nb_etages",
54 + "rl0311a": "role_nb_logements", "rl0312a": "role_nb_locaux_non_resid",
55 + "rl0313a": "role_nb_chambres_locatives", "lat": "role_lat",
56 + "lng": "role_lng", "n_adresses": "role_n_adresses",
57 + "rl0309a": "role_lien_physique", "rl0310a": "role_genre_construction",
58 + "code_mun": "role_code_mun", "rl0404a": "role_valeur_immeuble",
59 + "rl0402a": "role_valeur_terrain", "rl0403a": "role_valeur_batiment",
60 + "rl0405a": "role_valeur_role_anterieur", "rl0307a": "role_annee_construction",
61 + "dat_cond_mrche": "role_date_cond_marche"}
62 + return m[name]
63 +
64 +
65 +def eng(df, col, when):
66 + """Features plex. `col` : fonction nom générique -> nom de colonne."""
67 + o = pd.DataFrame(index=df.index)
68 + num = lambda n: pd.to_numeric(df[col(n)], errors="coerce")
69 + portes = num("rl0311a")
70 + aire = num("rl0308a")
71 + val = num("rl0404a")
72 + terr = num("rl0402a")
73 + bat = num("rl0403a")
74 + prev = num("rl0405a")
75 + yb = num("rl0307a")
76 +
77 + o["portes"] = portes
78 + o["aire_etages_m2"] = aire
79 + o["aire_par_porte"] = (aire / portes.replace(0, np.nan)).clip(10, 500)
80 + o["superficie_terrain_m2"] = num("rl0302a")
81 + o["front_terrain_m"] = num("rl0301a")
82 + o["nb_etages"] = num("rl0306a")
83 + o["nb_chambres_locatives"] = num("rl0313a")
84 + o["nb_locaux_non_resid"] = num("rl0312a")
85 + o["age"] = (when.dt.year - yb).where((yb > 1600) & (yb <= when.dt.year + 2))
86 + o["lat"] = num("lat")
87 + o["lng"] = num("lng")
88 + o["n_adresses"] = num("n_adresses")
89 + o["t_mois"] = (when - T0).dt.days / 30.44
90 + o["mois"] = when.dt.month.astype(float)
91 + # ancienneté du rôle : mois entre la date des conditions du marché du rôle
92 + # (dat_cond_mrche) et la date d'évaluation — LA variable d'un modèle en ratio :
93 + # plus le rôle est vieux, plus le marché s'en est éloigné
94 + dcm = pd.to_datetime(df[col("dat_cond_mrche")].astype(str).str[:10], errors="coerce")
95 + o["anciennete_role_mois"] = ((when - dcm).dt.days / 30.44).clip(-6, 72)
96 + o["log_val_terrain"] = np.log1p(terr.clip(lower=0))
97 + o["log_val_batiment"] = np.log1p(bat.clip(lower=0))
98 + o["log_val_immeuble"] = np.log1p(val.clip(lower=0))
99 + o["val_par_porte"] = (val / portes.replace(0, np.nan)).clip(5_000, 2_000_000)
100 + o["bat_par_m2"] = (bat / aire.replace(0, np.nan)).clip(50, 20_000)
101 + o["part_terrain"] = (terr / val.replace(0, np.nan)).clip(0, 1)
102 + o["croissance_role"] = (val / prev.replace(0, np.nan)).clip(0.2, 5)
103 + o["lien_physique"] = df[col("rl0309a")].astype(str).astype("category")
104 + o["genre_construction"] = df[col("rl0310a")].astype(str).astype("category")
105 + cm = pd.to_numeric(df[col("code_mun")].astype(str).str.strip(), errors="coerce")
106 + o["code_mun_i"] = cm.fillna(-1).astype(int).astype("category")
107 + o["region"] = (cm // 1000).fillna(-1).astype(int).astype("category")
108 + return o
109 +
110 +
111 +def align(X, levels=None):
112 + if levels is None:
113 + return {k: X[k].cat.categories for k in CAT}
114 + for k in CAT:
115 + X[k] = pd.Categorical(X[k], categories=levels[k])
116 + return levels
117 +
118 +
119 +def build_training():
120 + df = pd.read_parquet(f"{QC}/province_transactions_enrichi.parquet")
121 + lg_ = pd.to_numeric(df["role_nb_logements"], errors="coerce")
122 + cu = pd.to_numeric(df["role_cubf"], errors="coerce")
123 + val = pd.to_numeric(df["role_valeur_immeuble"], errors="coerce")
124 + ratio = df["amount"] / val.replace(0, np.nan)
125 + keep = (
126 + (cu == 1000) & (lg_ >= 2) & df["role_id_provinc"].notna() &
127 + (df["match_valeur_exacte"] | (df["match_dist_m"] <= 50)) &
128 + df["amount"].between(60_000, 30_000_000) & ratio.between(0.25, 4.0)
129 + )
130 + df = df[keep].copy()
131 + df["when"] = pd.to_datetime(df["date"])
132 + print(f"transactions plex retenues : {len(df)}", flush=True)
133 + val_ok = pd.to_numeric(df["role_valeur_immeuble"], errors="coerce")
134 + df = df[val_ok > 10_000].copy() # cible en ratio : rôle > 0 requis
135 + X = eng(df, c_tx, df["when"])
136 + base = pd.to_numeric(df["role_valeur_immeuble"], errors="coerce").values
137 + y = np.log(df["amount"].values / base) # log(prix / valeur au rôle)
138 + meta = df[["id", "date", "amount", "tx_year"]].reset_index(drop=True)
139 + meta["portes"] = pd.to_numeric(df["role_nb_logements"], errors="coerce").values
140 + meta["base"] = base
141 + meta["valeur_role"] = base
142 + return X.reset_index(drop=True), y, meta
143 +
144 +
145 +def iaao(pred, price):
146 + r = pred / price
147 + med = np.median(r)
148 + cod = 100 * np.mean(np.abs(r - med)) / med
149 + prd = np.mean(r) / (np.sum(pred) / np.sum(price))
150 + proxy = 0.5 * (pred / med + price)
151 + z = np.log(proxy) / np.log(2)
152 + beta = np.linalg.lstsq(np.c_[np.ones(len(z)), z], r / med - 1, rcond=None)[0]
153 + return dict(ratio_median=round(float(med), 4), COD=round(float(cod), 2),
154 + PRD=round(float(prd), 4), PRB=round(float(beta[1]), 4))
155 +
156 +
157 +def metrics(pred, price):
158 + if len(price) == 0:
159 + return {}
160 + ape = np.abs(pred - price) / price
161 + return dict(n=int(len(price)), MdAPE=round(float(np.median(ape)) * 100, 2),
162 + MAPE=round(float(np.mean(ape)) * 100, 2),
163 + dans_10pct=round(float((ape <= .10).mean()) * 100, 1),
164 + dans_20pct=round(float((ape <= .20).mean()) * 100, 1),
165 + R2_log=round(float(1 - np.var(np.log(pred) - np.log(price)) /
166 + np.var(np.log(price))), 4),
167 + **iaao(pred, price))
168 +
169 +
170 +BANDS = [(2, 2), (3, 3), (4, 5), (6, 12), (13, 10**9)]
171 +
172 +
173 +def band_of(doors):
174 + import numpy as _np
175 + b = _np.full(len(doors), len(BANDS) - 1)
176 + for i, (lo, hi) in enumerate(BANDS):
177 + b[(doors >= lo) & (doors <= hi)] = i
178 + return b
179 +
180 +
181 +def shrink_weight(doors):
182 + """Poids du modèle selon le support des ventes : 1 dans le domaine,
183 + décroissant hors domaine (13+ portes = extrapolation)."""
184 + import numpy as _np
185 + w = _np.ones(len(doors))
186 + w[(doors >= 13) & (doors <= 24)] = 0.55
187 + w[(doors >= 25) & (doors <= 49)] = 0.35
188 + w[doors >= 50] = 0.20
189 + return w
190 +
191 +
192 +def train():
193 + X, y, meta = build_training()
194 + rng = np.random.default_rng(20260809)
195 + n = len(X)
196 + u = rng.random(n)
197 + test = u < 0.15 # évaluation finale
198 + calib = (u >= 0.15) & (u < 0.30) # calibration conforme
199 + fit = u >= 0.30 # entraînement
200 + report = {"n_total": int(n), "n_fit": int(fit.sum()),
201 + "n_calib": int(calib.sum()), "n_test": int(test.sum())}
202 +
203 + print("=== modèle central (monotone) ===", flush=True)
204 + m = lgb.LGBMRegressor(**LGB)
205 + m.fit(X[FEATS][fit], y[fit], eval_set=[(X[FEATS][test], y[test])],
206 + callbacks=[lgb.early_stopping(200, verbose=False)])
207 + best = int(m.best_iteration_ or LGB["n_estimators"])
208 + report["best_iter"] = best
209 + sm = float(np.mean(np.exp(y[fit] - m.predict(X[FEATS][fit]))))
210 + report["smearing"] = round(sm, 4)
211 + base_test = meta.loc[test, "base"].values
212 + pred_test = base_test * np.exp(m.predict(X[FEATS][test])) * sm
213 + price_test = base_test * np.exp(y[test])
214 + report["holdout"] = metrics(pred_test, price_test)
215 +
216 + # segmentation par portes
217 + pt = meta.loc[test, "portes"].values
218 + vr = meta.loc[test, "valeur_role"].values
219 + for lab, mask in [("2 portes", pt == 2), ("3 portes", pt == 3),
220 + ("4-5 portes", (pt >= 4) & (pt <= 5)),
221 + ("6-12 portes", (pt >= 6) & (pt <= 12)),
222 + ("13+ portes", pt >= 13),
223 + ("rôle > 2M$", vr > 2_000_000),
224 + ("rôle > 5M$", vr > 5_000_000)]:
225 + report[f"seg_{lab}"] = metrics(pred_test[mask], price_test[mask])
226 + # temporel
227 + t26 = (meta["tx_year"] == 2026).values
228 + m26 = lgb.LGBMRegressor(**{**LGB, "n_estimators": best})
229 + m26.fit(X[FEATS][~t26], y[~t26])
230 + sm26 = float(np.mean(np.exp(y[~t26] - m26.predict(X[FEATS][~t26]))))
231 + b26 = meta.loc[t26, "base"].values
232 + report["test_2026"] = metrics(b26 * np.exp(m26.predict(X[FEATS][t26])) * sm26,
233 + b26 * np.exp(y[t26]))
234 +
235 + print("=== quantiles + calibration conforme ===", flush=True)
236 + qmods = {}
237 + for a, nm in [(0.1, "p10"), (0.9, "p90")]:
238 + mq = lgb.LGBMRegressor(**{**LGB, "objective": "quantile", "alpha": a,
239 + "metric": "quantile", "n_estimators": best})
240 + mq.fit(X[FEATS][fit], y[fit])
241 + qmods[nm] = mq
242 + lo_c = qmods["p10"].predict(X[FEATS][calib])
243 + hi_c = qmods["p90"].predict(X[FEATS][calib])
244 + # score de non-conformité (intervalle symétrisé) et quantile 80 %
245 + s = np.maximum(lo_c - y[calib], y[calib] - hi_c)
246 + k = int(np.ceil(0.80 * (calib.sum() + 1))) - 1
247 + qhat = float(np.sort(s)[min(k, len(s) - 1)])
248 + report["conformal_qhat_log"] = round(qhat, 4)
249 + # couverture avant/après sur le test
250 + lo_t = qmods["p10"].predict(X[FEATS][test])
251 + hi_t = qmods["p90"].predict(X[FEATS][test])
252 + report["couverture_brute_pct"] = round(float(((y[test] >= lo_t) & (y[test] <= hi_t)).mean()) * 100, 1)
253 + report["couverture_conforme_pct"] = round(float(((y[test] >= lo_t - qhat) & (y[test] <= hi_t + qhat)).mean()) * 100, 1)
254 +
255 + # --- calibration Mondrian (par bande de portes) + ancre de rétrécissement ---
256 + doors_cal = meta.loc[calib, "portes"].values
257 + bc = band_of(doors_cal)
258 + band_stats = []
259 + for i, (blo, bhi) in enumerate(BANDS):
260 + m_b = bc == i
261 + yb = y[calib][m_b]
262 + if m_b.sum() >= 30:
263 + sb = np.maximum(lo_c[m_b] - yb, yb - hi_c[m_b])
264 + kb = int(np.ceil(0.80 * (m_b.sum() + 1))) - 1
265 + qhat_b = float(np.sort(sb)[min(kb, len(sb) - 1)])
266 + else:
267 + qhat_b = qhat # repli global si bande trop mince
268 + band_stats.append({
269 + "lo": blo, "hi": bhi, "n_calib": int(m_b.sum()),
270 + "qhat": qhat_b,
271 + "ratio_median": float(np.exp(np.median(yb))) if m_b.sum() >= 10 else 1.0,
272 + "ratio_q10": float(np.exp(np.quantile(yb, 0.10))) if m_b.sum() >= 10 else None,
273 + "ratio_q90": float(np.exp(np.quantile(yb, 0.90))) if m_b.sum() >= 10 else None,
274 + })
275 + report["band_stats"] = band_stats
276 + # couverture Mondrian sur le test
277 + bt = band_of(meta.loc[test, "portes"].values)
278 + q_t = np.array([band_stats[i]["qhat"] for i in bt])
279 + report["couverture_mondrian_pct"] = round(float(((y[test] >= lo_t - q_t) & (y[test] <= hi_t + q_t)).mean()) * 100, 1)
280 +
281 + # modèle final : toutes les données
282 + print("=== réentraînement final ===", flush=True)
283 + mf = lgb.LGBMRegressor(**{**LGB, "n_estimators": int(best * 1.15)})
284 + mf.fit(X[FEATS], y)
285 + sm_f = float(np.mean(np.exp(y - mf.predict(X[FEATS]))))
286 + qf = {}
287 + for a, nm in [(0.1, "p10"), (0.9, "p90")]:
288 + mq = lgb.LGBMRegressor(**{**LGB, "objective": "quantile", "alpha": a,
289 + "metric": "quantile", "n_estimators": best})
290 + mq.fit(X[FEATS], y)
291 + qf[nm] = mq
292 +
293 + imp = pd.Series(mf.feature_importances_, index=FEATS).sort_values(ascending=False)
294 + report["importance"] = {k: int(v) for k, v in imp.head(12).items()}
295 + joblib.dump({"model": mf, "smearing": sm_f, "p10": qf["p10"], "p90": qf["p90"],
296 + "qhat": qhat, "band_stats": band_stats, "levels": align(X),
297 + "feats": FEATS},
298 + f"{MODELS}/valoplex_lgb.joblib")
299 + with open(f"{MODELS}/rapport_validation.json", "w") as f:
300 + json.dump(report, f, indent=2, ensure_ascii=False, default=str)
301 + print(json.dumps({k: v for k, v in report.items() if k != "importance"},
302 + indent=2, ensure_ascii=False))
303 + print("TRAIN_DONE")
304 +
305 +
306 +def predict():
307 + b = joblib.load(f"{MODELS}/valoplex_lgb.joblib")
308 + mf, sm, qhat = b["model"], b["smearing"], b["qhat"]
309 + idx = pd.read_csv(f"{QC}/data/indexRole2026.csv")
310 + muns = dict(zip(idx.iloc[:, 0].astype(str).str.lstrip("0"), idx.iloc[:, 1]))
311 + ident = lambda n: n
312 + for Y in range(2021, 2027):
313 + df = pd.read_parquet(f"{QC}/data/parquet/role_{Y}.parquet")
314 + cu = pd.to_numeric(df["rl0105a"], errors="coerce")
315 + lg_ = pd.to_numeric(df["rl0311a"], errors="coerce")
316 + df = df[(cu == 1000) & (lg_ >= 2)].reset_index(drop=True)
317 + when = pd.Series(pd.Timestamp(f"{Y}-06-15"), index=df.index)
318 + X = eng(df, ident, when)
319 + align(X, b["levels"])
320 + base = pd.to_numeric(df["rl0404a"], errors="coerce").values
321 + ok = base > 10_000
322 + doors_arr = pd.to_numeric(df["rl0311a"], errors="coerce").fillna(2).values
323 + bands_arr = band_of(doors_arr)
324 + bs = b["band_stats"]
325 + pl_raw = mf.predict(X[b["feats"]])
326 + # rétrécissement vers l'ancre empirique de la bande hors domaine
327 + w = shrink_weight(doors_arr)
328 + anchor = np.array([np.log(max(bs[i]["ratio_median"], 0.05)) for i in bands_arr])
329 + pl = w * pl_raw + (1 - w) * anchor
330 + # intervalles : quantiles + qhat de bande dans le domaine ;
331 + # dispersion empirique de bande recentrée hors domaine (13+ portes)
332 + q_b = np.array([bs[i]["qhat"] for i in bands_arr])
333 + lo = b["p10"].predict(X[b["feats"]]) - q_b
334 + hi = b["p90"].predict(X[b["feats"]]) + q_b
335 + big = doors_arr >= 13
336 + if big.any():
337 + med_b = np.array([np.log(max(bs[i]["ratio_median"], 0.05)) for i in bands_arr])
338 + q10_b = np.array([np.log(bs[i]["ratio_q10"]) if bs[i]["ratio_q10"] else med_b[k] - 0.4
339 + for k, i in enumerate(bands_arr)])
340 + q90_b = np.array([np.log(bs[i]["ratio_q90"]) if bs[i]["ratio_q90"] else med_b[k] + 0.4
341 + for k, i in enumerate(bands_arr)])
342 + lo[big] = pl[big] + (q10_b[big] - med_b[big])
343 + hi[big] = pl[big] + (q90_b[big] - med_b[big])
344 + out = pd.DataFrame({
345 + "id_provinc": df["id_provinc"], "annee": Y,
346 + "code_mun": df["code_mun"], "arrond": df["arrond"],
347 + "adresse": df["adresse"], "lat": df["lat"], "lng": df["lng"],
348 + "portes": lg_[ (cu == 1000) & (lg_ >= 2) ].values,
349 + "valeur_role": base,
350 + "valeur_estimee": np.where(ok, np.round(base * np.exp(pl) * sm, -2), np.nan),
351 + "valeur_estimee_p10": np.where(ok, np.round(base * np.exp(lo) * sm, -2), np.nan),
352 + "valeur_estimee_p90": np.where(ok, np.round(base * np.exp(hi) * sm, -2), np.nan),
353 + })
354 + out["municipalite"] = out["code_mun"].astype(str).str.strip() \
355 + .str.lstrip("0").map(muns)
356 + out.to_parquet(f"{ESTIM}/plex_estimes_{Y}.parquet", index=False)
357 + print(f"[{Y}] {len(out)} plex | médiane {out['valeur_estimee'].median():,.0f} $",
358 + flush=True)
359 + print("PREDICT_DONE")
360 +
361 +
362 +if __name__ == "__main__":
363 + {"train": train, "predict": predict}[sys.argv[1]]()
364