feat: Vrai-Prix — moteur d'évaluation immobilière transparent pour le Québec
Estimation hybride (modèle hédonique LightGBM + comparables ajustés), 3,75 M de propriétés, fourchette P10-P90, indice de confiance A-D, rapports PDF vectoriels, parc immobilier, statistiques provinciales. Identité éditoriale sharp — accent rouge #ff5148. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 50 changed files with +7,516 and −164
modified
.gitignore
+11 −38
@@ -1,41 +1,14 @@ | ||
| 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/ | |
| 1 | +# dépendances / build | |
| 2 | +node_modules/ | |
| 3 | +.next/ | |
| 4 | +*.tsbuildinfo | |
| 5 | +next-env.d.ts | |
| 19 | 6 | |
| 20 | −# production | |
| 21 | −/build | |
| 7 | +# données — jamais versionnées (base SQLite 1,5 Go) | |
| 8 | +data/ | |
| 22 | 9 | |
| 23 | −# misc | |
| 10 | +# divers | |
| 24 | 11 | .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 | |
| 12 | +*.log | |
| 13 | +test-results/ | |
| 14 | +playwright-report/ | |
modified
README.md
+109 −22
@@ -1,36 +1,123 @@ | ||
| 1 | −This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). | |
| 1 | +# Vrai-Prix | |
| 2 | 2 | |
| 3 | −## Getting Started | |
| 3 | +> **La vraie valeur de votre propriété, sans boîte noire.** | |
| 4 | +> Moteur d'évaluation immobilière transparent pour le Québec — 3,7 millions de propriétés, 745 119 ventes réelles, chaque calcul montré en entier. | |
| 4 | 5 | |
| 5 | −First, run the development server: | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 6 | 14 | |
| 7 | −```bash | |
| 8 | −npm run dev | |
| 9 | −# or | |
| 10 | −yarn dev | |
| 11 | −# or | |
| 12 | −pnpm dev | |
| 13 | −# or | |
| 14 | −bun dev | |
| 15 | +**En production : [www.vrai-prix.com](https://www.vrai-prix.com)** | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Ce que fait Vrai-Prix | |
| 20 | + | |
| 21 | +Vrai-Prix estime la valeur marchande de **n'importe quelle propriété du Québec** (unifamiliale, plex, condo, chalet, terrain) et montre *pourquoi* — chaque comparable, chaque ajustement en dollars, la pondération du modèle, la fourchette et l'indice de confiance. Le différenciateur est la **transparence totale** : pas de score magique, pas de boîte noire. | |
| 22 | + | |
| 23 | +| Fonctionnalité | Description | | |
| 24 | +|---|---| | |
| 25 | +| 🔎 Recherche d'adresse | Plein-texte (FTS5) parmi 3 747 008 unités d'évaluation | | |
| 26 | +| 💰 Estimation hybride | 65 % modèle hédonique + 35 % comparables ajustés | | |
| 27 | +| 📊 Fourchette + confiance | P10–P90 par régression quantile, indice A–D obligatoire | | |
| 28 | +| 🏠 Portrait de la propriété | Façade SVG dessinée par les données du registre, terrain à l'échelle, composition de la valeur | | |
| 29 | +| 🗺️ Plan des comparables | Carte « plan d'arpenteur » maison (SVG pur, zéro tuile) — azimut et distance réels | | |
| 30 | +| 📈 Historique 2021→2026 | Six millésimes d'estimations par propriété | | |
| 31 | +| 🏢 Parc immobilier | Évaluation multi-adresses agrégée (jusqu'à 40 propriétés) + rapport consolidé | | |
| 32 | +| 📄 Rapports PDF | Standard (3 p.), professionnel bancaire (6 p.), parc (n+1 p.), statistique provincial — 100 % vectoriels | | |
| 33 | +| 📉 Statistiques | Valeur totale de la province (**2,01 billions $**), palmarès des 200 municipalités | | |
| 34 | +| ⚖️ Conformité | Loi 25, sélecteur de témoins, méthodologie publique intégrale | | |
| 35 | + | |
| 36 | +## Les chiffres qui comptent | |
| 37 | + | |
| 38 | +Validation sur **102 943 ventes jamais vues** du modèle (holdout aléatoire) et test temporel strict (entraîné sans 2026, testé sur 68 364 ventes de 2026), selon la **norme IAAO sur études de ratios** : | |
| 39 | + | |
| 40 | +| Métrique | Holdout | Test temporel 2026 | Cible IAAO | | |
| 41 | +|---|---|---|---| | |
| 42 | +| Erreur médiane (MdAPE) | **11,0 %** | 14,0 % | — | | |
| 43 | +| À ±20 % du prix réel | 72,1 % | 66,7 % | — | | |
| 44 | +| Ratio médian | 0,995 | 0,961 | 0,90–1,10 ✅ | | |
| 45 | +| COD | 22,7 (condos : **11,9** ✅) | 23,7 | ≤ 15–20 | | |
| 46 | +| PRD | 1,092 | 1,075 | 0,98–1,03 | | |
| 47 | +| R² (log) | 0,789 | 0,743 | — | | |
| 48 | + | |
| 49 | +## Architecture | |
| 50 | + | |
| 51 | +``` | |
| 52 | +┌─ Données ouvertes MAMH ──────────────────────────────────────────┐ | |
| 53 | +│ Rôles d'évaluation foncière géoréférencés 2021-2026 │ | |
| 54 | +│ 6 millésimes × ~3,7 M unités = 22 150 285 observations │ | |
| 55 | +└──────────────┬───────────────────────────────────────────────────┘ | |
| 56 | + │ fusion spatiale XY (EPSG:32198) | |
| 57 | + │ kNN k=25 ≤200 m + jointure par valeur ≤500 m | |
| 58 | + │ → 99,88 % appariées, distance médiane 0,6 m | |
| 59 | +┌──────────────▼───────────────┐ ┌──────────────────────────────┐ | |
| 60 | +│ 745 119 transactions │ │ Modèle hédonique LightGBM │ | |
| 61 | +│ enrichies du rôle ├──▶│ log(prix), 18 variables │ | |
| 62 | +└──────────────────────────────┘ │ + quantiles P10/P90 │ | |
| 63 | + │ → 22 M d'estimations │ | |
| 64 | + └──────────────┬───────────────┘ | |
| 65 | +┌─ Application Next.js 16 ─────────────────────────▼───────────────┐ | |
| 66 | +│ SQLite (units + FTS5 + transactions + indice de marché) │ | |
| 67 | +│ Moteur comparables (ajust. marché/superficie/âge, médiane │ | |
| 68 | +│ pondérée) · RadarMap SVG · MetricViz · rapports pdfkit │ | |
| 69 | +└──────────────────────────────────────────────────────────────────┘ | |
| 15 | 70 | ``` |
| 16 | 71 | |
| 17 | −Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |
| 72 | +### Stack | |
| 73 | + | |
| 74 | +- **Frontend / API** : Next.js 16 (App Router), TypeScript strict, Tailwind CSS 4 | |
| 75 | +- **Base** : SQLite (better-sqlite3) — 3,75 M unités, FTS5, index spatiaux *(non incluse dans ce dépôt)* | |
| 76 | +- **Rapports** : pdfkit, 100 % vectoriels, polices embarquées (Space Grotesk / Inter / JetBrains Mono) | |
| 77 | +- **Modèle** (pipeline externe) : LightGBM, scikit-learn, pandas, pyogrio/GDAL | |
| 78 | +- **Tests** : Vitest (moteur d'estimation) + Playwright (mobile) | |
| 79 | + | |
| 80 | +### Design | |
| 81 | + | |
| 82 | +Identité « éditorial sharp » : papier grainé `#f5f3ee`, encre `#141814`, **accent rouge vif `#ff5148`**, bordeaux `#9e2a25` — bordures encre 1,5 px, ombres décalées, display uppercase, micro-étiquettes mono. | |
| 18 | 83 | |
| 19 | −You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | |
| 84 | +## Le moteur d'estimation (`src/lib/engine.ts`) | |
| 20 | 85 | |
| 21 | −This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. | |
| 86 | +1. **Comparables** : ventes candidates dans un rayon adaptatif (2,2 → 28 km), filtrées par type et superficie (±20 → ±40 %) ; | |
| 87 | +2. **Ajustements affichés en dollars** : conditions de marché (indice mensuel $/m² lissé 3 mois), superficie (50 % du $/m² du comparable, plafonné ±25 %), âge (0,5 %/an, plafonné ±10 %) ; | |
| 88 | +3. **Pondération gaussienne** : distance (σ 1 500 m), récence (σ 24 mois), similarité de superficie (σ 25 %) ; | |
| 89 | +4. **Fusion** : 65 % modèle hédonique pré-calculé + 35 % médiane pondérée des comparables ; | |
| 90 | +5. **Confiance** : nombre de comparables, dispersion, largeur de fourchette → A/B/C/D. | |
| 91 | + | |
| 92 | +## Démarrage | |
| 93 | + | |
| 94 | +```bash | |
| 95 | +npm install | |
| 96 | +# placer la base de données dans data/vraiprix.db (non versionnée) | |
| 97 | +npm run dev # développement | |
| 98 | +npm run test # tests du moteur (Vitest) | |
| 99 | +npm run build && npm run start | |
| 100 | +``` | |
| 22 | 101 | |
| 23 | −## Learn More | |
| 102 | +Variables d'environnement : `VRAIPRIX_DB` (chemin de la base SQLite), `PORT`. | |
| 24 | 103 | |
| 25 | −To learn more about Next.js, take a look at the following resources: | |
| 104 | +> **Note données** : la base SQLite (1,5 Go) et les pipelines d'entraînement ne sont pas 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). Les renseignements des propriétaires sont caviardés à la source. | |
| 26 | 105 | |
| 27 | −- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |
| 28 | −- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |
| 106 | +## Endpoints | |
| 29 | 107 | |
| 30 | −You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | |
| 108 | +| Route | Description | | |
| 109 | +|---|---| | |
| 110 | +| `GET /api/search?q=` | Autocomplétion d'adresses (FTS5) | | |
| 111 | +| `GET /api/estimate?id=` | Estimation complète d'une unité | | |
| 112 | +| `POST /api/estimate` | Estimation manuelle (municipalité + caractéristiques) | | |
| 113 | +| `POST /api/portfolio` | Agrégation d'un parc (jusqu'à 40 ids) | | |
| 114 | +| `GET /api/report?id=` | Rapport PDF standard (3 pages) | | |
| 115 | +| `GET /api/report/pro?id=` | Rapport détaillé format professionnel (6 pages) | | |
| 116 | +| `GET /api/report/portfolio?ids=` | Rapport de parc consolidé | | |
| 117 | +| `GET /api/report/stats` | Rapport statistique provincial | | |
| 31 | 118 | |
| 32 | −## Deploy on Vercel | |
| 119 | +## Auteur | |
| 33 | 120 | |
| 34 | −The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |
| 121 | +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 35 | 122 | |
| 36 | −Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | |
| 123 | +*Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ). Voir [conditions](https://www.vrai-prix.com/conditions) et [méthodologie complète](https://www.vrai-prix.com/methodologie).* | |
added
assets/fonts/Inter-Regular.ttf
+0 −0
Binary file not shown.
added
assets/fonts/JetBrainsMono-Bold.ttf
+0 −0
Binary file not shown.
added
assets/fonts/JetBrainsMono-Regular.ttf
+0 −0
Binary file not shown.
added
assets/fonts/SpaceGrotesk-Bold.ttf
+0 −0
Binary file not shown.
added
assets/fonts/SpaceGrotesk-Medium.ttf
+0 −0
Binary file not shown.
modified
next.config.ts
+2 −1
@@ -1,7 +1,8 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 1 | 2 | import type { NextConfig } from "next"; |
| 2 | 3 | |
| 3 | 4 | const nextConfig: NextConfig = { |
| 4 | − /* config options here */ | |
| 5 | + serverExternalPackages: ["better-sqlite3", "pdfkit"], | |
| 5 | 6 | }; |
| 6 | 7 | |
| 7 | 8 | export default nextConfig; |
modified
package-lock.json
+1350 −13
@@ -8,19 +8,25 @@ | ||
| 8 | 8 | "name": "vrai-prix", |
| 9 | 9 | "version": "0.1.0", |
| 10 | 10 | "dependencies": { |
| 11 | + "better-sqlite3": "^13.0.3", | |
| 11 | 12 | "next": "16.3.0", |
| 13 | + "pdfkit": "^0.19.1", | |
| 12 | 14 | "react": "19.2.8", |
| 13 | 15 | "react-dom": "19.2.8" |
| 14 | 16 | }, |
| 15 | 17 | "devDependencies": { |
| 16 | 18 | "@tailwindcss/postcss": "^4", |
| 19 | + "@types/better-sqlite3": "^9.6.0", | |
| 17 | 20 | "@types/node": "^20", |
| 21 | + "@types/pdfkit": "^0.17.6", | |
| 18 | 22 | "@types/react": "^19", |
| 19 | 23 | "@types/react-dom": "^19", |
| 20 | 24 | "eslint": "^9", |
| 21 | 25 | "eslint-config-next": "16.3.0", |
| 26 | + "playwright": "^1.62.1", | |
| 22 | 27 | "tailwindcss": "^4", |
| 23 | − "typescript": "^5" | |
| 28 | + "typescript": "^5", | |
| 29 | + "vitest": "^4.1.10" | |
| 24 | 30 | } |
| 25 | 31 | }, |
| 26 | 32 | "node_modules/@alloc/quick-lru": { |
@@ -1329,6 +1335,30 @@ | ||
| 1329 | 1335 | "node": ">= 10" |
| 1330 | 1336 | } |
| 1331 | 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 | + }, | |
| 1332 | 1362 | "node_modules/@nodelib/fs.scandir": { |
| 1333 | 1363 | "version": "2.1.5", |
| 1334 | 1364 | "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", |
@@ -1377,6 +1407,279 @@ | ||
| 1377 | 1407 | "node": ">=12.4.0" |
| 1378 | 1408 | } |
| 1379 | 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 | + }, | |
| 1380 | 1683 | "node_modules/@rtsao/scc": { |
| 1381 | 1684 | "version": "1.1.0", |
| 1382 | 1685 | "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", |
@@ -1384,6 +1687,13 @@ | ||
| 1384 | 1687 | "dev": true, |
| 1385 | 1688 | "license": "MIT" |
| 1386 | 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 | + }, | |
| 1387 | 1697 | "node_modules/@swc/helpers": { |
| 1388 | 1698 | "version": "0.5.15", |
| 1389 | 1699 | "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", |
@@ -1687,6 +1997,34 @@ | ||
| 1687 | 1997 | "tslib": "^2.4.0" |
| 1688 | 1998 | } |
| 1689 | 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 | + }, | |
| 1690 | 2028 | "node_modules/@types/estree": { |
| 1691 | 2029 | "version": "1.0.9", |
| 1692 | 2030 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", |
@@ -1718,6 +2056,16 @@ | ||
| 1718 | 2056 | "undici-types": "~6.21.0" |
| 1719 | 2057 | } |
| 1720 | 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 | + }, | |
| 1721 | 2069 | "node_modules/@types/react": { |
| 1722 | 2070 | "version": "19.2.18", |
| 1723 | 2071 | "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", |
@@ -2387,20 +2735,133 @@ | ||
| 2387 | 2735 | "win32" |
| 2388 | 2736 | ] |
| 2389 | 2737 | }, |
| 2390 | − "node_modules/acorn": { | |
| 2391 | − "version": "8.18.0", | |
| 2392 | − "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", | |
| 2393 | − "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", | |
| 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==", | |
| 2394 | 2742 | "dev": true, |
| 2395 | 2743 | "license": "MIT", |
| 2396 | − "bin": { | |
| 2397 | − "acorn": "bin/acorn" | |
| 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" | |
| 2398 | 2751 | }, |
| 2399 | − "engines": { | |
| 2400 | − "node": ">=0.4.0" | |
| 2752 | + "funding": { | |
| 2753 | + "url": "https://opencollective.com/vitest" | |
| 2401 | 2754 | } |
| 2402 | 2755 | }, |
| 2403 | − "node_modules/acorn-jsx": { | |
| 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": { | |
| 2404 | 2865 | "version": "5.3.2", |
| 2405 | 2866 | "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", |
| 2406 | 2867 | "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", |
@@ -2620,6 +3081,16 @@ | ||
| 2620 | 3081 | "url": "https://github.com/sponsors/ljharb" |
| 2621 | 3082 | } |
| 2622 | 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 | + }, | |
| 2623 | 3094 | "node_modules/ast-types-flow": { |
| 2624 | 3095 | "version": "0.0.8", |
| 2625 | 3096 | "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", |
@@ -2680,6 +3151,26 @@ | ||
| 2680 | 3151 | "dev": true, |
| 2681 | 3152 | "license": "MIT" |
| 2682 | 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 | + }, | |
| 2683 | 3174 | "node_modules/baseline-browser-mapping": { |
| 2684 | 3175 | "version": "2.11.13", |
| 2685 | 3176 | "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", |
@@ -2692,6 +3183,18 @@ | ||
| 2692 | 3183 | "node": ">=6.0.0" |
| 2693 | 3184 | } |
| 2694 | 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 | + "license": "MIT", | |
| 3191 | + "dependencies": { | |
| 3192 | + "node-addon-api": "^8.0.0" | |
| 3193 | + }, | |
| 3194 | + "engines": { | |
| 3195 | + "node": ">=22" | |
| 3196 | + } | |
| 3197 | + }, | |
| 2695 | 3198 | "node_modules/brace-expansion": { |
| 2696 | 3199 | "version": "1.1.18", |
| 2697 | 3200 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", |
@@ -2716,6 +3219,24 @@ | ||
| 2716 | 3219 | "node": ">=8" |
| 2717 | 3220 | } |
| 2718 | 3221 | }, |
| 3222 | + "node_modules/brotli": { | |
| 3223 | + "version": "1.3.3", | |
| 3224 | + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", | |
| 3225 | + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", | |
| 3226 | + "license": "MIT", | |
| 3227 | + "dependencies": { | |
| 3228 | + "base64-js": "^1.1.2" | |
| 3229 | + } | |
| 3230 | + }, | |
| 3231 | + "node_modules/browserify-zlib": { | |
| 3232 | + "version": "0.2.0", | |
| 3233 | + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", | |
| 3234 | + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", | |
| 3235 | + "license": "MIT", | |
| 3236 | + "dependencies": { | |
| 3237 | + "pako": "~1.0.5" | |
| 3238 | + } | |
| 3239 | + }, | |
| 2719 | 3240 | "node_modules/browserslist": { |
| 2720 | 3241 | "version": "4.28.8", |
| 2721 | 3242 | "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", |
@@ -2830,6 +3351,16 @@ | ||
| 2830 | 3351 | ], |
| 2831 | 3352 | "license": "CC-BY-4.0" |
| 2832 | 3353 | }, |
| 3354 | + "node_modules/chai": { | |
| 3355 | + "version": "6.2.2", | |
| 3356 | + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", | |
| 3357 | + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", | |
| 3358 | + "dev": true, | |
| 3359 | + "license": "MIT", | |
| 3360 | + "engines": { | |
| 3361 | + "node": ">=18" | |
| 3362 | + } | |
| 3363 | + }, | |
| 2833 | 3364 | "node_modules/chalk": { |
| 2834 | 3365 | "version": "4.1.2", |
| 2835 | 3366 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", |
@@ -2853,6 +3384,15 @@ | ||
| 2853 | 3384 | "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", |
| 2854 | 3385 | "license": "MIT" |
| 2855 | 3386 | }, |
| 3387 | + "node_modules/clone": { | |
| 3388 | + "version": "2.1.2", | |
| 3389 | + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", | |
| 3390 | + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", | |
| 3391 | + "license": "MIT", | |
| 3392 | + "engines": { | |
| 3393 | + "node": ">=0.8" | |
| 3394 | + } | |
| 3395 | + }, | |
| 2856 | 3396 | "node_modules/color-convert": { |
| 2857 | 3397 | "version": "2.0.1", |
| 2858 | 3398 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", |
@@ -3041,6 +3581,12 @@ | ||
| 3041 | 3581 | "node": ">=8" |
| 3042 | 3582 | } |
| 3043 | 3583 | }, |
| 3584 | + "node_modules/dfa": { | |
| 3585 | + "version": "1.2.0", | |
| 3586 | + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", | |
| 3587 | + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", | |
| 3588 | + "license": "MIT" | |
| 3589 | + }, | |
| 3044 | 3590 | "node_modules/doctrine": { |
| 3045 | 3591 | "version": "2.1.0", |
| 3046 | 3592 | "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", |
@@ -3233,6 +3779,13 @@ | ||
| 3233 | 3779 | "node": ">= 0.4" |
| 3234 | 3780 | } |
| 3235 | 3781 | }, |
| 3782 | + "node_modules/es-module-lexer": { | |
| 3783 | + "version": "2.3.1", | |
| 3784 | + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", | |
| 3785 | + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", | |
| 3786 | + "dev": true, | |
| 3787 | + "license": "MIT" | |
| 3788 | + }, | |
| 3236 | 3789 | "node_modules/es-object-atoms": { |
| 3237 | 3790 | "version": "1.1.2", |
| 3238 | 3791 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", |
@@ -3715,6 +4268,16 @@ | ||
| 3715 | 4268 | "node": ">=4.0" |
| 3716 | 4269 | } |
| 3717 | 4270 | }, |
| 4271 | + "node_modules/estree-walker": { | |
| 4272 | + "version": "3.0.3", | |
| 4273 | + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", | |
| 4274 | + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", | |
| 4275 | + "dev": true, | |
| 4276 | + "license": "MIT", | |
| 4277 | + "dependencies": { | |
| 4278 | + "@types/estree": "^1.0.0" | |
| 4279 | + } | |
| 4280 | + }, | |
| 3718 | 4281 | "node_modules/esutils": { |
| 3719 | 4282 | "version": "2.0.3", |
| 3720 | 4283 | "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", |
@@ -3725,11 +4288,20 @@ | ||
| 3725 | 4288 | "node": ">=0.10.0" |
| 3726 | 4289 | } |
| 3727 | 4290 | }, |
| 4291 | + "node_modules/expect-type": { | |
| 4292 | + "version": "1.4.0", | |
| 4293 | + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", | |
| 4294 | + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", | |
| 4295 | + "dev": true, | |
| 4296 | + "license": "Apache-2.0", | |
| 4297 | + "engines": { | |
| 4298 | + "node": ">=12.0.0" | |
| 4299 | + } | |
| 4300 | + }, | |
| 3728 | 4301 | "node_modules/fast-deep-equal": { |
| 3729 | 4302 | "version": "3.1.3", |
| 3730 | 4303 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", |
| 3731 | 4304 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", |
| 3732 | − "dev": true, | |
| 3733 | 4305 | "license": "MIT" |
| 3734 | 4306 | }, |
| 3735 | 4307 | "node_modules/fast-glob": { |
@@ -3850,6 +4422,23 @@ | ||
| 3850 | 4422 | "dev": true, |
| 3851 | 4423 | "license": "ISC" |
| 3852 | 4424 | }, |
| 4425 | + "node_modules/fontkit": { | |
| 4426 | + "version": "2.0.4", | |
| 4427 | + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", | |
| 4428 | + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", | |
| 4429 | + "license": "MIT", | |
| 4430 | + "dependencies": { | |
| 4431 | + "@swc/helpers": "^0.5.12", | |
| 4432 | + "brotli": "^1.3.2", | |
| 4433 | + "clone": "^2.1.2", | |
| 4434 | + "dfa": "^1.2.0", | |
| 4435 | + "fast-deep-equal": "^3.1.3", | |
| 4436 | + "restructure": "^3.0.0", | |
| 4437 | + "tiny-inflate": "^1.0.3", | |
| 4438 | + "unicode-properties": "^1.4.0", | |
| 4439 | + "unicode-trie": "^2.0.0" | |
| 4440 | + } | |
| 4441 | + }, | |
| 3853 | 4442 | "node_modules/for-each": { |
| 3854 | 4443 | "version": "0.3.5", |
| 3855 | 4444 | "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", |
@@ -3866,6 +4455,21 @@ | ||
| 3866 | 4455 | "url": "https://github.com/sponsors/ljharb" |
| 3867 | 4456 | } |
| 3868 | 4457 | }, |
| 4458 | + "node_modules/fsevents": { | |
| 4459 | + "version": "2.3.3", | |
| 4460 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |
| 4461 | + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", | |
| 4462 | + "dev": true, | |
| 4463 | + "hasInstallScript": true, | |
| 4464 | + "license": "MIT", | |
| 4465 | + "optional": true, | |
| 4466 | + "os": [ | |
| 4467 | + "darwin" | |
| 4468 | + ], | |
| 4469 | + "engines": { | |
| 4470 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 4471 | + } | |
| 4472 | + }, | |
| 3869 | 4473 | "node_modules/function-bind": { |
| 3870 | 4474 | "version": "1.1.2", |
| 3871 | 4475 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", |
@@ -4699,6 +5303,12 @@ | ||
| 4699 | 5303 | "jiti": "lib/jiti-cli.mjs" |
| 4700 | 5304 | } |
| 4701 | 5305 | }, |
| 5306 | + "node_modules/js-md5": { | |
| 5307 | + "version": "0.8.3", | |
| 5308 | + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", | |
| 5309 | + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", | |
| 5310 | + "license": "MIT" | |
| 5311 | + }, | |
| 4702 | 5312 | "node_modules/js-tokens": { |
| 4703 | 5313 | "version": "4.0.0", |
| 4704 | 5314 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", |
@@ -5109,6 +5719,25 @@ | ||
| 5109 | 5719 | "url": "https://opencollective.com/parcel" |
| 5110 | 5720 | } |
| 5111 | 5721 | }, |
| 5722 | + "node_modules/linebreak": { | |
| 5723 | + "version": "1.1.0", | |
| 5724 | + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", | |
| 5725 | + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", | |
| 5726 | + "license": "MIT", | |
| 5727 | + "dependencies": { | |
| 5728 | + "base64-js": "0.0.8", | |
| 5729 | + "unicode-trie": "^2.0.0" | |
| 5730 | + } | |
| 5731 | + }, | |
| 5732 | + "node_modules/linebreak/node_modules/base64-js": { | |
| 5733 | + "version": "0.0.8", | |
| 5734 | + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", | |
| 5735 | + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", | |
| 5736 | + "license": "MIT", | |
| 5737 | + "engines": { | |
| 5738 | + "node": ">= 0.4" | |
| 5739 | + } | |
| 5740 | + }, | |
| 5112 | 5741 | "node_modules/locate-path": { |
| 5113 | 5742 | "version": "6.0.0", |
| 5114 | 5743 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", |
@@ -5351,6 +5980,15 @@ | ||
| 5351 | 5980 | "node": "^10 || ^12 || >=14" |
| 5352 | 5981 | } |
| 5353 | 5982 | }, |
| 5983 | + "node_modules/node-addon-api": { | |
| 5984 | + "version": "8.9.1", | |
| 5985 | + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", | |
| 5986 | + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", | |
| 5987 | + "license": "MIT", | |
| 5988 | + "engines": { | |
| 5989 | + "node": "^18 || ^20 || >= 21" | |
| 5990 | + } | |
| 5991 | + }, | |
| 5354 | 5992 | "node_modules/node-exports-info": { |
| 5355 | 5993 | "version": "1.6.2", |
| 5356 | 5994 | "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", |
@@ -5503,6 +6141,20 @@ | ||
| 5503 | 6141 | "url": "https://github.com/sponsors/ljharb" |
| 5504 | 6142 | } |
| 5505 | 6143 | }, |
| 6144 | + "node_modules/obug": { | |
| 6145 | + "version": "2.1.4", | |
| 6146 | + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", | |
| 6147 | + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", | |
| 6148 | + "dev": true, | |
| 6149 | + "funding": [ | |
| 6150 | + "https://github.com/sponsors/sxzz", | |
| 6151 | + "https://opencollective.com/debug" | |
| 6152 | + ], | |
| 6153 | + "license": "MIT", | |
| 6154 | + "engines": { | |
| 6155 | + "node": ">=12.20.0" | |
| 6156 | + } | |
| 6157 | + }, | |
| 5506 | 6158 | "node_modules/optionator": { |
| 5507 | 6159 | "version": "0.9.4", |
| 5508 | 6160 | "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", |
@@ -5572,6 +6224,12 @@ | ||
| 5572 | 6224 | "url": "https://github.com/sponsors/sindresorhus" |
| 5573 | 6225 | } |
| 5574 | 6226 | }, |
| 6227 | + "node_modules/pako": { | |
| 6228 | + "version": "1.0.11", | |
| 6229 | + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", | |
| 6230 | + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", | |
| 6231 | + "license": "(MIT AND Zlib)" | |
| 6232 | + }, | |
| 5575 | 6233 | "node_modules/parent-module": { |
| 5576 | 6234 | "version": "1.0.1", |
| 5577 | 6235 | "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", |
@@ -5612,6 +6270,27 @@ | ||
| 5612 | 6270 | "dev": true, |
| 5613 | 6271 | "license": "MIT" |
| 5614 | 6272 | }, |
| 6273 | + "node_modules/pathe": { | |
| 6274 | + "version": "2.0.3", | |
| 6275 | + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", | |
| 6276 | + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", | |
| 6277 | + "dev": true, | |
| 6278 | + "license": "MIT" | |
| 6279 | + }, | |
| 6280 | + "node_modules/pdfkit": { | |
| 6281 | + "version": "0.19.1", | |
| 6282 | + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", | |
| 6283 | + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", | |
| 6284 | + "license": "MIT", | |
| 6285 | + "dependencies": { | |
| 6286 | + "@noble/ciphers": "^1.0.0", | |
| 6287 | + "@noble/hashes": "^1.6.0", | |
| 6288 | + "fontkit": "^2.0.4", | |
| 6289 | + "js-md5": "^0.8.3", | |
| 6290 | + "linebreak": "^1.1.0", | |
| 6291 | + "png-js": "^1.1.0" | |
| 6292 | + } | |
| 6293 | + }, | |
| 5615 | 6294 | "node_modules/picocolors": { |
| 5616 | 6295 | "version": "1.1.1", |
| 5617 | 6296 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", |
@@ -5631,6 +6310,61 @@ | ||
| 5631 | 6310 | "url": "https://github.com/sponsors/jonschlinkert" |
| 5632 | 6311 | } |
| 5633 | 6312 | }, |
| 6313 | + "node_modules/playwright": { | |
| 6314 | + "version": "1.62.1", | |
| 6315 | + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", | |
| 6316 | + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", | |
| 6317 | + "dev": true, | |
| 6318 | + "license": "Apache-2.0", | |
| 6319 | + "dependencies": { | |
| 6320 | + "playwright-core": "1.62.1" | |
| 6321 | + }, | |
| 6322 | + "bin": { | |
| 6323 | + "playwright": "cli.js" | |
| 6324 | + }, | |
| 6325 | + "engines": { | |
| 6326 | + "node": ">=20" | |
| 6327 | + }, | |
| 6328 | + "optionalDependencies": { | |
| 6329 | + "fsevents": "2.3.2" | |
| 6330 | + } | |
| 6331 | + }, | |
| 6332 | + "node_modules/playwright-core": { | |
| 6333 | + "version": "1.62.1", | |
| 6334 | + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", | |
| 6335 | + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", | |
| 6336 | + "dev": true, | |
| 6337 | + "license": "Apache-2.0", | |
| 6338 | + "bin": { | |
| 6339 | + "playwright-core": "cli.js" | |
| 6340 | + }, | |
| 6341 | + "engines": { | |
| 6342 | + "node": ">=20" | |
| 6343 | + } | |
| 6344 | + }, | |
| 6345 | + "node_modules/playwright/node_modules/fsevents": { | |
| 6346 | + "version": "2.3.2", | |
| 6347 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", | |
| 6348 | + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | |
| 6349 | + "dev": true, | |
| 6350 | + "hasInstallScript": true, | |
| 6351 | + "license": "MIT", | |
| 6352 | + "optional": true, | |
| 6353 | + "os": [ | |
| 6354 | + "darwin" | |
| 6355 | + ], | |
| 6356 | + "engines": { | |
| 6357 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 6358 | + } | |
| 6359 | + }, | |
| 6360 | + "node_modules/png-js": { | |
| 6361 | + "version": "1.1.0", | |
| 6362 | + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", | |
| 6363 | + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", | |
| 6364 | + "dependencies": { | |
| 6365 | + "browserify-zlib": "^0.2.0" | |
| 6366 | + } | |
| 6367 | + }, | |
| 5634 | 6368 | "node_modules/possible-typed-array-names": { |
| 5635 | 6369 | "version": "1.1.0", |
| 5636 | 6370 | "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", |
@@ -5839,6 +6573,12 @@ | ||
| 5839 | 6573 | "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" |
| 5840 | 6574 | } |
| 5841 | 6575 | }, |
| 6576 | + "node_modules/restructure": { | |
| 6577 | + "version": "3.0.2", | |
| 6578 | + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", | |
| 6579 | + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", | |
| 6580 | + "license": "MIT" | |
| 6581 | + }, | |
| 5842 | 6582 | "node_modules/reusify": { |
| 5843 | 6583 | "version": "1.1.0", |
| 5844 | 6584 | "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", |
@@ -5850,6 +6590,39 @@ | ||
| 5850 | 6590 | "node": ">=0.10.0" |
| 5851 | 6591 | } |
| 5852 | 6592 | }, |
| 6593 | + "node_modules/rolldown": { | |
| 6594 | + "version": "1.2.3", | |
| 6595 | + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", | |
| 6596 | + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", | |
| 6597 | + "dev": true, | |
| 6598 | + "license": "MIT", | |
| 6599 | + "dependencies": { | |
| 6600 | + "@oxc-project/types": "=0.143.0", | |
| 6601 | + "@rolldown/pluginutils": "^1.0.0" | |
| 6602 | + }, | |
| 6603 | + "bin": { | |
| 6604 | + "rolldown": "bin/cli.mjs" | |
| 6605 | + }, | |
| 6606 | + "engines": { | |
| 6607 | + "node": "^20.19.0 || >=22.12.0" | |
| 6608 | + }, | |
| 6609 | + "optionalDependencies": { | |
| 6610 | + "@rolldown/binding-android-arm64": "1.2.3", | |
| 6611 | + "@rolldown/binding-darwin-arm64": "1.2.3", | |
| 6612 | + "@rolldown/binding-darwin-x64": "1.2.3", | |
| 6613 | + "@rolldown/binding-freebsd-x64": "1.2.3", | |
| 6614 | + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", | |
| 6615 | + "@rolldown/binding-linux-arm64-gnu": "1.2.3", | |
| 6616 | + "@rolldown/binding-linux-arm64-musl": "1.2.3", | |
| 6617 | + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", | |
| 6618 | + "@rolldown/binding-linux-s390x-gnu": "1.2.3", | |
| 6619 | + "@rolldown/binding-linux-x64-gnu": "1.2.3", | |
| 6620 | + "@rolldown/binding-linux-x64-musl": "1.2.3", | |
| 6621 | + "@rolldown/binding-openharmony-arm64": "1.2.3", | |
| 6622 | + "@rolldown/binding-win32-arm64-msvc": "1.2.3", | |
| 6623 | + "@rolldown/binding-win32-x64-msvc": "1.2.3" | |
| 6624 | + } | |
| 6625 | + }, | |
| 5853 | 6626 | "node_modules/run-parallel": { |
| 5854 | 6627 | "version": "1.2.0", |
| 5855 | 6628 | "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", |
@@ -6156,6 +6929,13 @@ | ||
| 6156 | 6929 | "url": "https://github.com/sponsors/ljharb" |
| 6157 | 6930 | } |
| 6158 | 6931 | }, |
| 6932 | + "node_modules/siginfo": { | |
| 6933 | + "version": "2.0.0", | |
| 6934 | + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", | |
| 6935 | + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", | |
| 6936 | + "dev": true, | |
| 6937 | + "license": "ISC" | |
| 6938 | + }, | |
| 6159 | 6939 | "node_modules/source-map-js": { |
| 6160 | 6940 | "version": "1.2.1", |
| 6161 | 6941 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", |
@@ -6172,6 +6952,20 @@ | ||
| 6172 | 6952 | "dev": true, |
| 6173 | 6953 | "license": "MIT" |
| 6174 | 6954 | }, |
| 6955 | + "node_modules/stackback": { | |
| 6956 | + "version": "0.0.2", | |
| 6957 | + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", | |
| 6958 | + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", | |
| 6959 | + "dev": true, | |
| 6960 | + "license": "MIT" | |
| 6961 | + }, | |
| 6962 | + "node_modules/std-env": { | |
| 6963 | + "version": "4.2.0", | |
| 6964 | + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", | |
| 6965 | + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", | |
| 6966 | + "dev": true, | |
| 6967 | + "license": "MIT" | |
| 6968 | + }, | |
| 6175 | 6969 | "node_modules/stop-iteration-iterator": { |
| 6176 | 6970 | "version": "1.1.0", |
| 6177 | 6971 | "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", |
@@ -6393,6 +7187,29 @@ | ||
| 6393 | 7187 | "url": "https://opencollective.com/webpack" |
| 6394 | 7188 | } |
| 6395 | 7189 | }, |
| 7190 | + "node_modules/tiny-inflate": { | |
| 7191 | + "version": "1.0.3", | |
| 7192 | + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", | |
| 7193 | + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", | |
| 7194 | + "license": "MIT" | |
| 7195 | + }, | |
| 7196 | + "node_modules/tinybench": { | |
| 7197 | + "version": "2.9.0", | |
| 7198 | + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", | |
| 7199 | + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", | |
| 7200 | + "dev": true, | |
| 7201 | + "license": "MIT" | |
| 7202 | + }, | |
| 7203 | + "node_modules/tinyexec": { | |
| 7204 | + "version": "1.3.0", | |
| 7205 | + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", | |
| 7206 | + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", | |
| 7207 | + "dev": true, | |
| 7208 | + "license": "MIT", | |
| 7209 | + "engines": { | |
| 7210 | + "node": ">=18" | |
| 7211 | + } | |
| 7212 | + }, | |
| 6396 | 7213 | "node_modules/tinyglobby": { |
| 6397 | 7214 | "version": "0.2.17", |
| 6398 | 7215 | "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", |
@@ -6441,8 +7258,18 @@ | ||
| 6441 | 7258 | "url": "https://github.com/sponsors/jonschlinkert" |
| 6442 | 7259 | } |
| 6443 | 7260 | }, |
| 6444 | − "node_modules/to-regex-range": { | |
| 6445 | − "version": "5.0.1", | |
| 7261 | + "node_modules/tinyrainbow": { | |
| 7262 | + "version": "3.1.1", | |
| 7263 | + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", | |
| 7264 | + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", | |
| 7265 | + "dev": true, | |
| 7266 | + "license": "MIT", | |
| 7267 | + "engines": { | |
| 7268 | + "node": ">=14.0.0" | |
| 7269 | + } | |
| 7270 | + }, | |
| 7271 | + "node_modules/to-regex-range": { | |
| 7272 | + "version": "5.0.1", | |
| 6446 | 7273 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", |
| 6447 | 7274 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", |
| 6448 | 7275 | "dev": true, |
@@ -6654,6 +7481,32 @@ | ||
| 6654 | 7481 | "dev": true, |
| 6655 | 7482 | "license": "MIT" |
| 6656 | 7483 | }, |
| 7484 | + "node_modules/unicode-properties": { | |
| 7485 | + "version": "1.4.1", | |
| 7486 | + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", | |
| 7487 | + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", | |
| 7488 | + "license": "MIT", | |
| 7489 | + "dependencies": { | |
| 7490 | + "base64-js": "^1.3.0", | |
| 7491 | + "unicode-trie": "^2.0.0" | |
| 7492 | + } | |
| 7493 | + }, | |
| 7494 | + "node_modules/unicode-trie": { | |
| 7495 | + "version": "2.0.0", | |
| 7496 | + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", | |
| 7497 | + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", | |
| 7498 | + "license": "MIT", | |
| 7499 | + "dependencies": { | |
| 7500 | + "pako": "^0.2.5", | |
| 7501 | + "tiny-inflate": "^1.0.0" | |
| 7502 | + } | |
| 7503 | + }, | |
| 7504 | + "node_modules/unicode-trie/node_modules/pako": { | |
| 7505 | + "version": "0.2.9", | |
| 7506 | + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", | |
| 7507 | + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", | |
| 7508 | + "license": "MIT" | |
| 7509 | + }, | |
| 6657 | 7510 | "node_modules/unrs-resolver": { |
| 6658 | 7511 | "version": "1.12.2", |
| 6659 | 7512 | "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", |
@@ -6733,6 +7586,473 @@ | ||
| 6733 | 7586 | "punycode": "^2.1.0" |
| 6734 | 7587 | } |
| 6735 | 7588 | }, |
| 7589 | + "node_modules/vite": { | |
| 7590 | + "version": "8.2.1", | |
| 7591 | + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", | |
| 7592 | + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", | |
| 7593 | + "dev": true, | |
| 7594 | + "license": "MIT", | |
| 7595 | + "dependencies": { | |
| 7596 | + "lightningcss": "^1.33.0", | |
| 7597 | + "picomatch": "^4.0.5", | |
| 7598 | + "postcss": "^8.5.25", | |
| 7599 | + "rolldown": "~1.2.1", | |
| 7600 | + "tinyglobby": "^0.2.17" | |
| 7601 | + }, | |
| 7602 | + "bin": { | |
| 7603 | + "vite": "bin/vite.js" | |
| 7604 | + }, | |
| 7605 | + "engines": { | |
| 7606 | + "node": "^20.19.0 || >=22.12.0" | |
| 7607 | + }, | |
| 7608 | + "funding": { | |
| 7609 | + "url": "https://github.com/vitejs/vite?sponsor=1" | |
| 7610 | + }, | |
| 7611 | + "optionalDependencies": { | |
| 7612 | + "fsevents": "~2.3.3" | |
| 7613 | + }, | |
| 7614 | + "peerDependencies": { | |
| 7615 | + "@types/node": "^20.19.0 || >=22.12.0", | |
| 7616 | + "@vitejs/devtools": "^0.4.0", | |
| 7617 | + "esbuild": "^0.27.0 || ^0.28.0", | |
| 7618 | + "jiti": ">=1.21.0", | |
| 7619 | + "less": "^4.0.0", | |
| 7620 | + "sass": "^1.70.0", | |
| 7621 | + "sass-embedded": "^1.70.0", | |
| 7622 | + "stylus": ">=0.54.8", | |
| 7623 | + "sugarss": "^5.0.0", | |
| 7624 | + "terser": "^5.16.0", | |
| 7625 | + "tsx": "^4.8.1", | |
| 7626 | + "yaml": "^2.4.2" | |
| 7627 | + }, | |
| 7628 | + "peerDependenciesMeta": { | |
| 7629 | + "@types/node": { | |
| 7630 | + "optional": true | |
| 7631 | + }, | |
| 7632 | + "@vitejs/devtools": { | |
| 7633 | + "optional": true | |
| 7634 | + }, | |
| 7635 | + "esbuild": { | |
| 7636 | + "optional": true | |
| 7637 | + }, | |
| 7638 | + "jiti": { | |
| 7639 | + "optional": true | |
| 7640 | + }, | |
| 7641 | + "less": { | |
| 7642 | + "optional": true | |
| 7643 | + }, | |
| 7644 | + "sass": { | |
| 7645 | + "optional": true | |
| 7646 | + }, | |
| 7647 | + "sass-embedded": { | |
| 7648 | + "optional": true | |
| 7649 | + }, | |
| 7650 | + "stylus": { | |
| 7651 | + "optional": true | |
| 7652 | + }, | |
| 7653 | + "sugarss": { | |
| 7654 | + "optional": true | |
| 7655 | + }, | |
| 7656 | + "terser": { | |
| 7657 | + "optional": true | |
| 7658 | + }, | |
| 7659 | + "tsx": { | |
| 7660 | + "optional": true | |
| 7661 | + }, | |
| 7662 | + "yaml": { | |
| 7663 | + "optional": true | |
| 7664 | + } | |
| 7665 | + } | |
| 7666 | + }, | |
| 7667 | + "node_modules/vite/node_modules/lightningcss": { | |
| 7668 | + "version": "1.33.0", | |
| 7669 | + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", | |
| 7670 | + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", | |
| 7671 | + "dev": true, | |
| 7672 | + "license": "MPL-2.0", | |
| 7673 | + "dependencies": { | |
| 7674 | + "detect-libc": "^2.0.3" | |
| 7675 | + }, | |
| 7676 | + "engines": { | |
| 7677 | + "node": ">= 12.0.0" | |
| 7678 | + }, | |
| 7679 | + "funding": { | |
| 7680 | + "type": "opencollective", | |
| 7681 | + "url": "https://opencollective.com/parcel" | |
| 7682 | + }, | |
| 7683 | + "optionalDependencies": { | |
| 7684 | + "lightningcss-android-arm64": "1.33.0", | |
| 7685 | + "lightningcss-darwin-arm64": "1.33.0", | |
| 7686 | + "lightningcss-darwin-x64": "1.33.0", | |
| 7687 | + "lightningcss-freebsd-x64": "1.33.0", | |
| 7688 | + "lightningcss-linux-arm-gnueabihf": "1.33.0", | |
| 7689 | + "lightningcss-linux-arm64-gnu": "1.33.0", | |
| 7690 | + "lightningcss-linux-arm64-musl": "1.33.0", | |
| 7691 | + "lightningcss-linux-x64-gnu": "1.33.0", | |
| 7692 | + "lightningcss-linux-x64-musl": "1.33.0", | |
| 7693 | + "lightningcss-win32-arm64-msvc": "1.33.0", | |
| 7694 | + "lightningcss-win32-x64-msvc": "1.33.0" | |
| 7695 | + } | |
| 7696 | + }, | |
| 7697 | + "node_modules/vite/node_modules/lightningcss-android-arm64": { | |
| 7698 | + "version": "1.33.0", | |
| 7699 | + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", | |
| 7700 | + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", | |
| 7701 | + "cpu": [ | |
| 7702 | + "arm64" | |
| 7703 | + ], | |
| 7704 | + "dev": true, | |
| 7705 | + "license": "MPL-2.0", | |
| 7706 | + "optional": true, | |
| 7707 | + "os": [ | |
| 7708 | + "android" | |
| 7709 | + ], | |
| 7710 | + "engines": { | |
| 7711 | + "node": ">= 12.0.0" | |
| 7712 | + }, | |
| 7713 | + "funding": { | |
| 7714 | + "type": "opencollective", | |
| 7715 | + "url": "https://opencollective.com/parcel" | |
| 7716 | + } | |
| 7717 | + }, | |
| 7718 | + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { | |
| 7719 | + "version": "1.33.0", | |
| 7720 | + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", | |
| 7721 | + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", | |
| 7722 | + "cpu": [ | |
| 7723 | + "arm64" | |
| 7724 | + ], | |
| 7725 | + "dev": true, | |
| 7726 | + "license": "MPL-2.0", | |
| 7727 | + "optional": true, | |
| 7728 | + "os": [ | |
| 7729 | + "darwin" | |
| 7730 | + ], | |
| 7731 | + "engines": { | |
| 7732 | + "node": ">= 12.0.0" | |
| 7733 | + }, | |
| 7734 | + "funding": { | |
| 7735 | + "type": "opencollective", | |
| 7736 | + "url": "https://opencollective.com/parcel" | |
| 7737 | + } | |
| 7738 | + }, | |
| 7739 | + "node_modules/vite/node_modules/lightningcss-darwin-x64": { | |
| 7740 | + "version": "1.33.0", | |
| 7741 | + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", | |
| 7742 | + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", | |
| 7743 | + "cpu": [ | |
| 7744 | + "x64" | |
| 7745 | + ], | |
| 7746 | + "dev": true, | |
| 7747 | + "license": "MPL-2.0", | |
| 7748 | + "optional": true, | |
| 7749 | + "os": [ | |
| 7750 | + "darwin" | |
| 7751 | + ], | |
| 7752 | + "engines": { | |
| 7753 | + "node": ">= 12.0.0" | |
| 7754 | + }, | |
| 7755 | + "funding": { | |
| 7756 | + "type": "opencollective", | |
| 7757 | + "url": "https://opencollective.com/parcel" | |
| 7758 | + } | |
| 7759 | + }, | |
| 7760 | + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { | |
| 7761 | + "version": "1.33.0", | |
| 7762 | + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", | |
| 7763 | + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", | |
| 7764 | + "cpu": [ | |
| 7765 | + "x64" | |
| 7766 | + ], | |
| 7767 | + "dev": true, | |
| 7768 | + "license": "MPL-2.0", | |
| 7769 | + "optional": true, | |
| 7770 | + "os": [ | |
| 7771 | + "freebsd" | |
| 7772 | + ], | |
| 7773 | + "engines": { | |
| 7774 | + "node": ">= 12.0.0" | |
| 7775 | + }, | |
| 7776 | + "funding": { | |
| 7777 | + "type": "opencollective", | |
| 7778 | + "url": "https://opencollective.com/parcel" | |
| 7779 | + } | |
| 7780 | + }, | |
| 7781 | + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { | |
| 7782 | + "version": "1.33.0", | |
| 7783 | + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", | |
| 7784 | + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", | |
| 7785 | + "cpu": [ | |
| 7786 | + "arm" | |
| 7787 | + ], | |
| 7788 | + "dev": true, | |
| 7789 | + "license": "MPL-2.0", | |
| 7790 | + "optional": true, | |
| 7791 | + "os": [ | |
| 7792 | + "linux" | |
| 7793 | + ], | |
| 7794 | + "engines": { | |
| 7795 | + "node": ">= 12.0.0" | |
| 7796 | + }, | |
| 7797 | + "funding": { | |
| 7798 | + "type": "opencollective", | |
| 7799 | + "url": "https://opencollective.com/parcel" | |
| 7800 | + } | |
| 7801 | + }, | |
| 7802 | + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { | |
| 7803 | + "version": "1.33.0", | |
| 7804 | + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", | |
| 7805 | + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", | |
| 7806 | + "cpu": [ | |
| 7807 | + "arm64" | |
| 7808 | + ], | |
| 7809 | + "dev": true, | |
| 7810 | + "libc": [ | |
| 7811 | + "glibc" | |
| 7812 | + ], | |
| 7813 | + "license": "MPL-2.0", | |
| 7814 | + "optional": true, | |
| 7815 | + "os": [ | |
| 7816 | + "linux" | |
| 7817 | + ], | |
| 7818 | + "engines": { | |
| 7819 | + "node": ">= 12.0.0" | |
| 7820 | + }, | |
| 7821 | + "funding": { | |
| 7822 | + "type": "opencollective", | |
| 7823 | + "url": "https://opencollective.com/parcel" | |
| 7824 | + } | |
| 7825 | + }, | |
| 7826 | + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { | |
| 7827 | + "version": "1.33.0", | |
| 7828 | + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", | |
| 7829 | + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", | |
| 7830 | + "cpu": [ | |
| 7831 | + "arm64" | |
| 7832 | + ], | |
| 7833 | + "dev": true, | |
| 7834 | + "libc": [ | |
| 7835 | + "musl" | |
| 7836 | + ], | |
| 7837 | + "license": "MPL-2.0", | |
| 7838 | + "optional": true, | |
| 7839 | + "os": [ | |
| 7840 | + "linux" | |
| 7841 | + ], | |
| 7842 | + "engines": { | |
| 7843 | + "node": ">= 12.0.0" | |
| 7844 | + }, | |
| 7845 | + "funding": { | |
| 7846 | + "type": "opencollective", | |
| 7847 | + "url": "https://opencollective.com/parcel" | |
| 7848 | + } | |
| 7849 | + }, | |
| 7850 | + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { | |
| 7851 | + "version": "1.33.0", | |
| 7852 | + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", | |
| 7853 | + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", | |
| 7854 | + "cpu": [ | |
| 7855 | + "x64" | |
| 7856 | + ], | |
| 7857 | + "dev": true, | |
| 7858 | + "libc": [ | |
| 7859 | + "glibc" | |
| 7860 | + ], | |
| 7861 | + "license": "MPL-2.0", | |
| 7862 | + "optional": true, | |
| 7863 | + "os": [ | |
| 7864 | + "linux" | |
| 7865 | + ], | |
| 7866 | + "engines": { | |
| 7867 | + "node": ">= 12.0.0" | |
| 7868 | + }, | |
| 7869 | + "funding": { | |
| 7870 | + "type": "opencollective", | |
| 7871 | + "url": "https://opencollective.com/parcel" | |
| 7872 | + } | |
| 7873 | + }, | |
| 7874 | + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { | |
| 7875 | + "version": "1.33.0", | |
| 7876 | + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", | |
| 7877 | + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", | |
| 7878 | + "cpu": [ | |
| 7879 | + "x64" | |
| 7880 | + ], | |
| 7881 | + "dev": true, | |
| 7882 | + "libc": [ | |
| 7883 | + "musl" | |
| 7884 | + ], | |
| 7885 | + "license": "MPL-2.0", | |
| 7886 | + "optional": true, | |
| 7887 | + "os": [ | |
| 7888 | + "linux" | |
| 7889 | + ], | |
| 7890 | + "engines": { | |
| 7891 | + "node": ">= 12.0.0" | |
| 7892 | + }, | |
| 7893 | + "funding": { | |
| 7894 | + "type": "opencollective", | |
| 7895 | + "url": "https://opencollective.com/parcel" | |
| 7896 | + } | |
| 7897 | + }, | |
| 7898 | + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { | |
| 7899 | + "version": "1.33.0", | |
| 7900 | + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", | |
| 7901 | + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", | |
| 7902 | + "cpu": [ | |
| 7903 | + "arm64" | |
| 7904 | + ], | |
| 7905 | + "dev": true, | |
| 7906 | + "license": "MPL-2.0", | |
| 7907 | + "optional": true, | |
| 7908 | + "os": [ | |
| 7909 | + "win32" | |
| 7910 | + ], | |
| 7911 | + "engines": { | |
| 7912 | + "node": ">= 12.0.0" | |
| 7913 | + }, | |
| 7914 | + "funding": { | |
| 7915 | + "type": "opencollective", | |
| 7916 | + "url": "https://opencollective.com/parcel" | |
| 7917 | + } | |
| 7918 | + }, | |
| 7919 | + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { | |
| 7920 | + "version": "1.33.0", | |
| 7921 | + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", | |
| 7922 | + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", | |
| 7923 | + "cpu": [ | |
| 7924 | + "x64" | |
| 7925 | + ], | |
| 7926 | + "dev": true, | |
| 7927 | + "license": "MPL-2.0", | |
| 7928 | + "optional": true, | |
| 7929 | + "os": [ | |
| 7930 | + "win32" | |
| 7931 | + ], | |
| 7932 | + "engines": { | |
| 7933 | + "node": ">= 12.0.0" | |
| 7934 | + }, | |
| 7935 | + "funding": { | |
| 7936 | + "type": "opencollective", | |
| 7937 | + "url": "https://opencollective.com/parcel" | |
| 7938 | + } | |
| 7939 | + }, | |
| 7940 | + "node_modules/vite/node_modules/picomatch": { | |
| 7941 | + "version": "4.0.5", | |
| 7942 | + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", | |
| 7943 | + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", | |
| 7944 | + "dev": true, | |
| 7945 | + "license": "MIT", | |
| 7946 | + "engines": { | |
| 7947 | + "node": ">=12" | |
| 7948 | + }, | |
| 7949 | + "funding": { | |
| 7950 | + "url": "https://github.com/sponsors/jonschlinkert" | |
| 7951 | + } | |
| 7952 | + }, | |
| 7953 | + "node_modules/vitest": { | |
| 7954 | + "version": "4.1.10", | |
| 7955 | + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", | |
| 7956 | + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", | |
| 7957 | + "dev": true, | |
| 7958 | + "license": "MIT", | |
| 7959 | + "dependencies": { | |
| 7960 | + "@vitest/expect": "4.1.10", | |
| 7961 | + "@vitest/mocker": "4.1.10", | |
| 7962 | + "@vitest/pretty-format": "4.1.10", | |
| 7963 | + "@vitest/runner": "4.1.10", | |
| 7964 | + "@vitest/snapshot": "4.1.10", | |
| 7965 | + "@vitest/spy": "4.1.10", | |
| 7966 | + "@vitest/utils": "4.1.10", | |
| 7967 | + "es-module-lexer": "^2.0.0", | |
| 7968 | + "expect-type": "^1.3.0", | |
| 7969 | + "magic-string": "^0.30.21", | |
| 7970 | + "obug": "^2.1.1", | |
| 7971 | + "pathe": "^2.0.3", | |
| 7972 | + "picomatch": "^4.0.3", | |
| 7973 | + "std-env": "^4.0.0-rc.1", | |
| 7974 | + "tinybench": "^2.9.0", | |
| 7975 | + "tinyexec": "^1.0.2", | |
| 7976 | + "tinyglobby": "^0.2.15", | |
| 7977 | + "tinyrainbow": "^3.1.0", | |
| 7978 | + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", | |
| 7979 | + "why-is-node-running": "^2.3.0" | |
| 7980 | + }, | |
| 7981 | + "bin": { | |
| 7982 | + "vitest": "vitest.mjs" | |
| 7983 | + }, | |
| 7984 | + "engines": { | |
| 7985 | + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" | |
| 7986 | + }, | |
| 7987 | + "funding": { | |
| 7988 | + "url": "https://opencollective.com/vitest" | |
| 7989 | + }, | |
| 7990 | + "peerDependencies": { | |
| 7991 | + "@edge-runtime/vm": "*", | |
| 7992 | + "@opentelemetry/api": "^1.9.0", | |
| 7993 | + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", | |
| 7994 | + "@vitest/browser-playwright": "4.1.10", | |
| 7995 | + "@vitest/browser-preview": "4.1.10", | |
| 7996 | + "@vitest/browser-webdriverio": "4.1.10", | |
| 7997 | + "@vitest/coverage-istanbul": "4.1.10", | |
| 7998 | + "@vitest/coverage-v8": "4.1.10", | |
| 7999 | + "@vitest/ui": "4.1.10", | |
| 8000 | + "happy-dom": "*", | |
| 8001 | + "jsdom": "*", | |
| 8002 | + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" | |
| 8003 | + }, | |
| 8004 | + "peerDependenciesMeta": { | |
| 8005 | + "@edge-runtime/vm": { | |
| 8006 | + "optional": true | |
| 8007 | + }, | |
| 8008 | + "@opentelemetry/api": { | |
| 8009 | + "optional": true | |
| 8010 | + }, | |
| 8011 | + "@types/node": { | |
| 8012 | + "optional": true | |
| 8013 | + }, | |
| 8014 | + "@vitest/browser-playwright": { | |
| 8015 | + "optional": true | |
| 8016 | + }, | |
| 8017 | + "@vitest/browser-preview": { | |
| 8018 | + "optional": true | |
| 8019 | + }, | |
| 8020 | + "@vitest/browser-webdriverio": { | |
| 8021 | + "optional": true | |
| 8022 | + }, | |
| 8023 | + "@vitest/coverage-istanbul": { | |
| 8024 | + "optional": true | |
| 8025 | + }, | |
| 8026 | + "@vitest/coverage-v8": { | |
| 8027 | + "optional": true | |
| 8028 | + }, | |
| 8029 | + "@vitest/ui": { | |
| 8030 | + "optional": true | |
| 8031 | + }, | |
| 8032 | + "happy-dom": { | |
| 8033 | + "optional": true | |
| 8034 | + }, | |
| 8035 | + "jsdom": { | |
| 8036 | + "optional": true | |
| 8037 | + }, | |
| 8038 | + "vite": { | |
| 8039 | + "optional": false | |
| 8040 | + } | |
| 8041 | + } | |
| 8042 | + }, | |
| 8043 | + "node_modules/vitest/node_modules/picomatch": { | |
| 8044 | + "version": "4.0.5", | |
| 8045 | + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", | |
| 8046 | + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", | |
| 8047 | + "dev": true, | |
| 8048 | + "license": "MIT", | |
| 8049 | + "engines": { | |
| 8050 | + "node": ">=12" | |
| 8051 | + }, | |
| 8052 | + "funding": { | |
| 8053 | + "url": "https://github.com/sponsors/jonschlinkert" | |
| 8054 | + } | |
| 8055 | + }, | |
| 6736 | 8056 | "node_modules/which": { |
| 6737 | 8057 | "version": "2.0.2", |
| 6738 | 8058 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", |
@@ -6838,6 +8158,23 @@ | ||
| 6838 | 8158 | "url": "https://github.com/sponsors/ljharb" |
| 6839 | 8159 | } |
| 6840 | 8160 | }, |
| 8161 | + "node_modules/why-is-node-running": { | |
| 8162 | + "version": "2.3.0", | |
| 8163 | + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", | |
| 8164 | + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", | |
| 8165 | + "dev": true, | |
| 8166 | + "license": "MIT", | |
| 8167 | + "dependencies": { | |
| 8168 | + "siginfo": "^2.0.0", | |
| 8169 | + "stackback": "0.0.2" | |
| 8170 | + }, | |
| 8171 | + "bin": { | |
| 8172 | + "why-is-node-running": "cli.js" | |
| 8173 | + }, | |
| 8174 | + "engines": { | |
| 8175 | + "node": ">=8" | |
| 8176 | + } | |
| 8177 | + }, | |
| 6841 | 8178 | "node_modules/word-wrap": { |
| 6842 | 8179 | "version": "1.2.5", |
| 6843 | 8180 | "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", |
modified
package.json
+9 −2
@@ -6,21 +6,28 @@ | ||
| 6 | 6 | "dev": "next dev", |
| 7 | 7 | "build": "next build", |
| 8 | 8 | "start": "next start", |
| 9 | − "lint": "eslint" | |
| 9 | + "lint": "eslint", | |
| 10 | + "test": "vitest run" | |
| 10 | 11 | }, |
| 11 | 12 | "dependencies": { |
| 13 | + "better-sqlite3": "^13.0.3", | |
| 12 | 14 | "next": "16.3.0", |
| 15 | + "pdfkit": "^0.19.1", | |
| 13 | 16 | "react": "19.2.8", |
| 14 | 17 | "react-dom": "19.2.8" |
| 15 | 18 | }, |
| 16 | 19 | "devDependencies": { |
| 17 | 20 | "@tailwindcss/postcss": "^4", |
| 21 | + "@types/better-sqlite3": "^9.6.0", | |
| 18 | 22 | "@types/node": "^20", |
| 23 | + "@types/pdfkit": "^0.17.6", | |
| 19 | 24 | "@types/react": "^19", |
| 20 | 25 | "@types/react-dom": "^19", |
| 21 | 26 | "eslint": "^9", |
| 22 | 27 | "eslint-config-next": "16.3.0", |
| 28 | + "playwright": "^1.62.1", | |
| 23 | 29 | "tailwindcss": "^4", |
| 24 | − "typescript": "^5" | |
| 30 | + "typescript": "^5", | |
| 31 | + "vitest": "^4.1.10" | |
| 25 | 32 | } |
| 26 | 33 | } |
added
src/app/api/estimate/route.ts
+40 −0
@@ -0,0 +1,40 @@ | ||
| 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 | + floorArea?: number; | |
| 18 | + yearBuilt?: number; | |
| 19 | + landArea?: number; | |
| 20 | + }; | |
| 21 | + if (!body.municipality || !body.typeProp) { | |
| 22 | + return NextResponse.json( | |
| 23 | + { error: "municipality et typeProp requis" }, | |
| 24 | + { status: 400 } | |
| 25 | + ); | |
| 26 | + } | |
| 27 | + const r = estimateManual({ | |
| 28 | + municipality: body.municipality, | |
| 29 | + typeProp: body.typeProp, | |
| 30 | + floorArea: body.floorArea, | |
| 31 | + yearBuilt: body.yearBuilt, | |
| 32 | + landArea: body.landArea, | |
| 33 | + }); | |
| 34 | + if (!r) | |
| 35 | + return NextResponse.json( | |
| 36 | + { error: "municipalité introuvable ou marché insuffisant" }, | |
| 37 | + { status: 404 } | |
| 38 | + ); | |
| 39 | + return NextResponse.json(r); | |
| 40 | +} | |
added
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
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
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="vrai-prix-parc-${pf.items.length}-proprietes.pdf"`, | |
| 22 | + "Cache-Control": "private, max-age=300", | |
| 23 | + }, | |
| 24 | + }); | |
| 25 | +} | |
added
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(data.unit.typeProp)); | |
| 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="vrai-prix-rapport-detaille-${slug}.pdf"`, | |
| 25 | + "Cache-Control": "private, max-age=300", | |
| 26 | + }, | |
| 27 | + }); | |
| 28 | +} | |
added
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="vrai-prix-${slug}.pdf"`, | |
| 23 | + "Cache-Control": "private, max-age=300", | |
| 24 | + }, | |
| 25 | + }); | |
| 26 | +} | |
added
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="vrai-prix-rapport-statistique-provincial.pdf"', | |
| 14 | + "Cache-Control": "public, max-age=3600", | |
| 15 | + }, | |
| 16 | + }); | |
| 17 | +} | |
added
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
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 | + "Vrai-Prix (vrai-prix.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 Vrai-Prix 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 Vrai-Prix 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 Vrai-Prix. 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 Vrai-Prix, 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 | + "Vrai-Prix (vrai-prix.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 | + "Vrai-Prix estimates are generated automatically by statistical models applied to public data and are provided for information purposes only.", | |
| 68 | + "A Vrai-Prix 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 Vrai-Prix. 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 Vrai-Prix 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
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 Vrai-Prix (vrai-prix.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 à Vrai-Prix. 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 Vrai-Prix (vrai-prix.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, Vrai-Prix 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
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
src/app/estimation/manuelle/page.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 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 | + floorArea: num("floorArea"), | |
| 30 | + yearBuilt: num("yearBuilt"), | |
| 31 | + landArea: num("landArea"), | |
| 32 | + }); | |
| 33 | + if (!data) notFound(); | |
| 34 | + return <ResultView data={data} />; | |
| 35 | +} | |
modified
src/app/globals.css
+223 −15
@@ -1,26 +1,234 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Vrai-Prix — estimation immobilière transparente (Québec) | |
| 3 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + Système de design « éditorial sharp » — identité ROUGE distincte de Lou-Ka : | |
| 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 rouge vif #ff5148 + bordeaux #9e2a25 sur encre | |
| 8 | +----------------------------------------------------------------------------- */ | |
| 1 | 9 | @import "tailwindcss"; |
| 2 | 10 | |
| 3 | 11 | :root { |
| 4 | − --background: #ffffff; | |
| 5 | − --foreground: #171717; | |
| 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: #9e2a25; | |
| 21 | + --green-deep: #771f1b; | |
| 22 | + --lime: #ff5148; | |
| 23 | + --lime-soft: #ffe3e0; | |
| 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); | |
| 6 | 33 | } |
| 7 | 34 | |
| 8 | 35 | @theme inline { |
| 9 | − --color-background: var(--background); | |
| 10 | − --color-foreground: var(--foreground); | |
| 11 | − --font-sans: var(--font-geist-sans); | |
| 12 | − --font-mono: var(--font-geist-mono); | |
| 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; | |
| 13 | 52 | } |
| 14 | 53 | |
| 15 | −@media (prefers-color-scheme: dark) { | |
| 16 | − :root { | |
| 17 | − --background: #0a0a0a; | |
| 18 | − --foreground: #ededed; | |
| 19 | − } | |
| 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"); | |
| 20 | 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; } | |
| 21 | 74 | |
| 22 | −body { | |
| 23 | − background: var(--background); | |
| 24 | − color: var(--foreground); | |
| 25 | − font-family: Arial, Helvetica, sans-serif; | |
| 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, 81, 72, 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, 81, 72, 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; | |
| 26 | 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 | + | |
modified
src/app/layout.tsx
+77 −13
@@ -1,29 +1,93 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 1 | 2 | import type { Metadata } from "next"; |
| 2 | −import { Geist, Geist_Mono } from "next/font/google"; | |
| 3 | +import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; | |
| 3 | 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"; | |
| 4 | 10 | |
| 5 | −const geistSans = Geist({ | |
| 6 | − variable: "--font-geist-sans", | |
| 11 | +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); | |
| 12 | +const spaceGrotesk = Space_Grotesk({ | |
| 7 | 13 | subsets: ["latin"], |
| 14 | + variable: "--font-space-grotesk", | |
| 8 | 15 | }); |
| 9 | − | |
| 10 | −const geistMono = Geist_Mono({ | |
| 11 | − variable: "--font-geist-mono", | |
| 16 | +const jetbrains = JetBrains_Mono({ | |
| 12 | 17 | subsets: ["latin"], |
| 18 | + variable: "--font-jetbrains", | |
| 13 | 19 | }); |
| 14 | 20 | |
| 15 | 21 | export const metadata: Metadata = { |
| 16 | − title: "Create Next App", | |
| 17 | − description: "Generated by create next app", | |
| 22 | + title: "Vrai-Prix — la vraie valeur, sans boîte noire", | |
| 23 | + description: | |
| 24 | + "Estimation immobilière transparente pour le Québec : modèle hédonique + comparables réels, fourchette honnête et indice de confiance.", | |
| 18 | 25 | }; |
| 19 | 26 | |
| 27 | +const TICKER = [ | |
| 28 | + "3 747 008 unités d'évaluation", | |
| 29 | + "745 119 ventes réelles 2021-2026", | |
| 30 | + "22 M d'estimations calculées", | |
| 31 | + "Fourchette honnête P10-P90", | |
| 32 | + "Indice de confiance A-D", | |
| 33 | + "Zéro boîte noire — le calcul est montré", | |
| 34 | + "1 100 municipalités couvertes", | |
| 35 | + "Erreur médiane 11 %", | |
| 36 | +]; | |
| 37 | + | |
| 20 | 38 | export default function RootLayout({ children }: LayoutProps<"/">) { |
| 21 | 39 | return ( |
| 22 | − <html | |
| 23 | − lang="en" | |
| 24 | − className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} | |
| 25 | − > | |
| 26 | − <body className="min-h-full flex flex-col">{children}</body> | |
| 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 | + Vrai | |
| 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 | + Prix | |
| 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 | + Vrai<span className="text-lime">Prix</span> | |
| 75 | + </p> | |
| 76 | + <p className="max-w-2xl"> | |
| 77 | + Estimations bâties sur les rôles d'évaluation foncière du | |
| 78 | + Québec (MAMH, données ouvertes) et 745 119 transactions réelles. | |
| 79 | + Modèle hédonique + comparables ajustés, calcul montré en entier. | |
| 80 | + </p> | |
| 81 | + <FooterNav /> | |
| 82 | + <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.08em] text-[rgba(245,243,238,0.45)]"> | |
| 83 | + vrai-prix.com — Simon-Pierre Boucher · contact@spboucher.ai — | |
| 84 | + estimation statistique à titre indicatif | |
| 85 | + </p> | |
| 86 | + </div> | |
| 87 | + </footer> | |
| 88 | + <CookieConsent /> | |
| 89 | + </LangProvider> | |
| 90 | + </body> | |
| 27 | 91 | </html> |
| 28 | 92 | ); |
| 29 | 93 | } |
added
src/app/methodologie/page.tsx
+271 −0
@@ -0,0 +1,271 @@ | ||
| 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: "Données sources", | |
| 19 | + paras: [ | |
| 20 | + "Deux corpus publics forment la fondation de Vrai-Prix. Premièrement, les rôles d'évaluation foncière géoréférencés du Québec, publiés en données ouvertes par le ministère des Affaires municipales et de l'Habitation (MAMH) : six millésimes complets (2021 à 2026), de 3 626 120 à 3 747 008 unités d'évaluation par millésime, soit 22,15 millions d'observations unité-année. Chaque unité porte sa géométrie ponctuelle (NAD83, EPSG:4269), son matricule, son usage (code CUBF), ses caractéristiques de bâtiment et de terrain, et ses valeurs au rôle.", | |
| 21 | + "Deuxièmement, 745 119 transactions immobilières publiées entre janvier 2021 et juillet 2026, couvrant 1 100 municipalités, chacune géolocalisée et documentée (prix, date, type, année de construction, aire d'étages, valeurs au rôle).", | |
| 22 | + ], | |
| 23 | + kv: [ | |
| 24 | + ["Millésimes de rôle", "2021 · 2022 · 2023 · 2024 · 2025 · 2026"], | |
| 25 | + ["Unités d'évaluation (2026)", "3 747 008"], | |
| 26 | + ["Observations unité-année", "22 150 285"], | |
| 27 | + ["Transactions", "745 119 (2021-01 → 2026-07)"], | |
| 28 | + ["Municipalités couvertes", "1 100"], | |
| 29 | + ["Système de référence", "NAD83 (EPSG:4269), projeté EPSG:32198"], | |
| 30 | + ], | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + num: "02", | |
| 34 | + title: "Fusion spatiale transaction ↔ unité d'évaluation", | |
| 35 | + paras: [ | |
| 36 | + "Chaque transaction est appariée à son unité d'évaluation par coordonnées XY, dans le rôle de l'année de la transaction. L'appariement se fait en deux passes, en coordonnées projetées Québec Lambert (EPSG:32198).", | |
| 37 | + "Passe A — spatiale : parmi les 25 plus proches voisins dans un rayon de 200 m, chaque candidat reçoit un score : +100 si la valeur au rôle est identique à celle portée par la transaction, +60 si la valeur du rôle antérieur concorde, +30 si l'année de construction concorde, +30 si l'aire d'étages concorde à ±0,6 m², moins 0,5 point par mètre de distance. Le meilleur score gagne.", | |
| 38 | + "Passe B — jointure par valeur : les tours à condos placent des centaines d'unités sur le même point ; la géométrie seule ne suffit pas. Pour toute transaction sans concordance exacte de valeur en passe A, on cherche l'unité de valeur au rôle strictement identique dans un rayon de 500 m. En dernier recours, les millésimes adjacents (année ±1) sont essayés — les rôles triennaux étant déposés à des années différentes selon les municipalités.", | |
| 39 | + "Résultat : 99,88 % des transactions appariées, 82,2 % avec concordance exacte de la valeur au rôle, distance médiane de 0,6 m (p90 = 12 m). Ce lien est vérifiable transaction par transaction.", | |
| 40 | + ], | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + num: "03", | |
| 44 | + title: "Modèle hédonique de masse", | |
| 45 | + paras: [ | |
| 46 | + "Le cœur du système est un modèle de gradient boosting (LightGBM) entraîné à prédire le logarithme du prix de vente. L'échantillon d'entraînement compte 689 231 transactions, après exclusion des ventes non représentatives du marché : appariement incertain, prix hors de 50 000 $ à 20 M$, ratio prix/évaluation hors de l'intervalle [0,3 ; 4].", | |
| 47 | + "Dix-huit variables décrivent chaque propriété au moment de la vente : aire d'étages, superficie et frontage du terrain, nombre d'étages, de logements, de locaux non résidentiels et de chambres locatives, âge du bâtiment, usage CUBF (et sa famille), lien physique, genre de construction, municipalité et région (variables catégorielles), latitude, longitude, temps continu (mois écoulés) et mois de l'année, plus les valeurs au rôle (terrain, bâtiment, total, croissance vs rôle antérieur, part du terrain).", | |
| 48 | + "Hyperparamètres : 255 feuilles, taux d'apprentissage 0,05, sous-échantillonnage 85 %, arrêt précoce sur ensemble de validation (~825 itérations retenues). La rétro-transformation du logarithme applique le facteur de correction de Duan (smearing, 1,04). Deux modèles de régression quantile (α = 0,10 et 0,90), entraînés sur les mêmes variables, produisent la fourchette P10-P90 propre à chaque propriété. Un second modèle « hédonique pur », sans les valeurs au rôle, sert de contrôle.", | |
| 49 | + "Le modèle final est appliqué aux 3,75 millions d'unités de chaque millésime (temps fixé à la mi-année), produisant 22 millions d'estimations historisées 2021-2026.", | |
| 50 | + ], | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + num: "04", | |
| 54 | + title: "Validation — norme IAAO", | |
| 55 | + paras: [ | |
| 56 | + "La validation suit la norme de l'International Association of Assessing Officers (IAAO) sur les études de ratios : ratio médian (estimation/prix), coefficient de dispersion (COD), différentiel lié au prix (PRD) et coefficient de biais lié au prix (PRB). Deux protocoles : un ensemble aléatoire réservé de 15 % (102 943 ventes jamais vues) et un test temporel strict (entraînement sans 2026, test sur les 68 364 ventes de 2026).", | |
| 57 | + "Le COD provincial (22,7) dépasse la cible urbaine IAAO (≤ 15) — attendu pour un modèle unique couvrant aussi les marchés ruraux minces ; le segment condo (11,9) y satisfait pleinement. Le ratio médian de 0,995 indique l'absence de biais systématique.", | |
| 58 | + ], | |
| 59 | + table: { | |
| 60 | + head: ["Métrique", "Holdout aléatoire", "Test temporel 2026"], | |
| 61 | + rows: [ | |
| 62 | + ["Erreur médiane (MdAPE)", "11,0 %", "14,0 %"], | |
| 63 | + ["± 20 % du prix réel", "72,1 %", "66,7 %"], | |
| 64 | + ["R² (log)", "0,789", "0,743"], | |
| 65 | + ["Ratio médian (cible 0,90-1,10)", "0,995", "0,961"], | |
| 66 | + ["COD (cible ≤ 15-20)", "22,7 (condos 11,9)", "23,7"], | |
| 67 | + ["PRD (cible 0,98-1,03)", "1,092", "1,075"], | |
| 68 | + ["PRB (cible ±0,05)", "−0,104", "−0,082"], | |
| 69 | + ], | |
| 70 | + }, | |
| 71 | + }, | |
| 72 | + { | |
| 73 | + num: "05", | |
| 74 | + title: "Moteur d'estimation en ligne", | |
| 75 | + paras: [ | |
| 76 | + "Au moment de la consultation, l'estimation affichée combine deux sources indépendantes. (1) L'estimation du modèle hédonique, pré-calculée pour la propriété. (2) Une estimation par comparables : les ventes candidates sont cherchées dans un rayon adaptatif (2,2 km, élargi jusqu'à ~28 km si le marché est mince, minimum 30 candidates sur 30-36 mois), filtrées par famille de type et par aire d'étages (±20 %, relâché à ±40 % puis abandonné sous 6 candidates).", | |
| 77 | + "Chaque comparable est ajusté, en dollars affichés : ajustement de marché (indice mensuel, section 06), ajustement de superficie (50 % du $/m² du comparable par m² d'écart, plafonné à ±25 % du prix), ajustement d'âge (0,5 % du prix par année d'écart, plafonné à ±10 %). Le poids de chaque comparable est le produit de trois gaussiennes : distance (σ = 1 500 m), récence (σ = 24 mois) et similarité de superficie (σ = 25 %). L'estimation comparables est la médiane pondérée des prix ajustés (12 meilleures retenues).", | |
| 78 | + "Combinaison : 65 % modèle + 35 % comparables lorsque les deux existent (≥ 3 comparables) ; sinon la source disponible seule. La fourchette affichée est l'intervalle P10-P90 du modèle, recentré sur l'estimation finale.", | |
| 79 | + "L'indice de confiance (0-100, niveaux A ≥ 75, B ≥ 60, C ≥ 45, D < 45) agrège : le nombre de comparables (+2,5/comparable, max 25), leur dispersion (jusqu'à +20 quand la déviation médiane est faible), la présence du modèle (+15) et une pénalité de largeur de fourchette au-delà de 30 %. Aucune estimation n'est affichée sans son niveau de confiance.", | |
| 80 | + ], | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + num: "06", | |
| 84 | + title: "Indice de marché mensuel", | |
| 85 | + paras: [ | |
| 86 | + "L'ajustement temporel des comparables repose sur un indice mensuel par type de propriété : médiane du prix au mètre carré des ventes du mois (minimum 5 ventes), lissée par médiane mobile centrée de 3 mois, normalisée au dernier mois observé. Une vente de janvier 2024 comparée en août 2026 est ainsi ramenée aux conditions de marché actuelles avant tout autre ajustement.", | |
| 87 | + ], | |
| 88 | + }, | |
| 89 | + { | |
| 90 | + num: "07", | |
| 91 | + title: "Limites connues", | |
| 92 | + paras: [ | |
| 93 | + "L'honnêteté méthodologique exige de nommer ce que le système ne voit pas. Les rénovations intérieures, garages, piscines et l'état d'entretien n'apparaissent pas dans les données ouvertes du rôle. Dans les marchés ruraux minces, la dispersion dépasse les cibles IAAO urbaines — l'indice de confiance le reflète. Les unités d'une même tour à condos partagent un point géographique : sans numéro d'appartement, la recherche peut retourner une unité voisine. Les valeurs au rôle utilisées comme variables sont établies 18 à 24 mois avant leur entrée en vigueur. Enfin, une estimation statistique, aussi rigoureuse soit-elle, ne remplace pas l'évaluation d'un évaluateur agréé (OEAQ) ni l'analyse d'un courtier pour une mise en vente.", | |
| 94 | + ], | |
| 95 | + }, | |
| 96 | +]; | |
| 97 | + | |
| 98 | +const EN: Section[] = [ | |
| 99 | + { | |
| 100 | + num: "01", | |
| 101 | + title: "Source data", | |
| 102 | + paras: [ | |
| 103 | + "Two public corpora form the foundation of Vrai-Prix. First, Québec's georeferenced property assessment rolls, published as open data by the MAMH: six complete vintages (2021-2026), 3.63M to 3.75M assessment units each — 22.15 million unit-year observations, each with point geometry (NAD83), roll number, CUBF use code, building and lot characteristics, and assessed values.", | |
| 104 | + "Second, 745,119 real-estate transactions published between January 2021 and July 2026 across 1,100 municipalities, each geolocated and documented (price, date, type, year built, floor area, assessed values).", | |
| 105 | + ], | |
| 106 | + kv: [ | |
| 107 | + ["Roll vintages", "2021 · 2022 · 2023 · 2024 · 2025 · 2026"], | |
| 108 | + ["Assessment units (2026)", "3,747,008"], | |
| 109 | + ["Unit-year observations", "22,150,285"], | |
| 110 | + ["Transactions", "745,119 (2021-01 → 2026-07)"], | |
| 111 | + ["Municipalities", "1,100"], | |
| 112 | + ["Reference system", "NAD83 (EPSG:4269), projected EPSG:32198"], | |
| 113 | + ], | |
| 114 | + }, | |
| 115 | + { | |
| 116 | + num: "02", | |
| 117 | + title: "Spatial merge: transaction ↔ assessment unit", | |
| 118 | + paras: [ | |
| 119 | + "Each transaction is matched to its assessment unit by XY coordinates in the roll of its own year, in two passes using Québec Lambert (EPSG:32198) coordinates.", | |
| 120 | + "Pass A — spatial: among the 25 nearest neighbours within 200 m, each candidate is scored: +100 for an identical assessed value, +60 for a matching previous-roll value, +30 for a matching year built, +30 for a floor area within ±0.6 m², minus 0.5 point per metre of distance.", | |
| 121 | + "Pass B — value join: condo towers stack hundreds of units on one point, so geometry alone is insufficient. For transactions without an exact value match in pass A, we search for the unit with a strictly identical assessed value within 500 m. As a last resort, adjacent vintages (±1 year) are tried, since triennial rolls are deposited in different years across municipalities.", | |
| 122 | + "Result: 99.88% of transactions matched, 82.2% with an exact assessed-value match, median distance 0.6 m (p90 = 12 m). The link is verifiable transaction by transaction.", | |
| 123 | + ], | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + num: "03", | |
| 127 | + title: "Mass-appraisal hedonic model", | |
| 128 | + paras: [ | |
| 129 | + "The core is a gradient-boosting model (LightGBM) trained to predict the log of sale price on 689,231 transactions, after excluding non-market sales (uncertain match, price outside $50k-$20M, price/assessment ratio outside [0.3, 4]).", | |
| 130 | + "Eighteen variables describe each property at sale time: floor area, lot area and frontage, storeys, dwellings, non-residential units, rental rooms, building age, CUBF use (and family), physical link, construction style, municipality and region (categorical), latitude, longitude, continuous time and month of year, plus assessed values (land, building, total, growth vs previous roll, land share).", | |
| 131 | + "Hyperparameters: 255 leaves, learning rate 0.05, 85% subsampling, early stopping (~825 iterations). The log back-transform applies Duan's smearing factor (1.04). Two quantile models (α = 0.10, 0.90) produce each property's P10-P90 range; a value-free 'pure hedonic' variant serves as control.", | |
| 132 | + "The final model is applied to all 3.75M units of every vintage (time set to mid-year), producing 22 million historical estimates 2021-2026.", | |
| 133 | + ], | |
| 134 | + }, | |
| 135 | + { | |
| 136 | + num: "04", | |
| 137 | + title: "Validation — IAAO standard", | |
| 138 | + paras: [ | |
| 139 | + "Validation follows the IAAO Standard on Ratio Studies: median ratio, coefficient of dispersion (COD), price-related differential (PRD) and price-related bias (PRB). Two protocols: a 15% random holdout (102,943 unseen sales) and a strict temporal test (trained without 2026, tested on 68,364 sales of 2026).", | |
| 140 | + "The province-wide COD (22.7) exceeds the urban IAAO target (≤ 15) — expected for a single model that also covers thin rural markets; the condo segment (11.9) fully meets it. The 0.995 median ratio shows no systematic bias.", | |
| 141 | + ], | |
| 142 | + table: { | |
| 143 | + head: ["Metric", "Random holdout", "Temporal test 2026"], | |
| 144 | + rows: [ | |
| 145 | + ["Median error (MdAPE)", "11.0%", "14.0%"], | |
| 146 | + ["Within ±20% of price", "72.1%", "66.7%"], | |
| 147 | + ["R² (log)", "0.789", "0.743"], | |
| 148 | + ["Median ratio (target 0.90-1.10)", "0.995", "0.961"], | |
| 149 | + ["COD (target ≤ 15-20)", "22.7 (condos 11.9)", "23.7"], | |
| 150 | + ["PRD (target 0.98-1.03)", "1.092", "1.075"], | |
| 151 | + ["PRB (target ±0.05)", "−0.104", "−0.082"], | |
| 152 | + ], | |
| 153 | + }, | |
| 154 | + }, | |
| 155 | + { | |
| 156 | + num: "05", | |
| 157 | + title: "Online estimation engine", | |
| 158 | + paras: [ | |
| 159 | + "At consultation time, the displayed estimate blends two independent sources. (1) The precomputed hedonic estimate. (2) A comparables estimate: candidate sales are fetched in an adaptive radius (2.2 km, widened up to ~28 km in thin markets, minimum 30 candidates over 30-36 months), filtered by type family and floor area (±20%, relaxed to ±40%, dropped below 6 candidates).", | |
| 160 | + "Each comparable is adjusted, in displayed dollars: market adjustment (monthly index, section 06), area adjustment (50% of the comp's $/m² per m² of difference, capped at ±25%), age adjustment (0.5%/year, capped at ±10%). Weights are the product of three Gaussians: distance (σ = 1,500 m), recency (σ = 24 months) and area similarity (σ = 25%). The comparables estimate is the weighted median of adjusted prices (best 12 kept).", | |
| 161 | + "Blend: 65% model + 35% comparables when both exist (≥ 3 comps); otherwise the available source alone. The displayed range is the model's P10-P90 interval re-centred on the final estimate.", | |
| 162 | + "The confidence index (0-100; A ≥ 75, B ≥ 60, C ≥ 45, D < 45) aggregates comp count (+2.5 each, max 25), comp dispersion (up to +20), model availability (+15) and a penalty for range width beyond 30%. No estimate is ever shown without its confidence level.", | |
| 163 | + ], | |
| 164 | + }, | |
| 165 | + { | |
| 166 | + num: "06", | |
| 167 | + title: "Monthly market index", | |
| 168 | + paras: [ | |
| 169 | + "Time adjustment relies on a monthly index per property type: the median price per square metre of that month's sales (minimum 5), smoothed with a centred 3-month rolling median, normalized to the latest observed month.", | |
| 170 | + ], | |
| 171 | + }, | |
| 172 | + { | |
| 173 | + num: "07", | |
| 174 | + title: "Known limitations", | |
| 175 | + paras: [ | |
| 176 | + "Methodological honesty requires naming what the system cannot see. Interior renovations, garages, pools and upkeep do not appear in open roll data. In thin rural markets, dispersion exceeds urban IAAO targets — the confidence index reflects it. Units in the same condo tower share one geographic point: without an apartment number, search may return a neighbouring unit. Assessed values used as features are set 18-24 months before taking effect. Finally, a statistical estimate, however rigorous, does not replace a chartered appraiser's evaluation (OEAQ) or a broker's analysis for a listing.", | |
| 177 | + ], | |
| 178 | + }, | |
| 179 | +]; | |
| 180 | + | |
| 181 | +export default function MethodologiePage() { | |
| 182 | + const { lang } = useLang(); | |
| 183 | + const fr = lang === "fr"; | |
| 184 | + const sections = fr ? FR : EN; | |
| 185 | + return ( | |
| 186 | + <div className="py-10"> | |
| 187 | + <nav className="vp-mono mb-8 flex items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3"> | |
| 188 | + <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green"> | |
| 189 | + Vrai-Prix | |
| 190 | + </Link> | |
| 191 | + <span>/</span> | |
| 192 | + <span>{fr ? "Méthodologie" : "Methodology"}</span> | |
| 193 | + </nav> | |
| 194 | + <span className="kicker">{fr ? "Le calcul, en entier" : "The math, in full"}</span> | |
| 195 | + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(30px,5vw,52px)] font-bold uppercase leading-[1] tracking-[-0.03em]"> | |
| 196 | + {fr ? ( | |
| 197 | + <> | |
| 198 | + Méthodologie <span className="hl">ultra-détaillée</span> | |
| 199 | + </> | |
| 200 | + ) : ( | |
| 201 | + <> | |
| 202 | + The <span className="hl">full</span> methodology | |
| 203 | + </> | |
| 204 | + )} | |
| 205 | + </h1> | |
| 206 | + <p className="mt-4 max-w-2xl text-[16px] text-ink-2"> | |
| 207 | + {fr | |
| 208 | + ? "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." | |
| 209 | + : "Our differentiator is total transparency. This page documents every step — data, merge, model, validation, engine — at technical-report depth."} | |
| 210 | + </p> | |
| 211 | + | |
| 212 | + <div className="mt-10 space-y-8"> | |
| 213 | + {sections.map((sct) => ( | |
| 214 | + <section key={sct.num} className="vp-card p-6 sm:p-8"> | |
| 215 | + <div className="flex items-baseline gap-4"> | |
| 216 | + <span className="vp-mono text-[13px] font-bold text-green">{sct.num}</span> | |
| 217 | + <h2 className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]"> | |
| 218 | + {sct.title} | |
| 219 | + </h2> | |
| 220 | + </div> | |
| 221 | + <div className="mt-4 space-y-3.5"> | |
| 222 | + {sct.paras.map((p, i) => ( | |
| 223 | + <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2"> | |
| 224 | + {p} | |
| 225 | + </p> | |
| 226 | + ))} | |
| 227 | + </div> | |
| 228 | + {sct.kv && ( | |
| 229 | + <div className="mt-5 grid grid-cols-2 gap-2.5 sm:grid-cols-3"> | |
| 230 | + {sct.kv.map(([k, v]) => ( | |
| 231 | + <div key={k} className="kv-cell"> | |
| 232 | + <p className="k">{k}</p> | |
| 233 | + <p className="v">{v}</p> | |
| 234 | + </div> | |
| 235 | + ))} | |
| 236 | + </div> | |
| 237 | + )} | |
| 238 | + {sct.table && ( | |
| 239 | + <div className="src-wrap mt-5"> | |
| 240 | + <table className="src-table"> | |
| 241 | + <thead> | |
| 242 | + <tr> | |
| 243 | + {sct.table.head.map((h) => ( | |
| 244 | + <th key={h}>{h}</th> | |
| 245 | + ))} | |
| 246 | + </tr> | |
| 247 | + </thead> | |
| 248 | + <tbody> | |
| 249 | + {sct.table.rows.map(([a, b, c]) => ( | |
| 250 | + <tr key={a}> | |
| 251 | + <td className="font-semibold">{a}</td> | |
| 252 | + <td className="vp-mono text-[12.5px]">{b}</td> | |
| 253 | + <td className="vp-mono text-[12.5px]">{c}</td> | |
| 254 | + </tr> | |
| 255 | + ))} | |
| 256 | + </tbody> | |
| 257 | + </table> | |
| 258 | + </div> | |
| 259 | + )} | |
| 260 | + </section> | |
| 261 | + ))} | |
| 262 | + </div> | |
| 263 | + | |
| 264 | + <p className="vp-mono mt-8 text-[11px] uppercase tracking-[0.06em] text-ink-3"> | |
| 265 | + {fr | |
| 266 | + ? "Sources : rôles d'évaluation foncière du Québec (MAMH, données ouvertes) · IAAO Standard on Ratio Studies · IAAO Standard on AVMs" | |
| 267 | + : "Sources: Québec property assessment rolls (MAMH, open data) · IAAO Standard on Ratio Studies · IAAO Standard on AVMs"} | |
| 268 | + </p> | |
| 269 | + </div> | |
| 270 | + ); | |
| 271 | +} | |
modified
src/app/page.tsx
+90 −60
@@ -1,69 +1,99 @@ | ||
| 1 | −import Image from "next/image"; | |
| 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"; | |
| 2 | 6 | |
| 3 | 7 | export default function Home() { |
| 8 | + const { lang, t } = useLang(); | |
| 4 | 9 | return ( |
| 5 | − <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> | |
| 6 | − <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> | |
| 7 | − <Image | |
| 8 | − className="dark:invert h-5 w-[100px]" | |
| 9 | − src="/next.svg" | |
| 10 | − alt="Next.js logo" | |
| 11 | − width={100} | |
| 12 | − height={20} | |
| 13 | − priority | |
| 14 | − /> | |
| 15 | − <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> | |
| 16 | − <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> | |
| 17 | − To get started, edit the{" "} | |
| 18 | − <code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]"> | |
| 19 | − page.tsx | |
| 20 | − </code>{" "} | |
| 21 | − file. | |
| 22 | − </h1> | |
| 23 | − <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> | |
| 24 | − Looking for a starting point or more instructions? Head over to{" "} | |
| 25 | − <a | |
| 26 | − href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 27 | − className="font-medium text-zinc-950 dark:text-zinc-50" | |
| 28 | − > | |
| 29 | − Templates | |
| 30 | − </a>{" "} | |
| 31 | − or the{" "} | |
| 32 | − <a | |
| 33 | − href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 34 | − className="font-medium text-zinc-950 dark:text-zinc-50" | |
| 35 | − > | |
| 36 | − Learning | |
| 37 | − </a>{" "} | |
| 38 | − center. | |
| 10 | + <div className="pb-6 pt-10 sm:pt-14"> | |
| 11 | + {/* ---- Héros ---- */} | |
| 12 | + <section> | |
| 13 | + <span className="kicker"> | |
| 14 | + {lang === "fr" ? "Québec · 2021-2026" : "Québec · 2021-2026"} | |
| 15 | + </span> | |
| 16 | + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(34px,6.4vw,72px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]"> | |
| 17 | + {lang === "fr" ? ( | |
| 18 | + <> | |
| 19 | + La <span className="outline-txt">vraie</span> valeur. | |
| 20 | + <br /> | |
| 21 | + <span className="hl">Sans boîte noire.</span> | |
| 22 | + </> | |
| 23 | + ) : ( | |
| 24 | + <> | |
| 25 | + The <span className="outline-txt">true</span> value. | |
| 26 | + <br /> | |
| 27 | + <span className="hl">No black box.</span> | |
| 28 | + </> | |
| 29 | + )} | |
| 30 | + </h1> | |
| 31 | + <p className="mt-5 max-w-xl text-[16.5px] text-ink-2">{t("sub")}</p> | |
| 32 | + | |
| 33 | + <div className="mt-7 flex gap-2.5 overflow-x-auto pb-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 34 | + <span className="stat-chip"> | |
| 35 | + <span className="pulse" /> | |
| 36 | + <b>3 747 008</b> {lang === "fr" ? "propriétés" : "properties"} | |
| 37 | + </span> | |
| 38 | + <span className="stat-chip"> | |
| 39 | + <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"} | |
| 40 | + </span> | |
| 41 | + <span className="stat-chip"> | |
| 42 | + <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"} | |
| 43 | + </span> | |
| 44 | + <span className="stat-chip"> | |
| 45 | + MdAPE <b>11 %</b> | |
| 46 | + </span> | |
| 47 | + </div> | |
| 48 | + </section> | |
| 49 | + | |
| 50 | + {/* ---- Recherche ---- */} | |
| 51 | + <section className="mt-9"> | |
| 52 | + <div className="vp-card p-4 sm:p-5"> | |
| 53 | + <p className="klabel mb-2 pl-0.5"> | |
| 54 | + {lang === "fr" ? "Adresse de la propriété" : "Property address"} | |
| 39 | 55 | </p> |
| 56 | + <SearchBox /> | |
| 40 | 57 | </div> |
| 41 | − <div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> | |
| 42 | − <a | |
| 43 | − className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" | |
| 44 | − href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 45 | − target="_blank" | |
| 46 | − rel="noopener noreferrer" | |
| 47 | − > | |
| 48 | − <Image | |
| 49 | − className="dark:invert h-[14px] w-4" | |
| 50 | − src="/vercel.svg" | |
| 51 | − alt="Vercel logomark" | |
| 52 | − width={16} | |
| 53 | − height={14} | |
| 54 | − /> | |
| 55 | − Deploy Now | |
| 56 | − </a> | |
| 57 | − <a | |
| 58 | − className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" | |
| 59 | − href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 60 | − target="_blank" | |
| 61 | − rel="noopener noreferrer" | |
| 62 | − > | |
| 63 | − Documentation | |
| 64 | − </a> | |
| 58 | + <p className="vp-mono mt-2.5 pl-1 text-[11px] uppercase tracking-[0.06em] text-ink-3"> | |
| 59 | + {t("dataNote")} | |
| 60 | + </p> | |
| 61 | + </section> | |
| 62 | + | |
| 63 | + {/* ---- Formulaire manuel ---- */} | |
| 64 | + <section className="mt-14"> | |
| 65 | + <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span> | |
| 66 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 67 | + {t("manualTitle")} | |
| 68 | + </h2> | |
| 69 | + <div className="vp-card mt-4 p-4 sm:p-6"> | |
| 70 | + <EstimateForm /> | |
| 65 | 71 | </div> |
| 66 | − </main> | |
| 72 | + </section> | |
| 73 | + | |
| 74 | + {/* ---- Manifeste transparence ---- */} | |
| 75 | + <section className="mt-14 grid gap-4 sm:grid-cols-3"> | |
| 76 | + {(lang === "fr" | |
| 77 | + ? [ | |
| 78 | + ["01", "Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."], | |
| 79 | + ["02", "Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."], | |
| 80 | + ["03", "Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."], | |
| 81 | + ] | |
| 82 | + : [ | |
| 83 | + ["01", "The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."], | |
| 84 | + ["02", "Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."], | |
| 85 | + ["03", "Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."], | |
| 86 | + ] | |
| 87 | + ).map(([num, title, body]) => ( | |
| 88 | + <div key={num} className="vp-card vp-card-hover p-5"> | |
| 89 | + <p className="vp-mono text-[11px] font-bold text-green">{num}</p> | |
| 90 | + <h3 className="vp-display mt-1.5 text-[17px] font-bold uppercase tracking-[-0.01em]"> | |
| 91 | + {title} | |
| 92 | + </h3> | |
| 93 | + <p className="mt-2 text-[13.5px] text-ink-2">{body}</p> | |
| 94 | + </div> | |
| 95 | + ))} | |
| 96 | + </section> | |
| 67 | 97 | </div> |
| 68 | 98 | ); |
| 69 | 99 | } |
added
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 immobilier</span>, | |
| 97 | + <br /> | |
| 98 | + <span className="outline-txt">au complet.</span> | |
| 99 | + </> | |
| 100 | + ) : ( | |
| 101 | + <> | |
| 102 | + Your <span className="hl">property 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 chacune de vos adresses. Vrai-Prix évalue chaque propriété, agrège votre parc — valeur totale, fourchette, croissance 2021→2026 — et produit un rapport PDF consolidé." | |
| 111 | + : "Add each of your addresses. Vrai-Prix values every property, 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 une propriété (${list.length}/40)` : `Add a property (${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,81,72,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 propriété du parc" : "Every property 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
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: "Vrai-Prix — 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
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 | + "Vrai-Prix 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 | + "Vrai-Prix 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
src/components/EstimateForm.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 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 | +const TYPES = [ | |
| 8 | + "unifamilial", | |
| 9 | + "plex", | |
| 10 | + "condo_ou_multi", | |
| 11 | + "chalet", | |
| 12 | + "maison_mobile", | |
| 13 | + "terrain", | |
| 14 | +] as const; | |
| 15 | + | |
| 16 | +export default function EstimateForm() { | |
| 17 | + const { t } = useLang(); | |
| 18 | + const router = useRouter(); | |
| 19 | + const [municipality, setMunicipality] = useState(""); | |
| 20 | + const [typeProp, setTypeProp] = useState<string>("unifamilial"); | |
| 21 | + const [floorArea, setFloorArea] = useState(""); | |
| 22 | + const [yearBuilt, setYearBuilt] = useState(""); | |
| 23 | + const [landArea, setLandArea] = useState(""); | |
| 24 | + | |
| 25 | + const submit = (e: React.FormEvent) => { | |
| 26 | + e.preventDefault(); | |
| 27 | + const p = new URLSearchParams({ municipality, typeProp }); | |
| 28 | + if (floorArea) p.set("floorArea", floorArea); | |
| 29 | + if (yearBuilt) p.set("yearBuilt", yearBuilt); | |
| 30 | + if (landArea) p.set("landArea", landArea); | |
| 31 | + router.push(`/estimation/manuelle?${p.toString()}`); | |
| 32 | + }; | |
| 33 | + | |
| 34 | + return ( | |
| 35 | + <form | |
| 36 | + onSubmit={submit} | |
| 37 | + className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4" | |
| 38 | + > | |
| 39 | + <label className="col-span-2 flex flex-col gap-1.5 sm:col-span-1"> | |
| 40 | + <span className="klabel pl-0.5">{t("municipality")} *</span> | |
| 41 | + <input | |
| 42 | + required | |
| 43 | + value={municipality} | |
| 44 | + onChange={(e) => setMunicipality(e.target.value)} | |
| 45 | + className="vp-input" | |
| 46 | + placeholder="Montréal, Québec, Lévis…" | |
| 47 | + /> | |
| 48 | + </label> | |
| 49 | + <label className="flex flex-col gap-1.5"> | |
| 50 | + <span className="klabel pl-0.5">{t("propertyType")} *</span> | |
| 51 | + <select | |
| 52 | + value={typeProp} | |
| 53 | + onChange={(e) => setTypeProp(e.target.value)} | |
| 54 | + className="vp-input" | |
| 55 | + > | |
| 56 | + {TYPES.map((ty) => ( | |
| 57 | + <option key={ty} value={ty}> | |
| 58 | + {t(ty)} | |
| 59 | + </option> | |
| 60 | + ))} | |
| 61 | + </select> | |
| 62 | + </label> | |
| 63 | + <label className="flex flex-col gap-1.5"> | |
| 64 | + <span className="klabel pl-0.5">{t("floorArea")}</span> | |
| 65 | + <input | |
| 66 | + type="number" | |
| 67 | + min={10} | |
| 68 | + max={5000} | |
| 69 | + value={floorArea} | |
| 70 | + onChange={(e) => setFloorArea(e.target.value)} | |
| 71 | + className="vp-input" | |
| 72 | + placeholder="120" | |
| 73 | + /> | |
| 74 | + </label> | |
| 75 | + <label className="flex flex-col gap-1.5"> | |
| 76 | + <span className="klabel pl-0.5">{t("yearBuilt")}</span> | |
| 77 | + <input | |
| 78 | + type="number" | |
| 79 | + min={1700} | |
| 80 | + max={2026} | |
| 81 | + value={yearBuilt} | |
| 82 | + onChange={(e) => setYearBuilt(e.target.value)} | |
| 83 | + className="vp-input" | |
| 84 | + placeholder="1985" | |
| 85 | + /> | |
| 86 | + </label> | |
| 87 | + <label className="flex flex-col gap-1.5"> | |
| 88 | + <span className="klabel pl-0.5">{t("landArea")}</span> | |
| 89 | + <input | |
| 90 | + type="number" | |
| 91 | + min={0} | |
| 92 | + value={landArea} | |
| 93 | + onChange={(e) => setLandArea(e.target.value)} | |
| 94 | + className="vp-input" | |
| 95 | + placeholder="500" | |
| 96 | + /> | |
| 97 | + </label> | |
| 98 | + <div className="flex items-end"> | |
| 99 | + <button type="submit" className="btn btn-primary w-full"> | |
| 100 | + {t("estimateBtn")} → | |
| 101 | + </button> | |
| 102 | + </div> | |
| 103 | + </form> | |
| 104 | + ); | |
| 105 | +} | |
added
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
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 | + Vrai | |
| 96 | + <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime"> | |
| 97 | + Prix | |
| 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
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
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,81,72,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,81,72,0.25)] hover:bg-paper" | |
| 51 | + > | |
| 52 | + {t("leadBtn")} → | |
| 53 | + </button> | |
| 54 | + </form> | |
| 55 | + ); | |
| 56 | +} | |
added
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 | + Vrai-Prix | |
| 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 · vrai-prix.com | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | +} | |
added
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
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 Vrai-Prix : 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
src/components/ResultView.tsx
+417 −0
@@ -0,0 +1,417 @@ | ||
| 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 type { EstimateResult } from "@/lib/engine"; | |
| 18 | +import type { UnitEstimate } from "@/lib/estimator"; | |
| 19 | +import type { TKey } from "@/lib/i18n"; | |
| 20 | + | |
| 21 | +const fmt = (v: number | null | undefined, lang: string) => | |
| 22 | + v == null | |
| 23 | + ? "—" | |
| 24 | + : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { | |
| 25 | + style: "currency", | |
| 26 | + currency: "CAD", | |
| 27 | + maximumFractionDigits: 0, | |
| 28 | + }).format(v); | |
| 29 | + | |
| 30 | +const signed = (v: number, lang: string) => | |
| 31 | + `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`; | |
| 32 | + | |
| 33 | +function Chip({ k, v }: { k: string; v: string | number }) { | |
| 34 | + return ( | |
| 35 | + <div className="kv-cell"> | |
| 36 | + <p className="k">{k}</p> | |
| 37 | + <p className="v break-words">{v}</p> | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export default function ResultView({ data }: { data: UnitEstimate }) { | |
| 43 | + const { lang, t } = useLang(); | |
| 44 | + const [activeComp, setActiveComp] = useState<string | null>(null); | |
| 45 | + const r: EstimateResult = data.result; | |
| 46 | + const u = data.unit; | |
| 47 | + const s = u?.specs; | |
| 48 | + const fr = lang === "fr"; | |
| 49 | + const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1); | |
| 50 | + const moneyFmt = (v: number) => | |
| 51 | + new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", { | |
| 52 | + style: "currency", | |
| 53 | + currency: "CAD", | |
| 54 | + maximumFractionDigits: 0, | |
| 55 | + }).format(Math.round(v / 100) * 100); | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <div className="space-y-10 py-8"> | |
| 59 | + {/* fil d'ariane */} | |
| 60 | + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3"> | |
| 61 | + <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green"> | |
| 62 | + Vrai-Prix | |
| 63 | + </Link> | |
| 64 | + <span>/</span> | |
| 65 | + <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span> | |
| 66 | + </nav> | |
| 67 | + | |
| 68 | + {/* ---- Panneau prix ---- */} | |
| 69 | + <section className="vp-card rise p-6 sm:p-8"> | |
| 70 | + <div className="flex flex-wrap items-start justify-between gap-6"> | |
| 71 | + <div className="min-w-0 flex-1"> | |
| 72 | + <span className="kicker">{t("estimatedValue")}</span> | |
| 73 | + {u && ( | |
| 74 | + <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]"> | |
| 75 | + {u.adresse} | |
| 76 | + {s?.apt ? ` app. ${s.apt}` : ""} | |
| 77 | + {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null} | |
| 78 | + </h1> | |
| 79 | + )} | |
| 80 | + <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]"> | |
| 81 | + <CountUp value={r.estimate} format={moneyFmt} /> | |
| 82 | + </p> | |
| 83 | + <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3"> | |
| 84 | + <div> | |
| 85 | + <p className="klabel">{t("range")}</p> | |
| 86 | + <p className="vp-display text-[19px] font-bold"> | |
| 87 | + {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)} | |
| 88 | + </p> | |
| 89 | + </div> | |
| 90 | + {u?.valeurRole != null && ( | |
| 91 | + <div> | |
| 92 | + <p className="klabel">{t("assessed")}</p> | |
| 93 | + <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p> | |
| 94 | + </div> | |
| 95 | + )} | |
| 96 | + {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && ( | |
| 97 | + <div> | |
| 98 | + <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p> | |
| 99 | + <p className="vp-display text-[19px] font-bold text-green-deep"> | |
| 100 | + {r.estimate >= u.valeurRole ? "+" : ""} | |
| 101 | + {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} % | |
| 102 | + </p> | |
| 103 | + </div> | |
| 104 | + )} | |
| 105 | + </div> | |
| 106 | + {u && ( | |
| 107 | + <div className="mt-6 flex flex-wrap gap-2.5"> | |
| 108 | + <a | |
| 109 | + href={`/api/report?id=${encodeURIComponent(u.id)}`} | |
| 110 | + className="btn btn-primary" | |
| 111 | + download | |
| 112 | + > | |
| 113 | + {fr ? "Rapport PDF" : "PDF report"} ↓ | |
| 114 | + </a> | |
| 115 | + <a | |
| 116 | + href={`/api/report/pro?id=${encodeURIComponent(u.id)}`} | |
| 117 | + className="btn btn-ghost" | |
| 118 | + download | |
| 119 | + title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"} | |
| 120 | + > | |
| 121 | + {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓ | |
| 122 | + </a> | |
| 123 | + </div> | |
| 124 | + )} | |
| 125 | + </div> | |
| 126 | + <div className="shrink-0"> | |
| 127 | + <ConfidenceDial | |
| 128 | + pct={r.confidencePct} | |
| 129 | + level={r.confidenceLevel} | |
| 130 | + label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`} | |
| 131 | + /> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + <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"> | |
| 135 | + {t("disclaimer")} | |
| 136 | + </p> | |
| 137 | + </section> | |
| 138 | + | |
| 139 | + {/* ---- Portrait de la propriété (bento du registre) ---- */} | |
| 140 | + {u && s && ( | |
| 141 | + <section> | |
| 142 | + <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span> | |
| 143 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 144 | + {fr ? "Portrait de la propriété" : "Property portrait"} | |
| 145 | + </h2> | |
| 146 | + <div className="mt-4 grid gap-4 lg:grid-cols-3"> | |
| 147 | + {/* Bâtiment */} | |
| 148 | + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}> | |
| 149 | + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 150 | + {fr ? "Bâtiment" : "Building"} | |
| 151 | + </p> | |
| 152 | + <div className="flex justify-center py-2"> | |
| 153 | + <BuildingGlyph | |
| 154 | + floors={s.nbEtages} | |
| 155 | + dwellings={u.nbLogements} | |
| 156 | + genre={s.genreConstruction} | |
| 157 | + yearBuilt={u.anneeConstruction} | |
| 158 | + floorAreaM2={u.aireEtagesM2} | |
| 159 | + lang={lang} | |
| 160 | + /> | |
| 161 | + </div> | |
| 162 | + <EraLine year={u.anneeConstruction} lang={lang} /> | |
| 163 | + <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 164 | + <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} /> | |
| 165 | + <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} /> | |
| 166 | + <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} /> | |
| 167 | + <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} /> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + {/* Terrain */} | |
| 171 | + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}> | |
| 172 | + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 173 | + {fr ? "Terrain — à l'échelle" : "Lot — to scale"} | |
| 174 | + </p> | |
| 175 | + <div className="flex justify-center py-2"> | |
| 176 | + <LotDiagram | |
| 177 | + areaM2={u.superficieTerrainM2} | |
| 178 | + frontageM={s.frontTerrainM} | |
| 179 | + footprintM2={ | |
| 180 | + u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2 | |
| 181 | + } | |
| 182 | + lang={lang} | |
| 183 | + isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)} | |
| 184 | + /> | |
| 185 | + </div> | |
| 186 | + <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 187 | + <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} /> | |
| 188 | + <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} /> | |
| 189 | + <Chip k="CUBF" v={s.cubf ?? "—"} /> | |
| 190 | + <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} /> | |
| 191 | + </div> | |
| 192 | + </div> | |
| 193 | + {/* Valeur au rôle */} | |
| 194 | + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}> | |
| 195 | + <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 196 | + {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"} | |
| 197 | + </p> | |
| 198 | + <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]"> | |
| 199 | + {fmt(u.valeurRole, lang)} | |
| 200 | + </p> | |
| 201 | + <ValueSplit | |
| 202 | + land={s.valeurTerrain} | |
| 203 | + building={s.valeurBatiment} | |
| 204 | + previous={s.valeurAnterieure} | |
| 205 | + total={u.valeurRole} | |
| 206 | + lang={lang} | |
| 207 | + labels={{ | |
| 208 | + land: fr ? "Terrain" : "Land", | |
| 209 | + building: fr ? "Bâtiment" : "Building", | |
| 210 | + previous: fr ? "Rôle antérieur" : "Previous roll", | |
| 211 | + }} | |
| 212 | + /> | |
| 213 | + <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 214 | + <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} /> | |
| 215 | + <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} /> | |
| 216 | + </div> | |
| 217 | + </div> | |
| 218 | + </div> | |
| 219 | + {/* registre brut complet */} | |
| 220 | + <details className="vp-card mt-4 p-5"> | |
| 221 | + <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep"> | |
| 222 | + {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"} | |
| 223 | + </summary> | |
| 224 | + <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4"> | |
| 225 | + <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} /> | |
| 226 | + <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} /> | |
| 227 | + <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} /> | |
| 228 | + <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} /> | |
| 229 | + <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} /> | |
| 230 | + <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} /> | |
| 231 | + <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} /> | |
| 232 | + <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} /> | |
| 233 | + <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} /> | |
| 234 | + <Chip k="ID provincial" v={u.id} /> | |
| 235 | + </div> | |
| 236 | + </details> | |
| 237 | + </section> | |
| 238 | + )} | |
| 239 | + | |
| 240 | + {/* ---- Transparence du calcul ---- */} | |
| 241 | + <section> | |
| 242 | + <span className="kicker">{fr ? "Transparence" : "Transparency"}</span> | |
| 243 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 244 | + {t("whyTitle")} | |
| 245 | + </h2> | |
| 246 | + <div className="mt-4 grid gap-4 sm:grid-cols-2"> | |
| 247 | + <div className="vp-card vp-card-hover rise p-5"> | |
| 248 | + <p className="klabel">{t("modelPart")}</p> | |
| 249 | + <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]"> | |
| 250 | + {r.modelEstimate != null ? ( | |
| 251 | + <CountUp value={r.modelEstimate} format={moneyFmt} /> | |
| 252 | + ) : ( | |
| 253 | + "—" | |
| 254 | + )} | |
| 255 | + </p> | |
| 256 | + <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep"> | |
| 257 | + {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} % | |
| 258 | + </p> | |
| 259 | + </div> | |
| 260 | + <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}> | |
| 261 | + <p className="klabel">{t("compsPart")}</p> | |
| 262 | + <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]"> | |
| 263 | + {r.compsEstimate != null ? ( | |
| 264 | + <CountUp value={r.compsEstimate} format={moneyFmt} /> | |
| 265 | + ) : ( | |
| 266 | + "—" | |
| 267 | + )} | |
| 268 | + </p> | |
| 269 | + <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep"> | |
| 270 | + {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps | |
| 271 | + {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""} | |
| 272 | + </p> | |
| 273 | + </div> | |
| 274 | + </div> | |
| 275 | + </section> | |
| 276 | + | |
| 277 | + {/* ---- Historique ---- */} | |
| 278 | + {u && u.history.some((h) => h.value != null) && ( | |
| 279 | + <section> | |
| 280 | + <span className="kicker">2021 → 2026</span> | |
| 281 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 282 | + {t("history")} | |
| 283 | + </h2> | |
| 284 | + <div className="vp-card rise mt-4 p-5 sm:p-6"> | |
| 285 | + <div className="flex flex-col gap-2"> | |
| 286 | + {u.history.map((h, i) => { | |
| 287 | + const prev = i > 0 ? u.history[i - 1].value : null; | |
| 288 | + const yoy = | |
| 289 | + h.value != null && prev != null && prev > 0 | |
| 290 | + ? ((h.value / prev - 1) * 100) | |
| 291 | + : null; | |
| 292 | + return ( | |
| 293 | + <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"> | |
| 294 | + <span className="vp-mono text-[12px] font-bold">{h.year}</span> | |
| 295 | + <span className="hbar-track"> | |
| 296 | + <span | |
| 297 | + className="hbar-fill grow-x" | |
| 298 | + style={{ | |
| 299 | + width: `${((h.value ?? 0) / histMax) * 100}%`, | |
| 300 | + animationDelay: `${i * 90}ms`, | |
| 301 | + background: h.year === 2026 ? "var(--ink)" : undefined, | |
| 302 | + }} | |
| 303 | + /> | |
| 304 | + </span> | |
| 305 | + <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold"> | |
| 306 | + {fmt(h.value, lang)} | |
| 307 | + {yoy != null && ( | |
| 308 | + <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)]"}`}> | |
| 309 | + {yoy >= 0 ? "+" : ""} | |
| 310 | + {yoy.toFixed(0)} % | |
| 311 | + </span> | |
| 312 | + )} | |
| 313 | + </span> | |
| 314 | + </div> | |
| 315 | + ); | |
| 316 | + })} | |
| 317 | + </div> | |
| 318 | + </div> | |
| 319 | + </section> | |
| 320 | + )} | |
| 321 | + | |
| 322 | + {/* ---- Plan des comparables (carte maison) ---- */} | |
| 323 | + <section> | |
| 324 | + <span className="kicker"> | |
| 325 | + {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"} | |
| 326 | + </span> | |
| 327 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 328 | + {t("compsTitle")} | |
| 329 | + </h2> | |
| 330 | + <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]"> | |
| 331 | + <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]"> | |
| 332 | + <RadarMap | |
| 333 | + subject={{ | |
| 334 | + lat: u?.lat ?? r.comps[0]?.lat ?? 46.81, | |
| 335 | + lng: u?.lng ?? r.comps[0]?.lng ?? -71.21, | |
| 336 | + label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"), | |
| 337 | + }} | |
| 338 | + comps={r.comps.map((c) => ({ | |
| 339 | + id: c.id, | |
| 340 | + lat: c.lat, | |
| 341 | + lng: c.lng, | |
| 342 | + label: `${c.street ?? ""}, ${c.city ?? ""}`, | |
| 343 | + price: fmt(c.amount, lang), | |
| 344 | + adjusted: fmt(Math.round(c.adjustedPrice), lang), | |
| 345 | + date: c.date, | |
| 346 | + distanceM: c.distanceM, | |
| 347 | + weight: c.weight, | |
| 348 | + }))} | |
| 349 | + highlight={activeComp} | |
| 350 | + onHover={setActiveComp} | |
| 351 | + /> | |
| 352 | + </div> | |
| 353 | + <div className="src-wrap max-h-[560px] overflow-y-auto"> | |
| 354 | + <table className="src-table"> | |
| 355 | + <thead className="sticky top-0 z-10"> | |
| 356 | + <tr> | |
| 357 | + <th>#</th> | |
| 358 | + <th>{fr ? "Vente" : "Sale"}</th> | |
| 359 | + <th className="text-right">{t("adjusted")}</th> | |
| 360 | + </tr> | |
| 361 | + </thead> | |
| 362 | + <tbody> | |
| 363 | + {r.comps.map((c, i) => ( | |
| 364 | + <tr | |
| 365 | + key={c.id} | |
| 366 | + onMouseEnter={() => setActiveComp(c.id)} | |
| 367 | + onMouseLeave={() => setActiveComp(null)} | |
| 368 | + className={activeComp === c.id ? "!bg-lime-soft" : ""} | |
| 369 | + > | |
| 370 | + <td> | |
| 371 | + <span | |
| 372 | + className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${ | |
| 373 | + activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper" | |
| 374 | + }`} | |
| 375 | + > | |
| 376 | + {i + 1} | |
| 377 | + </span> | |
| 378 | + </td> | |
| 379 | + <td> | |
| 380 | + <p className="font-semibold leading-tight"> | |
| 381 | + {c.street}, {c.city} | |
| 382 | + </p> | |
| 383 | + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> | |
| 384 | + {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "} | |
| 385 | + {t("sold")} {fmt(c.amount, lang)} | |
| 386 | + </p> | |
| 387 | + <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2"> | |
| 388 | + {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "} | |
| 389 | + {t("adjAge")} {signed(c.adjAge, lang)} | |
| 390 | + </p> | |
| 391 | + </td> | |
| 392 | + <td className="vp-display text-right font-bold"> | |
| 393 | + {fmt(Math.round(c.adjustedPrice), lang)} | |
| 394 | + <p className="vp-mono text-[10px] font-normal text-ink-3"> | |
| 395 | + {t("weight")} {(c.weight * 100).toFixed(0)} % | |
| 396 | + </p> | |
| 397 | + </td> | |
| 398 | + </tr> | |
| 399 | + ))} | |
| 400 | + </tbody> | |
| 401 | + </table> | |
| 402 | + </div> | |
| 403 | + </div> | |
| 404 | + </section> | |
| 405 | + | |
| 406 | + {/* ---- Lead ---- */} | |
| 407 | + <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"> | |
| 408 | + <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime"> | |
| 409 | + {t("leadTitle")} | |
| 410 | + </h2> | |
| 411 | + <div className="mt-4 max-w-xl"> | |
| 412 | + <LeadForm unitId={u?.id ?? null} estimate={r.estimate} /> | |
| 413 | + </div> | |
| 414 | + </section> | |
| 415 | + </div> | |
| 416 | + ); | |
| 417 | +} | |
added
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
src/components/StatsView.tsx
+283 −0
@@ -0,0 +1,283 @@ | ||
| 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 | +import type { TKey } from "@/lib/i18n"; | |
| 7 | + | |
| 8 | +export interface ProvStats { | |
| 9 | + generated: string; | |
| 10 | + unites: number; | |
| 11 | + valeur_totale_2026: number; | |
| 12 | + valeur_role_totale: number; | |
| 13 | + valeur_mediane_2026: number; | |
| 14 | + croissance_2021_2026_pct: number; | |
| 15 | + logements: number; | |
| 16 | + aire_etages_km2: number; | |
| 17 | + terrain_km2: number; | |
| 18 | + municipalites: number; | |
| 19 | + totaux_annee: { year: number; total: number; n: number }[]; | |
| 20 | + par_type: { type: string; n: number; total: number; mediane: number | null }[]; | |
| 21 | + par_ville: { ville: string; n: number; total: number; mediane: number | null }[]; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const POP_QC = 9_111_000; // population du Québec (2026, ISQ) | |
| 25 | +const BUDGET_QC = 165_800_000_000; // dépenses budget QC 2026-2027 (approx.) | |
| 26 | +const PIB_QC = 640_000_000_000; // PIB nominal QC (approx. 2025) | |
| 27 | + | |
| 28 | +const money = (v: number, lang: string, frac = 0) => | |
| 29 | + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { | |
| 30 | + style: "currency", | |
| 31 | + currency: "CAD", | |
| 32 | + maximumFractionDigits: frac, | |
| 33 | + }).format(v); | |
| 34 | + | |
| 35 | +const compact = (v: number, lang: string) => { | |
| 36 | + const fr = lang === "fr"; | |
| 37 | + if (v >= 1e12) return `${(v / 1e12).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })} ${fr ? "billions $" : "T$"}`; | |
| 38 | + if (v >= 1e9) return `${(v / 1e9).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} G$`; | |
| 39 | + if (v >= 1e6) return `${(v / 1e6).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} M$`; | |
| 40 | + return money(v, lang); | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const num = (v: number, lang: string) => | |
| 44 | + v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA"); | |
| 45 | + | |
| 46 | +export default function StatsView({ s }: { s: ProvStats }) { | |
| 47 | + const { lang, t } = useLang(); | |
| 48 | + const fr = lang === "fr"; | |
| 49 | + const [q, setQ] = useState(""); | |
| 50 | + | |
| 51 | + const maxYear = Math.max(...s.totaux_annee.map((x) => x.total)); | |
| 52 | + const maxType = Math.max(...s.par_type.map((x) => x.total)); | |
| 53 | + const top10 = s.par_ville.slice(0, 10); | |
| 54 | + const maxVille = Math.max(...top10.map((x) => x.total)); | |
| 55 | + const villes = s.par_ville.filter((v) => | |
| 56 | + v.ville.toLowerCase().includes(q.trim().toLowerCase()) | |
| 57 | + ); | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div className="pb-6 pt-10 sm:pt-14"> | |
| 61 | + {/* ---- LE CHIFFRE ---- */} | |
| 62 | + <section> | |
| 63 | + <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span> | |
| 64 | + <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> | |
| 65 | + {fr ? ( | |
| 66 | + <> | |
| 67 | + Tout l'immobilier du Québec vaut, | |
| 68 | + <br /> | |
| 69 | + <span className="hl">au dollar près</span>… | |
| 70 | + </> | |
| 71 | + ) : ( | |
| 72 | + <> | |
| 73 | + All Québec real estate is worth, | |
| 74 | + <br /> | |
| 75 | + <span className="hl">to the dollar</span>… | |
| 76 | + </> | |
| 77 | + )} | |
| 78 | + </h1> | |
| 79 | + <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"> | |
| 80 | + <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime"> | |
| 81 | + <CountUp | |
| 82 | + value={s.valeur_totale_2026} | |
| 83 | + duration={2600} | |
| 84 | + format={(v) => money(v, lang)} | |
| 85 | + /> | |
| 86 | + </p> | |
| 87 | + <p className="vp-mono mt-4 text-[11.5px] uppercase tracking-[0.1em] text-[rgba(255,81,72,0.75)]"> | |
| 88 | + {fr | |
| 89 | + ? `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} propriétés · ${num(s.municipalites, lang)} municipalités · millésime 2026` | |
| 90 | + : `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} properties · ${num(s.municipalites, lang)} municipalities · 2026 vintage`} | |
| 91 | + </p> | |
| 92 | + <div className="mt-6 flex flex-wrap gap-2.5"> | |
| 93 | + {( | |
| 94 | + [ | |
| 95 | + [fr ? "par Québécois·e" : "per Quebecer", money(s.valeur_totale_2026 / POP_QC, lang)], | |
| 96 | + [fr ? "budgets annuels du Québec" : "annual Québec budgets", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`], | |
| 97 | + [fr ? "fois le PIB du Québec" : "× Québec GDP", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`], | |
| 98 | + [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`], | |
| 99 | + ] as [string, string][] | |
| 100 | + ).map(([k, v]) => ( | |
| 101 | + <span key={k} className="stat-chip !border-[rgba(255,81,72,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none"> | |
| 102 | + <b className="!text-lime">{v}</b> {k} | |
| 103 | + </span> | |
| 104 | + ))} | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> | |
| 108 | + {fr | |
| 109 | + ? "Somme des 3 747 008 estimations Vrai-Prix (modèle hédonique, rôle 2026). Évaluation municipale totale correspondante : " | |
| 110 | + : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "} | |
| 111 | + {compact(s.valeur_role_totale, lang)}. | |
| 112 | + </p> | |
| 113 | + <a href="/api/report/stats" className="btn btn-primary mt-5" download> | |
| 114 | + {fr | |
| 115 | + ? "Télécharger le rapport statistique provincial (PDF)" | |
| 116 | + : "Download the provincial statistics report (PDF)"}{" "} | |
| 117 | + ↓ | |
| 118 | + </a> | |
| 119 | + </section> | |
| 120 | + | |
| 121 | + {/* ---- tuiles physiques ---- */} | |
| 122 | + <section className="mt-10"> | |
| 123 | + <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> | |
| 124 | + {( | |
| 125 | + [ | |
| 126 | + [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true], | |
| 127 | + [fr ? "Logements" : "Dwellings", num(s.logements, lang), false], | |
| 128 | + [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false], | |
| 129 | + [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false], | |
| 130 | + ] as [string, string, boolean][] | |
| 131 | + ).map(([k, v, hero], i) => ( | |
| 132 | + <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}> | |
| 133 | + <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p> | |
| 134 | + <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,81,72,0.7)]" : "text-ink-3"}`}>{k}</p> | |
| 135 | + </div> | |
| 136 | + ))} | |
| 137 | + </div> | |
| 138 | + </section> | |
| 139 | + | |
| 140 | + {/* ---- valeur provinciale par année ---- */} | |
| 141 | + <section className="mt-12"> | |
| 142 | + <span className="kicker">2021 → 2026</span> | |
| 143 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 144 | + {fr ? "La province, année par année" : "The province, year by year"} | |
| 145 | + </h2> | |
| 146 | + <div className="vp-card rise mt-4 p-5 sm:p-6"> | |
| 147 | + <div className="flex flex-col gap-2"> | |
| 148 | + {s.totaux_annee.map((h, i) => { | |
| 149 | + const prev = i > 0 ? s.totaux_annee[i - 1].total : null; | |
| 150 | + const yoy = prev ? ((h.total / prev - 1) * 100) : null; | |
| 151 | + return ( | |
| 152 | + <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"> | |
| 153 | + <span className="vp-mono text-[12px] font-bold">{h.year}</span> | |
| 154 | + <span className="hbar-track !h-[24px]"> | |
| 155 | + <span | |
| 156 | + className="hbar-fill grow-x !rounded-r-[6px]" | |
| 157 | + style={{ | |
| 158 | + width: `${(h.total / maxYear) * 100}%`, | |
| 159 | + animationDelay: `${i * 90}ms`, | |
| 160 | + background: h.year === 2026 ? "var(--ink)" : undefined, | |
| 161 | + }} | |
| 162 | + /> | |
| 163 | + </span> | |
| 164 | + <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold"> | |
| 165 | + {compact(h.total, lang)} | |
| 166 | + {yoy != null && ( | |
| 167 | + <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)]"}`}> | |
| 168 | + {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} % | |
| 169 | + </span> | |
| 170 | + )} | |
| 171 | + </span> | |
| 172 | + </div> | |
| 173 | + ); | |
| 174 | + })} | |
| 175 | + </div> | |
| 176 | + <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | |
| 177 | + {fr | |
| 178 | + ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)." | |
| 179 | + : "Sums of units estimated each vintage — the stock grows every year (new construction included)."} | |
| 180 | + </p> | |
| 181 | + </div> | |
| 182 | + </section> | |
| 183 | + | |
| 184 | + {/* ---- par type ---- */} | |
| 185 | + <section className="mt-12"> | |
| 186 | + <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span> | |
| 187 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 188 | + {fr ? "Valeur par type de propriété" : "Value by property type"} | |
| 189 | + </h2> | |
| 190 | + <div className="vp-card rise mt-4 p-5 sm:p-6"> | |
| 191 | + <div className="flex flex-col gap-2.5"> | |
| 192 | + {s.par_type.map((tp, i) => ( | |
| 193 | + <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"> | |
| 194 | + <span className="vp-display truncate text-[13px] font-bold">{t(tp.type as TKey)}</span> | |
| 195 | + <span className="hbar-track !h-[22px]"> | |
| 196 | + <span | |
| 197 | + className="hbar-fill grow-x" | |
| 198 | + style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }} | |
| 199 | + /> | |
| 200 | + </span> | |
| 201 | + <span className="vp-mono text-right text-[11.5px] font-bold"> | |
| 202 | + {compact(tp.total, lang)} | |
| 203 | + <span className="block text-[9.5px] font-normal text-ink-3"> | |
| 204 | + {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"} | |
| 205 | + </span> | |
| 206 | + </span> | |
| 207 | + </div> | |
| 208 | + ))} | |
| 209 | + </div> | |
| 210 | + </div> | |
| 211 | + </section> | |
| 212 | + | |
| 213 | + {/* ---- par ville ---- */} | |
| 214 | + <section className="mt-12"> | |
| 215 | + <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span> | |
| 216 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 217 | + {fr ? "Valeur par municipalité" : "Value by municipality"} | |
| 218 | + </h2> | |
| 219 | + {/* top 10 en barres */} | |
| 220 | + <div className="vp-card rise mt-4 p-5 sm:p-6"> | |
| 221 | + <div className="flex flex-col gap-2"> | |
| 222 | + {top10.map((v, i) => ( | |
| 223 | + <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"> | |
| 224 | + <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"}`}> | |
| 225 | + {i + 1} | |
| 226 | + </span> | |
| 227 | + <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span> | |
| 228 | + <span className="hbar-track !h-[22px]"> | |
| 229 | + <span | |
| 230 | + className="hbar-fill grow-x" | |
| 231 | + style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }} | |
| 232 | + /> | |
| 233 | + </span> | |
| 234 | + <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span> | |
| 235 | + </div> | |
| 236 | + ))} | |
| 237 | + </div> | |
| 238 | + </div> | |
| 239 | + {/* table complète filtrable */} | |
| 240 | + <div className="mt-4"> | |
| 241 | + <input | |
| 242 | + value={q} | |
| 243 | + onChange={(e) => setQ(e.target.value)} | |
| 244 | + placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"} | |
| 245 | + className="vp-input max-w-md" | |
| 246 | + /> | |
| 247 | + <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto"> | |
| 248 | + <table className="src-table"> | |
| 249 | + <thead className="sticky top-0 z-10"> | |
| 250 | + <tr> | |
| 251 | + <th>#</th> | |
| 252 | + <th>{fr ? "Municipalité" : "Municipality"}</th> | |
| 253 | + <th className="text-right">{fr ? "Propriétés" : "Properties"}</th> | |
| 254 | + <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th> | |
| 255 | + <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th> | |
| 256 | + </tr> | |
| 257 | + </thead> | |
| 258 | + <tbody> | |
| 259 | + {villes.map((v) => { | |
| 260 | + const rank = s.par_ville.indexOf(v) + 1; | |
| 261 | + return ( | |
| 262 | + <tr key={v.ville}> | |
| 263 | + <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td> | |
| 264 | + <td className="font-semibold">{v.ville}</td> | |
| 265 | + <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td> | |
| 266 | + <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td> | |
| 267 | + <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td> | |
| 268 | + </tr> | |
| 269 | + ); | |
| 270 | + })} | |
| 271 | + </tbody> | |
| 272 | + </table> | |
| 273 | + </div> | |
| 274 | + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | |
| 275 | + {fr | |
| 276 | + ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.` | |
| 277 | + : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`} | |
| 278 | + </p> | |
| 279 | + </div> | |
| 280 | + </section> | |
| 281 | + </div> | |
| 282 | + ); | |
| 283 | +} | |
added
src/lib/db.ts
+142 −0
@@ -0,0 +1,142 @@ | ||
| 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.VRAIPRIX_DB ?? path.join(process.cwd(), "data", "vraiprix.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 | + est_2021: number | null; | |
| 49 | + est_2022: number | null; | |
| 50 | + est_2023: number | null; | |
| 51 | + est_2024: number | null; | |
| 52 | + est_2025: number | null; | |
| 53 | + est_2026: number | null; | |
| 54 | + p10: number | null; | |
| 55 | + p90: number | null; | |
| 56 | + est_hedo: number | null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export interface TxRow { | |
| 60 | + id: string; | |
| 61 | + date: string; | |
| 62 | + amount: number; | |
| 63 | + street: string | null; | |
| 64 | + city: string | null; | |
| 65 | + lat: number; | |
| 66 | + lng: number; | |
| 67 | + property_type: string | null; | |
| 68 | + year_built: number | null; | |
| 69 | + floor_area: number | null; | |
| 70 | + building_type: string | null; | |
| 71 | + id_provinc: string | null; | |
| 72 | + valeur_role: number | null; | |
| 73 | + land_area: number | null; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function getUnit(id: string): UnitRow | undefined { | |
| 77 | + return getDb() | |
| 78 | + .prepare("SELECT * FROM units WHERE id_provinc = ?") | |
| 79 | + .get(id) as UnitRow | undefined; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** Candidats comparables dans une boîte englobante autour du point. */ | |
| 83 | +export function getCandidates( | |
| 84 | + lat: number, | |
| 85 | + lng: number, | |
| 86 | + halfDeg: number, | |
| 87 | + sinceDate: string, | |
| 88 | + limit = 500 | |
| 89 | +): TxRow[] { | |
| 90 | + return getDb() | |
| 91 | + .prepare( | |
| 92 | + `SELECT * FROM transactions | |
| 93 | + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? | |
| 94 | + AND date >= ? AND amount >= 50000 | |
| 95 | + LIMIT ?` | |
| 96 | + ) | |
| 97 | + .all(lat - halfDeg, lat + halfDeg, lng - halfDeg, lng + halfDeg, sinceDate, limit) as TxRow[]; | |
| 98 | +} | |
| 99 | + | |
| 100 | +export function getMarketIndex(typeProp: string): { month: string; idx: number }[] { | |
| 101 | + return getDb() | |
| 102 | + .prepare( | |
| 103 | + "SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month" | |
| 104 | + ) | |
| 105 | + .all(typeProp) as { month: string; idx: number }[]; | |
| 106 | +} | |
| 107 | + | |
| 108 | +export function searchUnits(q: string, limit = 8): (UnitRow & { score: number })[] { | |
| 109 | + const cleaned = q | |
| 110 | + .replace(/[^\p{L}\p{N}\s'-]/gu, " ") | |
| 111 | + .trim() | |
| 112 | + .split(/\s+/) | |
| 113 | + .filter((t) => t.length > 0) | |
| 114 | + .map((t) => `"${t}"*`) | |
| 115 | + .join(" "); | |
| 116 | + if (!cleaned) return []; | |
| 117 | + return getDb() | |
| 118 | + .prepare( | |
| 119 | + `SELECT u.*, bm25(units_fts) AS score | |
| 120 | + FROM units_fts JOIN units u ON u.rowid = units_fts.rowid | |
| 121 | + WHERE units_fts MATCH ? | |
| 122 | + ORDER BY score LIMIT ?` | |
| 123 | + ) | |
| 124 | + .all(cleaned, limit) as (UnitRow & { score: number })[]; | |
| 125 | +} | |
| 126 | + | |
| 127 | +export function municipalityCenter( | |
| 128 | + name: string | |
| 129 | +): { lat: number; lng: number; n: number } | undefined { | |
| 130 | + return getDb() | |
| 131 | + .prepare( | |
| 132 | + `SELECT AVG(lat) AS lat, AVG(lng) AS lng, COUNT(*) AS n | |
| 133 | + FROM units WHERE municipalite = ? COLLATE NOCASE` | |
| 134 | + ) | |
| 135 | + .get(name) as { lat: number; lng: number; n: number } | undefined; | |
| 136 | +} | |
| 137 | + | |
| 138 | +export function saveLead(email: string, unitId: string | null, estimate: number | null): void { | |
| 139 | + getDb() | |
| 140 | + .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)") | |
| 141 | + .run(email, unitId, estimate); | |
| 142 | +} | |
added
src/lib/engine.test.ts
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { | |
| 4 | + adjustComps, | |
| 5 | + ageAdjustment, | |
| 6 | + areaAdjustment, | |
| 7 | + estimate, | |
| 8 | + haversineM, | |
| 9 | + timeFactor, | |
| 10 | + weightedMedian, | |
| 11 | + type CompInput, | |
| 12 | + type MarketIndexPoint, | |
| 13 | + type Subject, | |
| 14 | +} from "./engine"; | |
| 15 | + | |
| 16 | +const NOW = "2026-08-08"; | |
| 17 | + | |
| 18 | +const INDEX: MarketIndexPoint[] = [ | |
| 19 | + { month: "2024-01", idx: 0.8 }, | |
| 20 | + { month: "2025-01", idx: 0.9 }, | |
| 21 | + { month: "2026-01", idx: 1.0 }, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +function comp(over: Partial<CompInput>): CompInput { | |
| 25 | + return { | |
| 26 | + id: "c1", | |
| 27 | + date: "2026-02-15", | |
| 28 | + amount: 400000, | |
| 29 | + lat: 46.8, | |
| 30 | + lng: -71.2, | |
| 31 | + propertyType: "unifamilial", | |
| 32 | + yearBuilt: 1990, | |
| 33 | + floorArea: 120, | |
| 34 | + street: "1 rue Test", | |
| 35 | + city: "Québec", | |
| 36 | + ...over, | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +const SUBJECT: Subject = { | |
| 41 | + lat: 46.8, | |
| 42 | + lng: -71.2, | |
| 43 | + typeProp: "unifamilial", | |
| 44 | + floorArea: 120, | |
| 45 | + yearBuilt: 1990, | |
| 46 | + modelEstimate: 410000, | |
| 47 | + modelP10: 340000, | |
| 48 | + modelP90: 480000, | |
| 49 | +}; | |
| 50 | + | |
| 51 | +describe("haversineM", () => { | |
| 52 | + it("est nul à distance nulle et ~111 km par degré de latitude", () => { | |
| 53 | + expect(haversineM(46.8, -71.2, 46.8, -71.2)).toBe(0); | |
| 54 | + expect(haversineM(46.0, -71.2, 47.0, -71.2)).toBeGreaterThan(110000); | |
| 55 | + expect(haversineM(46.0, -71.2, 47.0, -71.2)).toBeLessThan(112000); | |
| 56 | + }); | |
| 57 | +}); | |
| 58 | + | |
| 59 | +describe("timeFactor", () => { | |
| 60 | + it("majore une vente ancienne selon l'indice", () => { | |
| 61 | + expect(timeFactor("2024-01", INDEX)).toBeCloseTo(1.0 / 0.8, 5); | |
| 62 | + expect(timeFactor("2026-03", INDEX)).toBe(1); | |
| 63 | + }); | |
| 64 | + it("retourne 1 sans indice", () => { | |
| 65 | + expect(timeFactor("2024-01", [])).toBe(1); | |
| 66 | + }); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +describe("areaAdjustment", () => { | |
| 70 | + it("ajuste à 50 % du $/m² du comparable", () => { | |
| 71 | + // comp 100 m² à 400 000 $ => 4 000 $/m² ; sujet 110 m² => +10 × 2 000 = +20 000 | |
| 72 | + expect(areaAdjustment(110, { floorArea: 100, amount: 400000 })).toBe(20000); | |
| 73 | + expect(areaAdjustment(90, { floorArea: 100, amount: 400000 })).toBe(-20000); | |
| 74 | + }); | |
| 75 | + it("borne à ±25 % du prix", () => { | |
| 76 | + expect(areaAdjustment(300, { floorArea: 100, amount: 400000 })).toBe(100000); | |
| 77 | + }); | |
| 78 | + it("retourne 0 si superficie manquante", () => { | |
| 79 | + expect(areaAdjustment(null, { floorArea: 100, amount: 400000 })).toBe(0); | |
| 80 | + expect(areaAdjustment(110, { floorArea: null, amount: 400000 })).toBe(0); | |
| 81 | + }); | |
| 82 | +}); | |
| 83 | + | |
| 84 | +describe("ageAdjustment", () => { | |
| 85 | + it("0,5 % par année d'écart, borné à ±10 %", () => { | |
| 86 | + expect(ageAdjustment(2000, { yearBuilt: 1990, amount: 400000 })).toBeCloseTo(20000); | |
| 87 | + expect(ageAdjustment(1900, { yearBuilt: 2020, amount: 400000 })).toBeCloseTo(-40000); | |
| 88 | + }); | |
| 89 | +}); | |
| 90 | + | |
| 91 | +describe("weightedMedian", () => { | |
| 92 | + it("respecte les poids", () => { | |
| 93 | + expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300); | |
| 94 | + expect(weightedMedian([100, 200, 300], [1, 1, 1])).toBe(200); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe("adjustComps", () => { | |
| 99 | + it("filtre par type et superficie, trie par poids", () => { | |
| 100 | + const candidates = [ | |
| 101 | + comp({ id: "proche", lat: 46.801, floorArea: 118 }), | |
| 102 | + comp({ id: "loin", lat: 46.9, floorArea: 118 }), | |
| 103 | + comp({ id: "condo", propertyType: "condo" }), | |
| 104 | + comp({ id: "trop-grand", floorArea: 400 }), | |
| 105 | + comp({ id: "a", floorArea: 125 }), | |
| 106 | + comp({ id: "b", floorArea: 115 }), | |
| 107 | + comp({ id: "c", floorArea: 130 }), | |
| 108 | + comp({ id: "d", floorArea: 110 }), | |
| 109 | + ]; | |
| 110 | + const res = adjustComps(SUBJECT, candidates, INDEX, NOW); | |
| 111 | + const ids = res.map((c) => c.id); | |
| 112 | + expect(ids).not.toContain("condo"); | |
| 113 | + expect(ids).not.toContain("trop-grand"); | |
| 114 | + expect(res[0].id).toBe("proche"); // le plus proche pèse le plus | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it("relâche le filtre de type quand le marché est mince", () => { | |
| 118 | + const candidates = [ | |
| 119 | + comp({ id: "x", propertyType: "indéterminé" }), | |
| 120 | + comp({ id: "y", propertyType: "indéterminé" }), | |
| 121 | + ]; | |
| 122 | + const res = adjustComps(SUBJECT, candidates, INDEX, NOW); | |
| 123 | + expect(res.length).toBe(2); | |
| 124 | + }); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +describe("estimate", () => { | |
| 128 | + const candidates = Array.from({ length: 8 }, (_, i) => | |
| 129 | + comp({ | |
| 130 | + id: `c${i}`, | |
| 131 | + lat: 46.8 + i * 0.001, | |
| 132 | + amount: 380000 + i * 10000, | |
| 133 | + date: "2026-01-10", | |
| 134 | + }) | |
| 135 | + ); | |
| 136 | + | |
| 137 | + it("combine modèle (65 %) et comparables (35 %)", () => { | |
| 138 | + const r = estimate(SUBJECT, candidates, INDEX, NOW); | |
| 139 | + expect(r.modelWeight).toBe(0.65); | |
| 140 | + expect(r.modelEstimate).toBe(410000); | |
| 141 | + expect(r.compsEstimate).not.toBeNull(); | |
| 142 | + const expected = 0.65 * 410000 + 0.35 * r.compsEstimate!; | |
| 143 | + expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100); | |
| 144 | + expect(r.low).toBeLessThan(r.estimate); | |
| 145 | + expect(r.high).toBeGreaterThan(r.estimate); | |
| 146 | + }); | |
| 147 | + | |
| 148 | + it("retombe sur le modèle seul sans comparables", () => { | |
| 149 | + const r = estimate(SUBJECT, [], INDEX, NOW); | |
| 150 | + expect(r.modelWeight).toBe(1); | |
| 151 | + expect(r.estimate).toBe(410000); | |
| 152 | + expect(r.nCompsUsed).toBe(0); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + it("fonctionne aux comparables seuls (sans modèle)", () => { | |
| 156 | + const r = estimate({ ...SUBJECT, modelEstimate: null, modelP10: null, modelP90: null }, | |
| 157 | + candidates, INDEX, NOW); | |
| 158 | + expect(r.modelEstimate).toBeNull(); | |
| 159 | + expect(r.estimate).toBeGreaterThan(300000); | |
| 160 | + expect(r.confidenceLevel).toMatch(/[A-D]/); | |
| 161 | + }); | |
| 162 | + | |
| 163 | + it("la confiance augmente avec le nombre de comparables", () => { | |
| 164 | + const few = estimate({ ...SUBJECT, modelEstimate: null }, candidates.slice(0, 3), INDEX, NOW); | |
| 165 | + const many = estimate({ ...SUBJECT, modelEstimate: null }, candidates, INDEX, NOW); | |
| 166 | + expect(many.confidencePct).toBeGreaterThanOrEqual(few.confidencePct); | |
| 167 | + }); | |
| 168 | + | |
| 169 | + it("jamais d'estimation sans niveau de confiance", () => { | |
| 170 | + const r = estimate(SUBJECT, candidates, INDEX, NOW); | |
| 171 | + expect(r.confidencePct).toBeGreaterThan(0); | |
| 172 | + expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel); | |
| 173 | + }); | |
| 174 | +}); | |
added
src/lib/engine.ts
+279 −0
@@ -0,0 +1,279 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Moteur d'estimation Vrai-Prix — 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; // unifamilial | plex | condo_ou_multi | chalet | maison_mobile | terrain | autre | |
| 18 | + floorArea?: number | null; | |
| 19 | + yearBuilt?: number | null; | |
| 20 | + landArea?: number | null; | |
| 21 | + modelEstimate?: number | null; | |
| 22 | + modelP10?: number | null; | |
| 23 | + modelP90?: number | null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface CompInput { | |
| 27 | + id: string; | |
| 28 | + date: string; // YYYY-MM-DD | |
| 29 | + amount: number; | |
| 30 | + lat: number; | |
| 31 | + lng: number; | |
| 32 | + propertyType: string | null; | |
| 33 | + yearBuilt: number | null; | |
| 34 | + floorArea: number | null; | |
| 35 | + street: string | null; | |
| 36 | + city: string | null; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface AdjustedComp extends CompInput { | |
| 40 | + distanceM: number; | |
| 41 | + monthsAgo: number; | |
| 42 | + adjTime: number; | |
| 43 | + adjArea: number; | |
| 44 | + adjAge: number; | |
| 45 | + adjustedPrice: number; | |
| 46 | + weight: number; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export interface MarketIndexPoint { | |
| 50 | + month: string; // YYYY-MM | |
| 51 | + idx: number; // 1.0 = niveau actuel | |
| 52 | +} | |
| 53 | + | |
| 54 | +export interface EstimateResult { | |
| 55 | + estimate: number; | |
| 56 | + low: number; | |
| 57 | + high: number; | |
| 58 | + confidencePct: number; | |
| 59 | + confidenceLevel: "A" | "B" | "C" | "D"; | |
| 60 | + modelEstimate: number | null; | |
| 61 | + compsEstimate: number | null; | |
| 62 | + modelWeight: number; | |
| 63 | + comps: AdjustedComp[]; | |
| 64 | + nCompsUsed: number; | |
| 65 | + compsDispersionPct: number | null; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** Correspondance type d'unité (rôle) -> types de transactions comparables. */ | |
| 69 | +export const TYPE_MATCH: Record<string, string[]> = { | |
| 70 | + unifamilial: ["unifamilial"], | |
| 71 | + plex: ["plex"], | |
| 72 | + condo_ou_multi: ["condo", "plex"], | |
| 73 | + chalet: ["unifamilial", "indéterminé"], | |
| 74 | + maison_mobile: ["unifamilial", "indéterminé"], | |
| 75 | + terrain: ["indéterminé"], | |
| 76 | + autre: ["unifamilial", "condo", "plex", "indéterminé"], | |
| 77 | +}; | |
| 78 | + | |
| 79 | +export function haversineM( | |
| 80 | + lat1: number, | |
| 81 | + lng1: number, | |
| 82 | + lat2: number, | |
| 83 | + lng2: number | |
| 84 | +): number { | |
| 85 | + const R = 6371000; | |
| 86 | + const toRad = (d: number) => (d * Math.PI) / 180; | |
| 87 | + const dLat = toRad(lat2 - lat1); | |
| 88 | + const dLng = toRad(lng2 - lng1); | |
| 89 | + const a = | |
| 90 | + Math.sin(dLat / 2) ** 2 + | |
| 91 | + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; | |
| 92 | + return 2 * R * Math.asin(Math.sqrt(a)); | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function monthsBetween(fromISO: string, toISO: string): number { | |
| 96 | + const a = new Date(fromISO); | |
| 97 | + const b = new Date(toISO); | |
| 98 | + return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44); | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */ | |
| 102 | +export function timeFactor( | |
| 103 | + saleMonth: string, | |
| 104 | + index: MarketIndexPoint[] | |
| 105 | +): number { | |
| 106 | + if (index.length === 0) return 1; | |
| 107 | + const last = index[index.length - 1].idx; | |
| 108 | + let saleIdx: number | null = null; | |
| 109 | + for (const p of index) { | |
| 110 | + if (p.month <= saleMonth) saleIdx = p.idx; | |
| 111 | + } | |
| 112 | + if (saleIdx === null) saleIdx = index[0].idx; | |
| 113 | + if (saleIdx <= 0) return 1; | |
| 114 | + return last / saleIdx; | |
| 115 | +} | |
| 116 | + | |
| 117 | +/** Ajustement superficie : 50 % du $/m² marginal du comparable. */ | |
| 118 | +export function areaAdjustment( | |
| 119 | + subjectArea: number | null | undefined, | |
| 120 | + comp: { floorArea: number | null; amount: number } | |
| 121 | +): number { | |
| 122 | + if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0; | |
| 123 | + const ppm2 = comp.amount / comp.floorArea; | |
| 124 | + const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2; | |
| 125 | + // borné à ±25 % du prix du comparable | |
| 126 | + const cap = 0.25 * comp.amount; | |
| 127 | + return Math.max(-cap, Math.min(cap, adj)); | |
| 128 | +} | |
| 129 | + | |
| 130 | +/** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */ | |
| 131 | +export function ageAdjustment( | |
| 132 | + subjectYear: number | null | undefined, | |
| 133 | + comp: { yearBuilt: number | null; amount: number } | |
| 134 | +): number { | |
| 135 | + if (!subjectYear || !comp.yearBuilt) return 0; | |
| 136 | + const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005)); | |
| 137 | + return pct * comp.amount; | |
| 138 | +} | |
| 139 | + | |
| 140 | +export function weightedMedian(values: number[], weights: number[]): number { | |
| 141 | + const order = values | |
| 142 | + .map((v, i) => ({ v, w: weights[i] })) | |
| 143 | + .sort((a, b) => a.v - b.v); | |
| 144 | + const total = order.reduce((s, o) => s + o.w, 0); | |
| 145 | + if (total <= 0) return NaN; | |
| 146 | + let acc = 0; | |
| 147 | + for (const o of order) { | |
| 148 | + acc += o.w; | |
| 149 | + if (acc >= total / 2) return o.v; | |
| 150 | + } | |
| 151 | + return order[order.length - 1].v; | |
| 152 | +} | |
| 153 | + | |
| 154 | +export function adjustComps( | |
| 155 | + subject: Subject, | |
| 156 | + candidates: CompInput[], | |
| 157 | + index: MarketIndexPoint[], | |
| 158 | + nowISO: string | |
| 159 | +): AdjustedComp[] { | |
| 160 | + const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre; | |
| 161 | + let pool = candidates.filter( | |
| 162 | + (c) => c.propertyType !== null && types.includes(c.propertyType) | |
| 163 | + ); | |
| 164 | + if (pool.length < 6) pool = candidates; // relâche le filtre de type si marché mince | |
| 165 | + | |
| 166 | + // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu | |
| 167 | + if (subject.floorArea) { | |
| 168 | + for (const tol of [0.2, 0.4]) { | |
| 169 | + const filtered = pool.filter( | |
| 170 | + (c) => | |
| 171 | + c.floorArea != null && | |
| 172 | + Math.abs(c.floorArea - subject.floorArea!) <= | |
| 173 | + tol * subject.floorArea! | |
| 174 | + ); | |
| 175 | + if (filtered.length >= 6) { | |
| 176 | + pool = filtered; | |
| 177 | + break; | |
| 178 | + } | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + const comps = pool.map((c) => { | |
| 183 | + const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng); | |
| 184 | + const monthsAgo = monthsBetween(c.date, nowISO); | |
| 185 | + const tf = timeFactor(c.date.slice(0, 7), index); | |
| 186 | + const adjTime = c.amount * (tf - 1); | |
| 187 | + const adjArea = areaAdjustment(subject.floorArea, c); | |
| 188 | + const adjAge = ageAdjustment(subject.yearBuilt, c); | |
| 189 | + const adjustedPrice = c.amount + adjTime + adjArea + adjAge; | |
| 190 | + const areaDiffPct = | |
| 191 | + subject.floorArea && c.floorArea | |
| 192 | + ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea | |
| 193 | + : 0.15; | |
| 194 | + const weight = | |
| 195 | + Math.exp(-((distanceM / 1500) ** 2)) * | |
| 196 | + Math.exp(-((monthsAgo / 24) ** 2)) * | |
| 197 | + Math.exp(-((areaDiffPct / 0.25) ** 2)); | |
| 198 | + return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjustedPrice, weight }; | |
| 199 | + }); | |
| 200 | + | |
| 201 | + return comps | |
| 202 | + .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4) | |
| 203 | + .sort((a, b) => b.weight - a.weight) | |
| 204 | + .slice(0, 12); | |
| 205 | +} | |
| 206 | + | |
| 207 | +function pctDispersion(comps: AdjustedComp[], center: number): number | null { | |
| 208 | + if (comps.length < 3 || center <= 0) return null; | |
| 209 | + const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center); | |
| 210 | + dev.sort((a, b) => a - b); | |
| 211 | + return dev[Math.floor(dev.length / 2)] * 100; | |
| 212 | +} | |
| 213 | + | |
| 214 | +export function estimate( | |
| 215 | + subject: Subject, | |
| 216 | + candidates: CompInput[], | |
| 217 | + index: MarketIndexPoint[], | |
| 218 | + nowISO: string | |
| 219 | +): EstimateResult { | |
| 220 | + const comps = adjustComps(subject, candidates, index, nowISO); | |
| 221 | + const compsEstimate = | |
| 222 | + comps.length >= 3 | |
| 223 | + ? weightedMedian( | |
| 224 | + comps.map((c) => c.adjustedPrice), | |
| 225 | + comps.map((c) => c.weight) | |
| 226 | + ) | |
| 227 | + : null; | |
| 228 | + | |
| 229 | + const model = subject.modelEstimate ?? null; | |
| 230 | + let modelWeight = 0; | |
| 231 | + let estimateValue: number; | |
| 232 | + if (model !== null && compsEstimate !== null) { | |
| 233 | + modelWeight = 0.65; | |
| 234 | + estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate; | |
| 235 | + } else if (model !== null) { | |
| 236 | + modelWeight = 1; | |
| 237 | + estimateValue = model; | |
| 238 | + } else if (compsEstimate !== null) { | |
| 239 | + estimateValue = compsEstimate; | |
| 240 | + } else { | |
| 241 | + estimateValue = NaN; | |
| 242 | + } | |
| 243 | + | |
| 244 | + // fourchette : intervalle du modèle recentré, sinon dispersion des comparables | |
| 245 | + let low: number, high: number; | |
| 246 | + if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) { | |
| 247 | + low = estimateValue * (subject.modelP10 / model); | |
| 248 | + high = estimateValue * (subject.modelP90 / model); | |
| 249 | + } else { | |
| 250 | + const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25; | |
| 251 | + low = estimateValue * (1 - disp / 100); | |
| 252 | + high = estimateValue * (1 + disp / 100); | |
| 253 | + } | |
| 254 | + | |
| 255 | + const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue); | |
| 256 | + // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle | |
| 257 | + let score = 35; | |
| 258 | + score += Math.min(25, comps.length * 2.5); | |
| 259 | + if (dispersion !== null) score += Math.max(0, 20 - dispersion); | |
| 260 | + if (model !== null) score += 15; | |
| 261 | + const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100; | |
| 262 | + score -= Math.max(0, (widthPct - 30) / 3); | |
| 263 | + score = Math.max(5, Math.min(97, score)); | |
| 264 | + const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D"; | |
| 265 | + | |
| 266 | + return { | |
| 267 | + estimate: Math.round(estimateValue / 100) * 100, | |
| 268 | + low: Math.round(low / 100) * 100, | |
| 269 | + high: Math.round(high / 100) * 100, | |
| 270 | + confidencePct: Math.round(score), | |
| 271 | + confidenceLevel: level, | |
| 272 | + modelEstimate: model, | |
| 273 | + compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null, | |
| 274 | + modelWeight, | |
| 275 | + comps, | |
| 276 | + nCompsUsed: comps.length, | |
| 277 | + compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null, | |
| 278 | + }; | |
| 279 | +} | |
added
src/lib/estimator.ts
+294 −0
@@ -0,0 +1,294 @@ | ||
| 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 | +} | |
| 33 | + | |
| 34 | +export interface UnitEstimate { | |
| 35 | + unit: { | |
| 36 | + id: string; | |
| 37 | + adresse: string | null; | |
| 38 | + municipalite: string | null; | |
| 39 | + typeProp: string; | |
| 40 | + cubfLibelle: string | null; | |
| 41 | + anneeConstruction: number | null; | |
| 42 | + aireEtagesM2: number | null; | |
| 43 | + superficieTerrainM2: number | null; | |
| 44 | + nbLogements: number | null; | |
| 45 | + valeurRole: number | null; | |
| 46 | + lat: number; | |
| 47 | + lng: number; | |
| 48 | + history: { year: number; value: number | null }[]; | |
| 49 | + specs: UnitSpecs; | |
| 50 | + } | null; | |
| 51 | + result: EstimateResult; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Type de comparable attendu pour le type de tx du marché de l'indice. */ | |
| 55 | +export function indexType(typeProp: string): string { | |
| 56 | + if (typeProp === "condo_ou_multi") return "condo"; | |
| 57 | + if (typeProp === "plex") return "plex"; | |
| 58 | + return "unifamilial"; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function toComps(rows: TxRow[]): CompInput[] { | |
| 62 | + return rows.map((r) => ({ | |
| 63 | + id: r.id, | |
| 64 | + date: r.date, | |
| 65 | + amount: r.amount, | |
| 66 | + lat: r.lat, | |
| 67 | + lng: r.lng, | |
| 68 | + propertyType: r.property_type, | |
| 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: u.type_prop, | |
| 102 | + floorArea: u.aire_etages_m2, | |
| 103 | + yearBuilt: u.annee_construction, | |
| 104 | + landArea: u.superficie_terrain_m2, | |
| 105 | + modelEstimate: u.est_2026, | |
| 106 | + modelP10: u.p10, | |
| 107 | + modelP90: u.p90, | |
| 108 | + }; | |
| 109 | + const result = estimate( | |
| 110 | + subject, | |
| 111 | + toComps(candidatesAround(u.lat, u.lng)), | |
| 112 | + getMarketIndex(indexType(u.type_prop)), | |
| 113 | + nowISO() | |
| 114 | + ); | |
| 115 | + return { | |
| 116 | + unit: { | |
| 117 | + id: u.id_provinc, | |
| 118 | + adresse: u.adresse, | |
| 119 | + municipalite: u.municipalite, | |
| 120 | + typeProp: u.type_prop, | |
| 121 | + cubfLibelle: u.cubf_libelle, | |
| 122 | + anneeConstruction: u.annee_construction, | |
| 123 | + aireEtagesM2: u.aire_etages_m2, | |
| 124 | + superficieTerrainM2: u.superficie_terrain_m2, | |
| 125 | + nbLogements: u.nb_logements, | |
| 126 | + valeurRole: u.valeur_role, | |
| 127 | + lat: u.lat, | |
| 128 | + lng: u.lng, | |
| 129 | + history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({ | |
| 130 | + year: y, | |
| 131 | + value: u[`est_${y}` as keyof UnitRow] as number | null, | |
| 132 | + })), | |
| 133 | + specs: { | |
| 134 | + cubf: u.cubf, | |
| 135 | + cubfLibelle: u.cubf_libelle, | |
| 136 | + anneeEstimee: u.annee_estimee, | |
| 137 | + frontTerrainM: u.front_terrain_m, | |
| 138 | + nbEtages: u.nb_etages, | |
| 139 | + nbLocauxNonResid: u.nb_locaux_non_resid, | |
| 140 | + nbChambresLocatives: u.nb_chambres_locatives, | |
| 141 | + lienPhysique: u.lien_physique, | |
| 142 | + genreConstruction: u.genre_construction, | |
| 143 | + matricule: u.matricule, | |
| 144 | + uniteVoisinage: u.unite_voisinage, | |
| 145 | + nAdresses: u.n_adresses, | |
| 146 | + datCondMarche: u.dat_cond_marche, | |
| 147 | + valeurTerrain: u.valeur_terrain, | |
| 148 | + valeurBatiment: u.valeur_batiment, | |
| 149 | + valeurAnterieure: u.valeur_anterieure, | |
| 150 | + arrond: u.arrond, | |
| 151 | + apt: u.apt, | |
| 152 | + }, | |
| 153 | + }, | |
| 154 | + result, | |
| 155 | + }; | |
| 156 | +} | |
| 157 | + | |
| 158 | +export interface PortfolioAggregates { | |
| 159 | + count: number; | |
| 160 | + totalEstimate: number; | |
| 161 | + totalLow: number; | |
| 162 | + totalHigh: number; | |
| 163 | + totalRole: number; | |
| 164 | + ecartRolePct: number | null; | |
| 165 | + confidencePct: number; // moyenne pondérée par valeur | |
| 166 | + confidenceLevel: "A" | "B" | "C" | "D"; | |
| 167 | + totalFloorArea: number; | |
| 168 | + totalLandArea: number; | |
| 169 | + totalDwellings: number; | |
| 170 | + avgYearBuilt: number | null; | |
| 171 | + municipalities: { name: string; count: number; total: number }[]; | |
| 172 | + types: { type: string; count: number; total: number }[]; | |
| 173 | + history: { year: number; total: number; nCovered: number }[]; | |
| 174 | + growthPct: number | null; // croissance du parc 2021→2026 (unités couvertes) | |
| 175 | +} | |
| 176 | + | |
| 177 | +export interface PortfolioResult { | |
| 178 | + items: UnitEstimate[]; | |
| 179 | + aggregates: PortfolioAggregates; | |
| 180 | +} | |
| 181 | + | |
| 182 | +export function estimatePortfolio(ids: string[]): PortfolioResult { | |
| 183 | + const items = ids | |
| 184 | + .slice(0, 40) | |
| 185 | + .map((id) => estimateByUnitId(id)) | |
| 186 | + .filter((x): x is UnitEstimate => x !== null && x.unit !== null); | |
| 187 | + | |
| 188 | + const val = (n: number | null | undefined) => n ?? 0; | |
| 189 | + const totalEstimate = items.reduce((s, i) => s + val(i.result.estimate), 0); | |
| 190 | + const totalRole = items.reduce((s, i) => s + val(i.unit!.valeurRole), 0); | |
| 191 | + const wConf = | |
| 192 | + totalEstimate > 0 | |
| 193 | + ? items.reduce((s, i) => s + i.result.confidencePct * val(i.result.estimate), 0) / | |
| 194 | + totalEstimate | |
| 195 | + : 0; | |
| 196 | + const level = wConf >= 75 ? "A" : wConf >= 60 ? "B" : wConf >= 45 ? "C" : "D"; | |
| 197 | + | |
| 198 | + const byKey = (key: (i: UnitEstimate) => string) => { | |
| 199 | + const m = new Map<string, { count: number; total: number }>(); | |
| 200 | + for (const i of items) { | |
| 201 | + const k = key(i); | |
| 202 | + const e = m.get(k) ?? { count: 0, total: 0 }; | |
| 203 | + e.count += 1; | |
| 204 | + e.total += val(i.result.estimate); | |
| 205 | + m.set(k, e); | |
| 206 | + } | |
| 207 | + return [...m.entries()] | |
| 208 | + .map(([name, v]) => ({ name, ...v })) | |
| 209 | + .sort((a, b) => b.total - a.total); | |
| 210 | + }; | |
| 211 | + | |
| 212 | + const years = [2021, 2022, 2023, 2024, 2025, 2026] as const; | |
| 213 | + const history = years.map((y) => { | |
| 214 | + let total = 0; | |
| 215 | + let n = 0; | |
| 216 | + for (const i of items) { | |
| 217 | + const h = i.unit!.history.find((hh) => hh.year === y); | |
| 218 | + if (h?.value != null) { | |
| 219 | + total += h.value; | |
| 220 | + n += 1; | |
| 221 | + } | |
| 222 | + } | |
| 223 | + return { year: y, total, nCovered: n }; | |
| 224 | + }); | |
| 225 | + // croissance : somme 2026 vs 2021 sur les unités couvertes aux deux bornes | |
| 226 | + let g21 = 0; | |
| 227 | + let g26 = 0; | |
| 228 | + for (const i of items) { | |
| 229 | + const a = i.unit!.history.find((h) => h.year === 2021)?.value; | |
| 230 | + const b = i.unit!.history.find((h) => h.year === 2026)?.value; | |
| 231 | + if (a != null && b != null) { | |
| 232 | + g21 += a; | |
| 233 | + g26 += b; | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + const yb = items | |
| 238 | + .map((i) => i.unit!.anneeConstruction) | |
| 239 | + .filter((v): v is number => v != null && v > 1600); | |
| 240 | + | |
| 241 | + return { | |
| 242 | + items, | |
| 243 | + aggregates: { | |
| 244 | + count: items.length, | |
| 245 | + totalEstimate, | |
| 246 | + totalLow: items.reduce((s, i) => s + val(i.result.low), 0), | |
| 247 | + totalHigh: items.reduce((s, i) => s + val(i.result.high), 0), | |
| 248 | + totalRole, | |
| 249 | + ecartRolePct: totalRole > 0 ? (totalEstimate / totalRole - 1) * 100 : null, | |
| 250 | + confidencePct: Math.round(wConf), | |
| 251 | + confidenceLevel: level, | |
| 252 | + totalFloorArea: Math.round(items.reduce((s, i) => s + val(i.unit!.aireEtagesM2), 0)), | |
| 253 | + totalLandArea: Math.round(items.reduce((s, i) => s + val(i.unit!.superficieTerrainM2), 0)), | |
| 254 | + totalDwellings: items.reduce((s, i) => s + val(i.unit!.nbLogements), 0), | |
| 255 | + avgYearBuilt: yb.length ? Math.round(yb.reduce((s, v) => s + v, 0) / yb.length) : null, | |
| 256 | + municipalities: byKey((i) => i.unit!.municipalite ?? "—"), | |
| 257 | + types: byKey((i) => i.unit!.typeProp).map(({ name, ...v }) => ({ type: name, ...v })), | |
| 258 | + history, | |
| 259 | + growthPct: g21 > 0 ? (g26 / g21 - 1) * 100 : null, | |
| 260 | + }, | |
| 261 | + }; | |
| 262 | +} | |
| 263 | + | |
| 264 | +export interface ManualParams { | |
| 265 | + municipality: string; | |
| 266 | + typeProp: string; | |
| 267 | + floorArea?: number; | |
| 268 | + yearBuilt?: number; | |
| 269 | + landArea?: number; | |
| 270 | +} | |
| 271 | + | |
| 272 | +export function estimateManual(p: ManualParams): UnitEstimate | null { | |
| 273 | + const center = municipalityCenter(p.municipality); | |
| 274 | + if (!center || !center.n) return null; | |
| 275 | + const subject: Subject = { | |
| 276 | + lat: center.lat, | |
| 277 | + lng: center.lng, | |
| 278 | + typeProp: p.typeProp, | |
| 279 | + floorArea: p.floorArea ?? null, | |
| 280 | + yearBuilt: p.yearBuilt ?? null, | |
| 281 | + landArea: p.landArea ?? null, | |
| 282 | + modelEstimate: null, | |
| 283 | + modelP10: null, | |
| 284 | + modelP90: null, | |
| 285 | + }; | |
| 286 | + const result = estimate( | |
| 287 | + subject, | |
| 288 | + toComps(candidatesAround(center.lat, center.lng)), | |
| 289 | + getMarketIndex(indexType(p.typeProp)), | |
| 290 | + nowISO() | |
| 291 | + ); | |
| 292 | + if (!Number.isFinite(result.estimate)) return null; | |
| 293 | + return { unit: null, result }; | |
| 294 | +} | |
added
src/lib/i18n.ts
+111 −0
@@ -0,0 +1,111 @@ | ||
| 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 propriété, en toute transparence", | |
| 7 | + sub: "Estimation basée sur 3,7 millions d'unités d'évaluation et 745 000 ventes réelles au Québec (2021-2026). Pas de boîte noire : on vous montre le calcul.", | |
| 8 | + searchPlaceholder: "Entrez une adresse (ex. 861 route Elgin, Saint-Pamphile)", | |
| 9 | + estimateBtn: "Estimer", | |
| 10 | + manualTitle: "Pas d'adresse ? Décrivez la propriété", | |
| 11 | + municipality: "Municipalité", | |
| 12 | + propertyType: "Type de propriété", | |
| 13 | + floorArea: "Superficie habitable (m²)", | |
| 14 | + yearBuilt: "Année de construction", | |
| 15 | + landArea: "Superficie du terrain (m²)", | |
| 16 | + estimatedValue: "Valeur estimée", | |
| 17 | + range: "Fourchette honnête", | |
| 18 | + confidence: "Indice de confiance", | |
| 19 | + whyTitle: "Pourquoi ce prix ? Le calcul, sans boîte noire", | |
| 20 | + modelPart: "Modèle hédonique (LightGBM, 690 000 ventes QC)", | |
| 21 | + compsPart: "Comparables ajustés (médiane pondérée)", | |
| 22 | + weightModel: "poids du modèle", | |
| 23 | + weightComps: "poids des comparables", | |
| 24 | + compsTitle: "Comparables utilisés", | |
| 25 | + adjTime: "Ajust. marché", | |
| 26 | + adjArea: "Ajust. superficie", | |
| 27 | + adjAge: "Ajust. âge", | |
| 28 | + adjusted: "Prix ajusté", | |
| 29 | + sold: "Vendu", | |
| 30 | + distance: "Distance", | |
| 31 | + weight: "Poids", | |
| 32 | + history: "Valeur estimée par année", | |
| 33 | + assessed: "Évaluation municipale (rôle 2026)", | |
| 34 | + leadTitle: "Recevoir le rapport détaillé (PDF)", | |
| 35 | + leadBtn: "M'envoyer le rapport", | |
| 36 | + leadOk: "Merci ! Votre rapport sera envoyé sous peu.", | |
| 37 | + emailPlaceholder: "votre@courriel.com", | |
| 38 | + disclaimer: "Estimation statistique à titre indicatif — ne remplace pas une évaluation professionnelle.", | |
| 39 | + unifamilial: "Unifamiliale", | |
| 40 | + plex: "Plex (2-5 logements)", | |
| 41 | + condo_ou_multi: "Condo / multirésidentiel", | |
| 42 | + chalet: "Chalet", | |
| 43 | + maison_mobile: "Maison mobile", | |
| 44 | + terrain: "Terrain", | |
| 45 | + autre: "Autre", | |
| 46 | + notFound: "Adresse introuvable", | |
| 47 | + dataNote: "Données : rôles d'évaluation foncière du Québec (MAMH) et transactions publiques.", | |
| 48 | + confA: "Très fiable", | |
| 49 | + confB: "Fiable", | |
| 50 | + confC: "Indicative", | |
| 51 | + confD: "Peu fiable — marché mince", | |
| 52 | + searchNoResult: "Aucun résultat — essayez « numéro rue municipalité »", | |
| 53 | + loading: "Calcul en cours…", | |
| 54 | + }, | |
| 55 | + en: { | |
| 56 | + tagline: "Your property's true value, fully transparent", | |
| 57 | + sub: "Estimates built on 3.7M assessment units and 745k real Québec sales (2021-2026). No black box: we show the math.", | |
| 58 | + searchPlaceholder: "Enter an address (e.g. 861 route Elgin, Saint-Pamphile)", | |
| 59 | + estimateBtn: "Estimate", | |
| 60 | + manualTitle: "No address? Describe the property", | |
| 61 | + municipality: "Municipality", | |
| 62 | + propertyType: "Property type", | |
| 63 | + floorArea: "Living area (m²)", | |
| 64 | + yearBuilt: "Year built", | |
| 65 | + landArea: "Lot area (m²)", | |
| 66 | + estimatedValue: "Estimated value", | |
| 67 | + range: "Honest range", | |
| 68 | + confidence: "Confidence index", | |
| 69 | + whyTitle: "Why this price? The math, no black box", | |
| 70 | + modelPart: "Hedonic model (LightGBM, 690k QC sales)", | |
| 71 | + compsPart: "Adjusted comparables (weighted median)", | |
| 72 | + weightModel: "model weight", | |
| 73 | + weightComps: "comparables weight", | |
| 74 | + compsTitle: "Comparables used", | |
| 75 | + adjTime: "Market adj.", | |
| 76 | + adjArea: "Area adj.", | |
| 77 | + adjAge: "Age adj.", | |
| 78 | + adjusted: "Adjusted price", | |
| 79 | + sold: "Sold", | |
| 80 | + distance: "Distance", | |
| 81 | + weight: "Weight", | |
| 82 | + history: "Estimated value by year", | |
| 83 | + assessed: "Municipal assessment (2026 roll)", | |
| 84 | + leadTitle: "Get the detailed report (PDF)", | |
| 85 | + leadBtn: "Send me the report", | |
| 86 | + leadOk: "Thanks! Your report is on its way.", | |
| 87 | + emailPlaceholder: "you@email.com", | |
| 88 | + disclaimer: "Statistical estimate for information only — not a substitute for a professional appraisal.", | |
| 89 | + unifamilial: "Single-family", | |
| 90 | + plex: "Plex (2-5 units)", | |
| 91 | + condo_ou_multi: "Condo / multi-residential", | |
| 92 | + chalet: "Cottage", | |
| 93 | + maison_mobile: "Mobile home", | |
| 94 | + terrain: "Land", | |
| 95 | + autre: "Other", | |
| 96 | + notFound: "Address not found", | |
| 97 | + dataNote: "Data: Québec property assessment rolls (MAMH) and public transactions.", | |
| 98 | + confA: "Very reliable", | |
| 99 | + confB: "Reliable", | |
| 100 | + confC: "Indicative", | |
| 101 | + confD: "Low reliability — thin market", | |
| 102 | + searchNoResult: "No result — try “number street municipality”", | |
| 103 | + loading: "Computing…", | |
| 104 | + }, | |
| 105 | +} as const; | |
| 106 | + | |
| 107 | +export type TKey = keyof (typeof dict)["fr"]; | |
| 108 | + | |
| 109 | +export function t(lang: Lang, key: TKey): string { | |
| 110 | + return dict[lang][key] ?? dict.fr[key]; | |
| 111 | +} | |
added
src/lib/report-portfolio.ts
+303 −0
@@ -0,0 +1,303 @@ | ||
| 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é Vrai-Prix. | |
| 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 = "#9e2a25"; | |
| 16 | +const LIME = "#ff5148"; | |
| 17 | +const LIME_SOFT = "#ffe3e0"; | |
| 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("Vrai", x, y, { lineBreak: false }); | |
| 43 | + const w = doc.widthOfString("Vrai"); | |
| 44 | + const bx = x + w + 4 * scale; | |
| 45 | + doc.save(); | |
| 46 | + doc.rotate(-3, { origin: [bx, y + 12 * scale] }); | |
| 47 | + const bw = doc.widthOfString("Prix") + 12 * scale; | |
| 48 | + doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME); | |
| 49 | + doc.fillColor(INK).text("Prix", 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 | + "VRAI-PRIX.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,81,72,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 | + y = doc.y + 6; | |
| 134 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE", M, y, { lineBreak: false }); | |
| 135 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M, y + 10, { lineBreak: false }); | |
| 136 | + const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} %`; | |
| 137 | + doc.font("JB-Bold").fontSize(7); | |
| 138 | + const cw = doc.widthOfString(confTxt) + 16; | |
| 139 | + doc.roundedRect(M + 220, y + 4, cw, 16, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK); | |
| 140 | + doc.fillColor(INK).text(confTxt, M + 228, y + 8.5, { lineBreak: false }); | |
| 141 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("RÔLE 2026", M + 380, y, { lineBreak: false }); | |
| 142 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(fmt(u.valeurRole), M + 380, y + 10, { lineBreak: false }); | |
| 143 | + y += 34; | |
| 144 | + | |
| 145 | + // fiche compacte | |
| 146 | + const rows: [string, string][] = [ | |
| 147 | + ["Usage", s.cubfLibelle ?? "—"], | |
| 148 | + ["Année constr.", u.anneeConstruction ? String(u.anneeConstruction) : "—"], | |
| 149 | + ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"], | |
| 150 | + ["Terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"], | |
| 151 | + ["Frontage", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"], | |
| 152 | + ["Étages", s.nbEtages != null ? String(s.nbEtages) : "—"], | |
| 153 | + ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"], | |
| 154 | + ["Genre", s.genreConstruction ?? "—"], | |
| 155 | + ["Lien phys.", s.lienPhysique ?? "—"], | |
| 156 | + ["Val. terrain", fmt(s.valeurTerrain)], | |
| 157 | + ["Val. bâtiment", fmt(s.valeurBatiment)], | |
| 158 | + ["Matricule", s.matricule ?? "—"], | |
| 159 | + ]; | |
| 160 | + kicker(doc, "Fiche du registre", M, y); | |
| 161 | + y += 14; | |
| 162 | + const cw2 = (W - 2 * M - 3 * 8) / 4; | |
| 163 | + rows.forEach(([k, v], i) => { | |
| 164 | + kvCell(doc, M + (i % 4) * (cw2 + 8), y + Math.floor(i / 4) * 36, cw2, 30, k, v); | |
| 165 | + }); | |
| 166 | + y += 3 * 36 + 14; | |
| 167 | + | |
| 168 | + // historique | |
| 169 | + kicker(doc, "Valeur estimée 2021 → 2026", M, y); | |
| 170 | + y += 16; | |
| 171 | + card(doc, M, y, W - 2 * M, u.history.length * 19 + 20); | |
| 172 | + hbars(doc, M + 14, y + 12, W - 2 * M - 28, | |
| 173 | + u.history.map((h) => [String(h.year), h.value ?? 0, fmt(h.value)])); | |
| 174 | + y += u.history.length * 19 + 34; | |
| 175 | + | |
| 176 | + // top comparables | |
| 177 | + kicker(doc, `Meilleurs comparables (${Math.min(5, r.comps.length)})`, M, y); | |
| 178 | + y += 14; | |
| 179 | + r.comps.slice(0, 5).forEach((c, i) => { | |
| 180 | + const ly = y + i * 17; | |
| 181 | + if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2); | |
| 182 | + doc.font("SG-Bold").fontSize(7.5).fillColor(INK) | |
| 183 | + .text(`${i + 1}. ${c.street ?? ""}, ${c.city ?? ""}`, M + 6, ly, { width: 300, height: 9, ellipsis: true, lineBreak: false }); | |
| 184 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 185 | + .text(`${c.date} · ${c.distanceM < 1000 ? Math.round(c.distanceM) + " m" : (c.distanceM / 1000).toFixed(1) + " km"}`, | |
| 186 | + M + 316, ly + 1, { lineBreak: false }); | |
| 187 | + doc.font("SG-Bold").fontSize(8).fillColor(INK) | |
| 188 | + .text(fmt(Math.round(c.adjustedPrice)), W - M - 90, ly, { width: 84, align: "right", lineBreak: false }); | |
| 189 | + }); | |
| 190 | +} | |
| 191 | + | |
| 192 | +export async function buildPortfolioReport(pf: PortfolioResult): Promise<Buffer> { | |
| 193 | + const { items, aggregates: a } = pf; | |
| 194 | + const generated = new Date().toISOString().slice(0, 16).replace("T", " "); | |
| 195 | + const pageTotal = 1 + items.length; | |
| 196 | + | |
| 197 | + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "Vrai-Prix — Rapport de parc immobilier" } }); | |
| 198 | + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); | |
| 199 | + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf")); | |
| 200 | + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); | |
| 201 | + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); | |
| 202 | + doc.registerFont("Inter", F("Inter-Regular.ttf")); | |
| 203 | + | |
| 204 | + const chunks: Buffer[] = []; | |
| 205 | + doc.on("data", (c: Buffer) => chunks.push(c)); | |
| 206 | + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); | |
| 207 | + | |
| 208 | + /* ------------------------------ COUVERTURE ------------------------------ */ | |
| 209 | + pageFrame(doc, 1, pageTotal, generated); | |
| 210 | + doc.rect(0, 0, W, 110).fill(INK); | |
| 211 | + logo(doc, M, 32); | |
| 212 | + doc.font("JB-Reg").fontSize(7).fillColor(LIME) | |
| 213 | + .text("RAPPORT DE PARC IMMOBILIER · QUÉBEC", M, 70, { characterSpacing: 1.6, lineBreak: false }); | |
| 214 | + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") | |
| 215 | + .text(`${a.count} PROPRIÉTÉS`, W - M - 200, 38, { width: 200, align: "right" }) | |
| 216 | + .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 50, { width: 200, align: "right" }); | |
| 217 | + doc.rect(0, 110, W, 3).fill(LIME); | |
| 218 | + | |
| 219 | + let y = 134; | |
| 220 | + kicker(doc, "Valeur totale estimée du parc", M, y); | |
| 221 | + y += 16; | |
| 222 | + doc.font("SG-Bold").fontSize(44).fillColor(INK).text(fmt(a.totalEstimate), M, y); | |
| 223 | + y = doc.y + 8; | |
| 224 | + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("FOURCHETTE CUMULÉE", M, y, { lineBreak: false }); | |
| 225 | + doc.font("SG-Bold").fontSize(12).fillColor(INK).text(`${fmt(a.totalLow)} → ${fmt(a.totalHigh)}`, M, y + 10, { lineBreak: false }); | |
| 226 | + const confTxt = `CONFIANCE PONDÉRÉE ${a.confidenceLevel} · ${a.confidencePct} %`; | |
| 227 | + doc.font("JB-Bold").fontSize(7.5); | |
| 228 | + const cw = doc.widthOfString(confTxt) + 20; | |
| 229 | + doc.roundedRect(M + 260, y + 2, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[a.confidenceLevel], INK); | |
| 230 | + doc.fillColor(INK).text(confTxt, M + 270, y + 7.5, { lineBreak: false }); | |
| 231 | + y += 38; | |
| 232 | + | |
| 233 | + // tuiles | |
| 234 | + const tiles: [string, string, boolean][] = [ | |
| 235 | + ["Propriétés", String(a.count), true], | |
| 236 | + ["Évaluation municipale", fmt(a.totalRole), false], | |
| 237 | + ["Écart vs rôle", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false], | |
| 238 | + ["Croissance 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true], | |
| 239 | + ["Aire habitable totale", `${a.totalFloorArea.toLocaleString("fr-CA")} m²`, false], | |
| 240 | + ["Terrain total", `${a.totalLandArea.toLocaleString("fr-CA")} m²`, false], | |
| 241 | + ["Logements", String(a.totalDwellings), false], | |
| 242 | + ["Année moyenne", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false], | |
| 243 | + ]; | |
| 244 | + const tw = (W - 2 * M - 3 * 10) / 4; | |
| 245 | + tiles.forEach(([k, v, hero], i) => { | |
| 246 | + tile(doc, M + (i % 4) * (tw + 10), y + Math.floor(i / 4) * 58, tw, 50, k, v, hero); | |
| 247 | + }); | |
| 248 | + y += 2 * 58 + 12; | |
| 249 | + | |
| 250 | + // historique du parc | |
| 251 | + kicker(doc, "Valeur du parc par année (unités couvertes)", M, y); | |
| 252 | + y += 14; | |
| 253 | + card(doc, M, y, (W - 2 * M - 12) * 0.58, 6 * 19 + 20); | |
| 254 | + hbars(doc, M + 12, y + 12, (W - 2 * M - 12) * 0.58 - 24, | |
| 255 | + a.history.map((h) => [String(h.year), h.total, fmt(h.total)])); | |
| 256 | + | |
| 257 | + // répartitions | |
| 258 | + const rx = M + (W - 2 * M - 12) * 0.58 + 12; | |
| 259 | + const rw = W - M - rx; | |
| 260 | + card(doc, rx, y, rw, 6 * 19 + 20); | |
| 261 | + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR MUNICIPALITÉ", rx + 12, y + 10, { characterSpacing: 0.8 }); | |
| 262 | + a.municipalities.slice(0, 3).forEach((m, i) => { | |
| 263 | + const ly = y + 22 + i * 15; | |
| 264 | + doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(m.name, rx + 12, ly, { width: rw - 100, height: 9, ellipsis: true, lineBreak: false }); | |
| 265 | + 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 }); | |
| 266 | + }); | |
| 267 | + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR TYPE", rx + 12, y + 72, { characterSpacing: 0.8 }); | |
| 268 | + a.types.slice(0, 3).forEach((tp, i) => { | |
| 269 | + const ly = y + 84 + i * 15; | |
| 270 | + 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 }); | |
| 271 | + 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 }); | |
| 272 | + }); | |
| 273 | + y += 6 * 19 + 34; | |
| 274 | + | |
| 275 | + // table des propriétés | |
| 276 | + kicker(doc, "Sommaire des propriétés", M, y); | |
| 277 | + y += 14; | |
| 278 | + items.slice(0, 14).forEach((it, i) => { | |
| 279 | + const u = it.unit!; | |
| 280 | + const ly = y + i * 17; | |
| 281 | + if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2); | |
| 282 | + doc.circle(M + 8, ly + 5, 6).lineWidth(0.9).fillAndStroke(GREEN, INK); | |
| 283 | + doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), M + 2, ly + 2.5, { width: 12, align: "center", lineBreak: false }); | |
| 284 | + doc.font("SG-Bold").fontSize(7.5).fillColor(INK) | |
| 285 | + .text(`${u.adresse ?? ""} · ${u.municipalite ?? ""}`, M + 20, ly, { width: 280, height: 9, ellipsis: true, lineBreak: false }); | |
| 286 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 287 | + .text(`CONF. ${it.result.confidenceLevel}`, M + 310, ly + 1, { lineBreak: false }); | |
| 288 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 289 | + .text(`RÔLE ${fmt(u.valeurRole)}`, M + 360, ly + 1, { width: 90, lineBreak: false }); | |
| 290 | + doc.font("SG-Bold").fontSize(8).fillColor(INK) | |
| 291 | + .text(fmt(it.result.estimate), W - M - 80, ly, { width: 76, align: "right", lineBreak: false }); | |
| 292 | + }); | |
| 293 | + if (items.length > 14) { | |
| 294 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 295 | + .text(`+ ${items.length - 14} autres propriétés — voir pages suivantes`, M, y + 14 * 17 + 2); | |
| 296 | + } | |
| 297 | + | |
| 298 | + /* --------------------------- PAGES PROPRIÉTÉS --------------------------- */ | |
| 299 | + items.forEach((it, i) => propertyPage(doc, it, i, items.length, i + 2, pageTotal, generated)); | |
| 300 | + | |
| 301 | + doc.end(); | |
| 302 | + return done; | |
| 303 | +} | |
added
src/lib/report-pro.ts
+423 −0
@@ -0,0 +1,423 @@ | ||
| 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 | + | |
| 14 | +const INK = "#141814"; | |
| 15 | +const INK2 = "#4d5551"; | |
| 16 | +const INK3 = "#8b928c"; | |
| 17 | +const SURFACE2 = "#faf9f5"; | |
| 18 | +const GREEN = "#9e2a25"; | |
| 19 | +const GREEN_DEEP = "#771f1b"; | |
| 20 | +const LIME = "#ff5148"; | |
| 21 | + | |
| 22 | +const W = 612; | |
| 23 | +const H = 792; | |
| 24 | +const M = 56; | |
| 25 | +const CW = W - 2 * M; | |
| 26 | + | |
| 27 | +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); | |
| 28 | +const fmt = (v: number | null | undefined) => | |
| 29 | + v == null | |
| 30 | + ? "—" | |
| 31 | + : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); | |
| 32 | +const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`; | |
| 33 | +const CONF_TXT: Record<string, string> = { | |
| 34 | + A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince", | |
| 35 | +}; | |
| 36 | + | |
| 37 | +type Doc = InstanceType<typeof PDFDocument>; | |
| 38 | + | |
| 39 | +interface Ctx { | |
| 40 | + doc: Doc; | |
| 41 | + page: number; | |
| 42 | + dossier: string; | |
| 43 | + generated: string; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function header(c: Ctx) { | |
| 47 | + const { doc } = c; | |
| 48 | + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Vrai", M, 26, { lineBreak: false }); | |
| 49 | + const w = doc.widthOfString("Vrai"); | |
| 50 | + doc.rect(M + w + 2, 25, doc.widthOfString("Prix") + 6, 13).fill(INK); | |
| 51 | + doc.fillColor(LIME).text("Prix", M + w + 5, 26, { lineBreak: false }); | |
| 52 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 53 | + .text("RAPPORT D'ÉVALUATION DÉTAILLÉ — FORMAT PROFESSIONNEL", M + 70, 29, { characterSpacing: 0.8, lineBreak: false }); | |
| 54 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 55 | + .text(`DOSSIER ${c.dossier}`, W - M - 160, 29, { width: 160, align: "right" }); | |
| 56 | + doc.rect(M, 44, CW, 0.8).fill(INK); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function footer(c: Ctx, total: number) { | |
| 60 | + const { doc } = c; | |
| 61 | + doc.rect(M, H - 52, CW, 0.8).fill(INK); | |
| 62 | + doc.font("JB-Reg").fontSize(6).fillColor(INK3).text( | |
| 63 | + "ESTIMATION STATISTIQUE AUTOMATISÉE (AVM) — NE CONSTITUE PAS UN RAPPORT D'ÉVALUATION AU SENS DES NORMES DE L'OEAQ · VRAI-PRIX.COM · CONTACT@SPBOUCHER.AI", | |
| 64 | + M, H - 44, { width: CW - 50, characterSpacing: 0.3, lineBreak: false }); | |
| 65 | + doc.font("JB-Bold").fontSize(7).fillColor(INK) | |
| 66 | + .text(`${c.page} / ${total}`, W - M - 40, H - 45, { width: 40, align: "right" }); | |
| 67 | +} | |
| 68 | + | |
| 69 | +function newPage(c: Ctx, total: number) { | |
| 70 | + c.doc.addPage({ size: "LETTER", margin: 0 }); | |
| 71 | + c.page += 1; | |
| 72 | + c.doc.rect(0, 0, W, H).fill("#ffffff"); | |
| 73 | + header(c); | |
| 74 | + footer(c, total); | |
| 75 | + return 62; // y de départ | |
| 76 | +} | |
| 77 | + | |
| 78 | +function sectionTitle(c: Ctx, num: string, title: string, y: number): number { | |
| 79 | + const { doc } = c; | |
| 80 | + doc.font("JB-Bold").fontSize(8).fillColor(GREEN) | |
| 81 | + .text(num, M, y + 2, { lineBreak: false }); | |
| 82 | + doc.font("SG-Bold").fontSize(13).fillColor(INK) | |
| 83 | + .text(title.toUpperCase(), M + 30, y, { lineBreak: false }); | |
| 84 | + doc.rect(M, y + 19, CW, 1.4).fill(INK); | |
| 85 | + doc.rect(M, y + 21.5, 60, 2.2).fill(LIME); | |
| 86 | + return y + 32; | |
| 87 | +} | |
| 88 | + | |
| 89 | +function para(c: Ctx, txt: string, y: number, opts?: { size?: number; color?: string; width?: number }): number { | |
| 90 | + const { doc } = c; | |
| 91 | + doc.font("Inter").fontSize(opts?.size ?? 8.6).fillColor(opts?.color ?? INK2) | |
| 92 | + .text(txt, M, y, { width: opts?.width ?? CW, lineGap: 2.2, align: "justify" }); | |
| 93 | + return doc.y + 8; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function kvTable(c: Ctx, rows: [string, string][], y: number, colW = CW): number { | |
| 97 | + const { doc } = c; | |
| 98 | + const rh = 17; | |
| 99 | + rows.forEach(([k, v], i) => { | |
| 100 | + const ry = y + i * rh; | |
| 101 | + if (i % 2 === 0) doc.rect(M, ry, colW, rh).fill(SURFACE2); | |
| 102 | + doc.font("JB-Bold").fontSize(6.6).fillColor(INK3) | |
| 103 | + .text(k.toUpperCase(), M + 8, ry + 5, { characterSpacing: 0.5, lineBreak: false }); | |
| 104 | + doc.font("SG-Bold").fontSize(8.6).fillColor(INK) | |
| 105 | + .text(v, M + colW * 0.42, ry + 4, { width: colW * 0.58 - 10, height: 11, ellipsis: true }); | |
| 106 | + }); | |
| 107 | + doc.rect(M, y, colW, rows.length * rh).lineWidth(0.9).stroke(INK); | |
| 108 | + return y + rows.length * rh + 10; | |
| 109 | +} | |
| 110 | + | |
| 111 | +export async function buildProReport( | |
| 112 | + data: UnitEstimate, | |
| 113 | + marketIndex: { month: string; idx: number }[] | |
| 114 | +): Promise<Buffer> { | |
| 115 | + const u = data.unit!; | |
| 116 | + const s = u.specs; | |
| 117 | + const r = data.result; | |
| 118 | + const generated = new Date().toISOString().slice(0, 10); | |
| 119 | + const dossier = `VP-${u.id.slice(0, 8)}-${generated.replaceAll("-", "")}`; | |
| 120 | + const TOTAL = 6; | |
| 121 | + | |
| 122 | + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `Rapport d'évaluation détaillé — ${u.adresse ?? ""}` } }); | |
| 123 | + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); | |
| 124 | + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf")); | |
| 125 | + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); | |
| 126 | + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); | |
| 127 | + doc.registerFont("Inter", F("Inter-Regular.ttf")); | |
| 128 | + | |
| 129 | + const chunks: Buffer[] = []; | |
| 130 | + doc.on("data", (ch: Buffer) => chunks.push(ch)); | |
| 131 | + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); | |
| 132 | + const c: Ctx = { doc, page: 1, dossier, generated }; | |
| 133 | + | |
| 134 | + /* ------------------------------- PAGE TITRE ------------------------------- */ | |
| 135 | + doc.rect(0, 0, W, H).fill("#ffffff"); | |
| 136 | + doc.rect(0, 0, W, 6).fill(INK); | |
| 137 | + doc.rect(0, 6, W, 2).fill(LIME); | |
| 138 | + // logo sobre | |
| 139 | + doc.font("SG-Bold").fontSize(26).fillColor(INK).text("Vrai", M, 70, { lineBreak: false }); | |
| 140 | + const lw = doc.widthOfString("Vrai"); | |
| 141 | + doc.rect(M + lw + 4, 68, doc.widthOfString("Prix") + 14, 32).fill(INK); | |
| 142 | + doc.fillColor(LIME).text("Prix", M + lw + 11, 70, { lineBreak: false }); | |
| 143 | + doc.font("JB-Reg").fontSize(7.5).fillColor(INK3) | |
| 144 | + .text("MOTEUR D'ÉVALUATION IMMOBILIÈRE STATISTIQUE · QUÉBEC", M, 108, { characterSpacing: 1.2 }); | |
| 145 | + | |
| 146 | + doc.rect(M, 168, CW, 1.4).fill(INK); | |
| 147 | + doc.font("SG-Bold").fontSize(30).fillColor(INK) | |
| 148 | + .text("RAPPORT D'ÉVALUATION\nDÉTAILLÉ", M, 188, { lineGap: 4 }); | |
| 149 | + doc.font("Inter").fontSize(10.5).fillColor(INK2) | |
| 150 | + .text("Approche par comparaison directe et modélisation hédonique de masse", M, 262); | |
| 151 | + doc.rect(M, 288, CW, 1.4).fill(INK); | |
| 152 | + | |
| 153 | + // bloc propriété | |
| 154 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK3).text("PROPRIÉTÉ ÉVALUÉE", M, 318, { characterSpacing: 1.2 }); | |
| 155 | + doc.font("SG-Bold").fontSize(17).fillColor(INK) | |
| 156 | + .text(`${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`, M, 332); | |
| 157 | + doc.font("SG-Med").fontSize(12).fillColor(INK2) | |
| 158 | + .text(`${u.municipalite ?? ""} (Québec) · Matricule ${s.matricule ?? "—"}`, M, doc.y + 2); | |
| 159 | + | |
| 160 | + // encadré conclusion | |
| 161 | + const by = 420; | |
| 162 | + doc.roundedRect(M, by, CW, 128, 6).lineWidth(1.6).fillAndStroke(SURFACE2, INK); | |
| 163 | + doc.font("JB-Bold").fontSize(7.5).fillColor(GREEN_DEEP) | |
| 164 | + .text("CONCLUSION DE LA VALEUR MARCHANDE ESTIMÉE", M + 20, by + 16, { characterSpacing: 1.2 }); | |
| 165 | + doc.font("SG-Bold").fontSize(38).fillColor(INK).text(fmt(r.estimate), M + 20, by + 32); | |
| 166 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE (P10-P90)", M + 20, by + 84, { lineBreak: false }); | |
| 167 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M + 20, by + 96, { lineBreak: false }); | |
| 168 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("DATE D'EFFET", M + 260, by + 84, { lineBreak: false }); | |
| 169 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(generated, M + 260, by + 96, { lineBreak: false }); | |
| 170 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FIABILITÉ", M + 380, by + 84, { lineBreak: false }); | |
| 171 | + doc.font("SG-Bold").fontSize(11).fillColor(INK) | |
| 172 | + .text(`${r.confidenceLevel} · ${r.confidencePct} %`, M + 380, by + 96, { lineBreak: false }); | |
| 173 | + | |
| 174 | + // préparé pour / par | |
| 175 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ POUR", M, 585, { characterSpacing: 1 }); | |
| 176 | + doc.rect(M, 610, 220, 0.9).fill(INK2); | |
| 177 | + doc.font("Inter").fontSize(7).fillColor(INK3).text("(destinataire / institution financière)", M, 615); | |
| 178 | + doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ PAR", M + 300, 585, { characterSpacing: 1 }); | |
| 179 | + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Vrai-Prix — moteur d'évaluation statistique", M + 300, 597); | |
| 180 | + doc.font("Inter").fontSize(8).fillColor(INK2).text("Simon-Pierre Boucher · contact@spboucher.ai", M + 300, 611); | |
| 181 | + doc.font("Inter").fontSize(8).fillColor(INK2).text(`Dossier ${dossier} · Généré le ${generated}`, M + 300, 623); | |
| 182 | + | |
| 183 | + // avis | |
| 184 | + doc.roundedRect(M, 668, CW, 54, 5).lineWidth(1).stroke(INK); | |
| 185 | + doc.font("JB-Bold").fontSize(6.5).fillColor(INK).text("AVIS IMPORTANT", M + 12, 678, { characterSpacing: 1 }); | |
| 186 | + doc.font("Inter").fontSize(7.4).fillColor(INK2).text( | |
| 187 | + "Ce rapport est produit automatiquement par un modèle d'évaluation de masse (AVM). Il est structuré selon les usages de la " + | |
| 188 | + "pratique de l'évaluation afin d'en faciliter la lecture, mais il ne constitue pas un rapport d'évaluation préparé par un " + | |
| 189 | + "évaluateur agréé (OEAQ) et aucune inspection de la propriété n'a été effectuée. Voir les hypothèses et conditions limitatives, section 7.", | |
| 190 | + M + 12, 689, { width: CW - 24, lineGap: 1.5 }); | |
| 191 | + footer(c, TOTAL); | |
| 192 | + | |
| 193 | + /* ------------------------- PAGE 2 : SOMMAIRE EXÉCUTIF ------------------------- */ | |
| 194 | + let y = newPage(c, TOTAL); | |
| 195 | + y = sectionTitle(c, "1.", "Sommaire exécutif", y); | |
| 196 | + y = kvTable(c, [ | |
| 197 | + ["Adresse", `${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}, ${u.municipalite ?? ""}`], | |
| 198 | + ["Identifiant provincial", u.id], | |
| 199 | + ["Matricule", s.matricule ?? "—"], | |
| 200 | + ["Usage prédominant (CUBF)", `${s.cubf ?? "—"} — ${s.cubfLibelle ?? "—"}`], | |
| 201 | + ["Droit évalué (présumé)", "Pleine propriété, libre de toute charge"], | |
| 202 | + ["Date d'effet de l'estimation", generated], | |
| 203 | + ["Méthodes utilisées", "Comparaison directe (ajustée) et modèle hédonique de masse"], | |
| 204 | + ["Valeur estimée", fmt(r.estimate)], | |
| 205 | + ["Fourchette P10-P90", `${fmt(r.low)} → ${fmt(r.high)}`], | |
| 206 | + ["Indice de fiabilité", `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}`], | |
| 207 | + ["Évaluation municipale (rôle 2026)", fmt(u.valeurRole)], | |
| 208 | + ["Écart estimation / rôle", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(1)} %` : "—"], | |
| 209 | + ], y); | |
| 210 | + y = para(c, | |
| 211 | + "La valeur conclue résulte de la réconciliation pondérée de deux approches indépendantes : un modèle hédonique de " + | |
| 212 | + `masse entraîné sur environ 690 000 transactions québécoises (pondération ${(r.modelWeight * 100).toFixed(0)} %) et l'analyse de ` + | |
| 213 | + `${r.nCompsUsed} ventes comparables récentes situées à proximité du sujet, individuellement ajustées pour les conditions de ` + | |
| 214 | + `marché, la superficie habitable et l'âge (pondération ${((1 - r.modelWeight) * 100).toFixed(0)} %). La dispersion des comparables ` + | |
| 215 | + `ajustés est de ±${r.compsDispersionPct ?? "—"} %. L'indice de fiabilité intègre le nombre de comparables, leur cohérence et la ` + | |
| 216 | + "largeur de la fourchette d'incertitude du modèle.", y); | |
| 217 | + | |
| 218 | + y = sectionTitle(c, "2.", "Mandat, portée et définition de la valeur", y + 4); | |
| 219 | + y = para(c, | |
| 220 | + "Mandat — Estimer la valeur marchande la plus probable de la propriété identifiée ci-dessus, à la date d'effet, aux fins " + | |
| 221 | + "d'information et d'appui à une démarche de financement. Portée — Analyse automatisée des données publiques du rôle " + | |
| 222 | + "d'évaluation foncière (MAMH) et des transactions publiées ; aucune inspection, aucune vérification de titres.", y); | |
| 223 | + y = para(c, | |
| 224 | + "Définition de la valeur marchande — Le prix le plus probable, à la date d'effet, moyennant paiement comptant ou " + | |
| 225 | + "conditions équivalentes, pour lequel la propriété devrait se vendre sur un marché concurrentiel et ouvert, dans le cadre " + | |
| 226 | + "d'une transaction sans lien de dépendance entre un acheteur et un vendeur prudents et informés, aucune des parties " + | |
| 227 | + "n'étant contrainte d'agir et un délai de mise en marché raisonnable étant accordé.", y); | |
| 228 | + y = para(c, | |
| 229 | + "Approches écartées — L'approche du coût n'est pas appliquée (absence de données fiables sur le coût de remplacement " + | |
| 230 | + "déprécié sans inspection). L'approche du revenu n'est pas appliquée (propriété à vocation principalement résidentielle ; " + | |
| 231 | + "données de loyers non disponibles dans les sources publiques utilisées).", y); | |
| 232 | + | |
| 233 | + /* --------------------- PAGE 3 : DESCRIPTION DE LA PROPRIÉTÉ --------------------- */ | |
| 234 | + y = newPage(c, TOTAL); | |
| 235 | + y = sectionTitle(c, "3.", "Description de la propriété", y); | |
| 236 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.1 Bâtiment", M, y); y += 16; | |
| 237 | + y = kvTable(c, [ | |
| 238 | + ["Année de construction", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (estimée au rôle)" : " (réelle)"}` : "—"], | |
| 239 | + ["Aire d'étages (habitable)", u.aireEtagesM2 ? `${u.aireEtagesM2} m² (${Math.round(u.aireEtagesM2 * 10.7639)} pi²)` : "—"], | |
| 240 | + ["Nombre d'étages", s.nbEtages != null ? String(s.nbEtages) : "—"], | |
| 241 | + ["Genre de construction", s.genreConstruction ?? "—"], | |
| 242 | + ["Lien physique", s.lienPhysique ?? "—"], | |
| 243 | + ["Nombre de logements", u.nbLogements != null ? String(u.nbLogements) : "—"], | |
| 244 | + ["Locaux non résidentiels", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"], | |
| 245 | + ], y); | |
| 246 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.2 Terrain", M, y); y += 16; | |
| 247 | + y = kvTable(c, [ | |
| 248 | + ["Superficie du terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m² (${Math.round(u.superficieTerrainM2 * 10.7639)} pi²)` : "—"], | |
| 249 | + ["Mesure frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"], | |
| 250 | + ["Unité de voisinage (rôle)", s.uniteVoisinage || "—"], | |
| 251 | + ["Arrondissement", s.arrond || "—"], | |
| 252 | + ], y); | |
| 253 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.3 Données du rôle d'évaluation", M, y); y += 16; | |
| 254 | + y = kvTable(c, [ | |
| 255 | + ["Valeur du terrain (rôle 2026)", fmt(s.valeurTerrain)], | |
| 256 | + ["Valeur du bâtiment (rôle 2026)", fmt(s.valeurBatiment)], | |
| 257 | + ["Valeur totale (rôle 2026)", fmt(u.valeurRole)], | |
| 258 | + ["Valeur au rôle antérieur", fmt(s.valeurAnterieure)], | |
| 259 | + ["Conditions du marché considérées au", s.datCondMarche ?? "—"], | |
| 260 | + ], y); | |
| 261 | + y = para(c, | |
| 262 | + "Les caractéristiques ci-dessus proviennent du rôle d'évaluation foncière en vigueur (source : MAMH, données ouvertes). " + | |
| 263 | + "Elles n'ont pas fait l'objet d'une vérification sur place.", y); | |
| 264 | + | |
| 265 | + /* --------------------- PAGE 4 : GRILLE DES COMPARABLES --------------------- */ | |
| 266 | + y = newPage(c, TOTAL); | |
| 267 | + y = sectionTitle(c, "4.", "Approche par comparaison — grille d'ajustement", y); | |
| 268 | + const comps = r.comps.slice(0, 4); | |
| 269 | + const colW = (CW - 118) / comps.length; | |
| 270 | + const rowsG: [string, (ci: number) => string][] = [ | |
| 271 | + ["Adresse", (i) => `${comps[i].street ?? ""}`], | |
| 272 | + ["Municipalité", (i) => comps[i].city ?? "—"], | |
| 273 | + ["Date de vente", (i) => comps[i].date], | |
| 274 | + ["Prix de vente", (i) => fmt(comps[i].amount)], | |
| 275 | + ["Distance du sujet", (i) => comps[i].distanceM < 1000 ? `${Math.round(comps[i].distanceM)} m` : `${(comps[i].distanceM / 1000).toFixed(1)} km`], | |
| 276 | + ["Aire d'étages", (i) => comps[i].floorArea ? `${comps[i].floorArea} m²` : "—"], | |
| 277 | + ["Année constr.", (i) => comps[i].yearBuilt ? String(comps[i].yearBuilt) : "—"], | |
| 278 | + ["Ajust. conditions de marché", (i) => signed(comps[i].adjTime)], | |
| 279 | + ["Ajust. superficie", (i) => signed(comps[i].adjArea)], | |
| 280 | + ["Ajust. âge", (i) => signed(comps[i].adjAge)], | |
| 281 | + ["PRIX AJUSTÉ", (i) => fmt(Math.round(comps[i].adjustedPrice))], | |
| 282 | + ["Pondération", (i) => `${(comps[i].weight * 100).toFixed(0)} %`], | |
| 283 | + ]; | |
| 284 | + const rh = 20; | |
| 285 | + // en-têtes colonnes | |
| 286 | + doc.rect(M, y, 118, rh).fill(INK); | |
| 287 | + doc.font("JB-Bold").fontSize(6.4).fillColor("#ffffff").text("ÉLÉMENT", M + 6, y + 7, { lineBreak: false }); | |
| 288 | + comps.forEach((_, i) => { | |
| 289 | + doc.rect(M + 118 + i * colW, y, colW, rh).fillAndStroke(i % 2 ? SURFACE2 : "#ffffff", INK); | |
| 290 | + doc.font("JB-Bold").fontSize(6.6).fillColor(GREEN_DEEP) | |
| 291 | + .text(`COMPARABLE ${i + 1}`, M + 118 + i * colW + 6, y + 7, { lineBreak: false }); | |
| 292 | + }); | |
| 293 | + y += rh; | |
| 294 | + rowsG.forEach(([label, get]) => { | |
| 295 | + const bold = label === "PRIX AJUSTÉ"; | |
| 296 | + doc.rect(M, y, 118, rh).fillAndStroke(bold ? LIME : SURFACE2, INK); | |
| 297 | + doc.font("JB-Bold").fontSize(6).fillColor(INK) | |
| 298 | + .text(label.toUpperCase(), M + 6, y + 7, { width: 108, height: 8, ellipsis: true, lineBreak: false }); | |
| 299 | + comps.forEach((_, ci) => { | |
| 300 | + doc.rect(M + 118 + ci * colW, y, colW, rh).fillAndStroke(bold ? "#f6fbe4" : "#ffffff", INK); | |
| 301 | + doc.font(bold ? "SG-Bold" : "Inter").fontSize(bold ? 8.4 : 7.4).fillColor(bold ? INK : INK2) | |
| 302 | + .text(get(ci), M + 118 + ci * colW + 6, y + (bold ? 5.5 : 6.5), { width: colW - 12, height: 10, ellipsis: true, lineBreak: false }); | |
| 303 | + }); | |
| 304 | + y += rh; | |
| 305 | + }); | |
| 306 | + y += 10; | |
| 307 | + y = para(c, | |
| 308 | + `${r.nCompsUsed} ventes comparables ont été retenues au total (les ${comps.length} plus pondérées sont détaillées ci-dessus ; la liste ` + | |
| 309 | + "complète figure dans le rapport standard). Chaque prix de vente est ajusté : (i) aux conditions actuelles du marché au moyen " + | |
| 310 | + "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 " + | |
| 311 | + "prix unitaire au m² du comparable, plafonné à ±25 %, et (iii) pour l'écart d'âge à raison de 0,5 % par année, plafonné à ±10 %. " + | |
| 312 | + `La médiane pondérée des prix ajustés s'établit à ${fmt(r.compsEstimate)}.`, y); | |
| 313 | + | |
| 314 | + // mini-indice de marché | |
| 315 | + if (marketIndex.length > 3) { | |
| 316 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("4.1 Indice des conditions de marché du segment", M, y); | |
| 317 | + y += 16; | |
| 318 | + const gw = CW, gh = 84; | |
| 319 | + doc.roundedRect(M, y, gw, gh, 5).lineWidth(1).fillAndStroke("#ffffff", INK); | |
| 320 | + const vals = marketIndex.map((p) => p.idx); | |
| 321 | + const mn = Math.min(...vals), mx = Math.max(...vals), span = mx - mn || 1; | |
| 322 | + const pts = marketIndex.map((p, i) => [ | |
| 323 | + M + 14 + (i / (marketIndex.length - 1)) * (gw - 28), | |
| 324 | + y + gh - 18 - ((p.idx - mn) / span) * (gh - 36), | |
| 325 | + ]); | |
| 326 | + doc.moveTo(pts[0][0], pts[0][1]); | |
| 327 | + pts.slice(1).forEach(([px, py]) => doc.lineTo(px, py)); | |
| 328 | + doc.lineWidth(1.8).stroke(GREEN); | |
| 329 | + const last = pts[pts.length - 1]; | |
| 330 | + doc.circle(last[0], last[1], 3).lineWidth(1.2).fillAndStroke(LIME, INK); | |
| 331 | + doc.font("JB-Reg").fontSize(6).fillColor(INK3) | |
| 332 | + .text(marketIndex[0].month, M + 14, y + gh - 12, { lineBreak: false }) | |
| 333 | + .text(marketIndex[marketIndex.length - 1].month, M + gw - 60, y + gh - 12, { width: 46, align: "right" }); | |
| 334 | + doc.font("JB-Reg").fontSize(6).fillColor(INK3) | |
| 335 | + .text(`BASE 1,00 = ${marketIndex[marketIndex.length - 1].month} · MÉDIANE $/M² LISSÉE 3 MOIS`, M + 14, y + 8, { characterSpacing: 0.5 }); | |
| 336 | + } | |
| 337 | + | |
| 338 | + /* ------------------- PAGE 5 : HÉDONIQUE + RÉCONCILIATION ------------------- */ | |
| 339 | + y = newPage(c, TOTAL); | |
| 340 | + y = sectionTitle(c, "5.", "Analyse hédonique de masse", y); | |
| 341 | + y = para(c, | |
| 342 | + "Un modèle de gradient boosting (LightGBM) est entraîné sur environ 690 000 transactions québécoises (2021-2026) appariées " + | |
| 343 | + "au rôle d'évaluation foncière géoréférencé. Dix-huit variables décrivent chaque propriété (dimensions, âge, usage, " + | |
| 344 | + "localisation fine, valeurs au rôle, temps). Sur un échantillon réservé de 102 943 ventes jamais vues du modèle, l'erreur " + | |
| 345 | + "médiane est de 11,0 % et 72 % des estimations tombent à ±20 % du prix réel (ratio médian 0,995, sans biais systématique — " + | |
| 346 | + "validation selon la norme IAAO sur études de ratios). L'incertitude propre à la propriété est quantifiée par deux modèles de " + | |
| 347 | + "régression quantile (10e et 90e centiles).", y); | |
| 348 | + y = kvTable(c, [ | |
| 349 | + ["Estimation du modèle hédonique", fmt(r.modelEstimate)], | |
| 350 | + ["Intervalle P10-P90 du modèle", `${fmt(r.low)} → ${fmt(r.high)} (recentré)`], | |
| 351 | + ["Erreur médiane du modèle (validation)", "11,0 % (MdAPE, holdout aléatoire)"], | |
| 352 | + ], y); | |
| 353 | + | |
| 354 | + y = sectionTitle(c, "6.", "Réconciliation et conclusion", y + 4); | |
| 355 | + const rh2 = 20; | |
| 356 | + const recRows: [string, string, string][] = [ | |
| 357 | + ["Approche", "Indication de valeur", "Pondération"], | |
| 358 | + ["Modèle hédonique de masse", fmt(r.modelEstimate), `${(r.modelWeight * 100).toFixed(0)} %`], | |
| 359 | + ["Comparaison directe (médiane pondérée)", fmt(r.compsEstimate), `${((1 - r.modelWeight) * 100).toFixed(0)} %`], | |
| 360 | + ["VALEUR CONCLUE", fmt(r.estimate), "100 %"], | |
| 361 | + ]; | |
| 362 | + recRows.forEach(([a, b, cc], i) => { | |
| 363 | + const head = i === 0; | |
| 364 | + const last = i === recRows.length - 1; | |
| 365 | + doc.rect(M, y, CW, rh2).fillAndStroke(head ? INK : last ? LIME : i % 2 ? SURFACE2 : "#ffffff", INK); | |
| 366 | + doc.font(head || last ? "JB-Bold" : "Inter").fontSize(head ? 6.8 : last ? 8.6 : 8) | |
| 367 | + .fillColor(head ? "#ffffff" : INK) | |
| 368 | + .text(head ? a.toUpperCase() : a, M + 8, y + 6, { lineBreak: false }); | |
| 369 | + doc.text(b, M + CW * 0.52, y + 6, { lineBreak: false }); | |
| 370 | + doc.text(cc, M + CW * 0.84, y + 6, { lineBreak: false }); | |
| 371 | + y += rh2; | |
| 372 | + }); | |
| 373 | + y += 10; | |
| 374 | + y = para(c, | |
| 375 | + `Compte tenu de la qualité de l'appariement des données, du nombre et de la cohérence des comparables (dispersion ±${r.compsDispersionPct ?? "—"} %) ` + | |
| 376 | + `et de la performance validée du modèle, la valeur marchande estimée de la propriété au ${generated} est conclue à ` + | |
| 377 | + `${fmt(r.estimate)}, à l'intérieur d'une fourchette raisonnable de ${fmt(r.low)} à ${fmt(r.high)}. Indice de fiabilité : ` + | |
| 378 | + `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}.`, y); | |
| 379 | + | |
| 380 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("6.1 Évolution de la valeur estimée (2021-2026)", M, y); | |
| 381 | + y += 16; | |
| 382 | + const hist = u.history.filter((h) => h.value != null); | |
| 383 | + y = kvTable(c, hist.map((h) => [String(h.year), fmt(h.value)] as [string, string]), y, CW * 0.6); | |
| 384 | + | |
| 385 | + /* ------------------- PAGE 6 : CONDITIONS + CERTIFICATION ------------------- */ | |
| 386 | + y = newPage(c, TOTAL); | |
| 387 | + y = sectionTitle(c, "7.", "Hypothèses et conditions limitatives", y); | |
| 388 | + const conds = [ | |
| 389 | + "Le présent rapport est généré automatiquement par le moteur d'évaluation statistique Vrai-Prix (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.", | |
| 390 | + "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.", | |
| 391 | + "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.", | |
| 392 | + "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.", | |
| 393 | + "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.", | |
| 394 | + "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.", | |
| 395 | + "La fourchette P10-P90 et l'indice de fiabilité font partie intégrante de la conclusion et doivent être communiqués avec elle.", | |
| 396 | + "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.", | |
| 397 | + "Pour une décision de financement finale, une confirmation par un évaluateur agréé (OEAQ) mandaté par l'institution financière est recommandée.", | |
| 398 | + ]; | |
| 399 | + conds.forEach((txt, i) => { | |
| 400 | + doc.font("JB-Bold").fontSize(7).fillColor(GREEN).text(`7.${i + 1}`, M, y + 1, { lineBreak: false }); | |
| 401 | + doc.font("Inter").fontSize(7.9).fillColor(INK2) | |
| 402 | + .text(txt, M + 26, y, { width: CW - 26, lineGap: 1.6, align: "justify" }); | |
| 403 | + y = doc.y + 6; | |
| 404 | + }); | |
| 405 | + | |
| 406 | + y = sectionTitle(c, "8.", "Attestation", y + 2); | |
| 407 | + y = para(c, | |
| 408 | + "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 " + | |
| 409 | + "Vrai-Prix pour produire la conclusion de valeur ; que la méthodologie complète, ses métriques de validation et ses limites " + | |
| 410 | + "sont publiées à vrai-prix.com/methodologie ; et que ni Vrai-Prix ni son exploitant n'ont d'intérêt présent ou futur dans la " + | |
| 411 | + "propriété évaluée, ni de lien de dépendance avec les parties à une éventuelle transaction.", y); | |
| 412 | + y += 14; | |
| 413 | + doc.rect(M, y + 26, 200, 0.9).fill(INK); | |
| 414 | + doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Vrai-Prix", M, y + 32); | |
| 415 | + doc.font("Inter").fontSize(8).fillColor(INK2) | |
| 416 | + .text("Simon-Pierre Boucher · contact@spboucher.ai", M, y + 46) | |
| 417 | + .text(`Dossier ${dossier} · ${generated}`, M, y + 58); | |
| 418 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 419 | + .text("ANNEXE DISPONIBLE : RAPPORT STANDARD (PLAN DES COMPARABLES, LISTE COMPLÈTE DES VENTES) — VRAI-PRIX.COM", M, y + 82, { characterSpacing: 0.5 }); | |
| 420 | + | |
| 421 | + doc.end(); | |
| 422 | + return done; | |
| 423 | +} | |
added
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 = "#9e2a25"; | |
| 16 | +const GREEN_DEEP = "#771f1b"; | |
| 17 | +const LIME = "#ff5148"; | |
| 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("Vrai", x, y, { lineBreak: false }); | |
| 47 | + const w = doc.widthOfString("Vrai"); | |
| 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("Prix") + 12, 30, 5).fill(LIME); | |
| 52 | + doc.fillColor(INK).text("Prix", 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 | + "VRAI-PRIX.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: "Vrai-Prix — 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,81,72,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
src/lib/report.ts
+360 −0
@@ -0,0 +1,360 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Rapport PDF Vrai-Prix — 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 | + | |
| 11 | +const INK = "#141814"; | |
| 12 | +const INK3 = "#8b928c"; | |
| 13 | +const PAPER = "#f5f3ee"; | |
| 14 | +const SURFACE2 = "#faf9f5"; | |
| 15 | +const GREEN = "#9e2a25"; | |
| 16 | +const GREEN_DEEP = "#771f1b"; | |
| 17 | +const LIME = "#ff5148"; | |
| 18 | +const LIME_SOFT = "#ffe3e0"; | |
| 19 | +const AMBER_SOFT = "#fdf3e2"; | |
| 20 | +const DANGER_SOFT = "#fbe9e7"; | |
| 21 | +const LINE = "#dedcd4"; | |
| 22 | + | |
| 23 | +const W = 612; | |
| 24 | +const H = 792; | |
| 25 | +const M = 44; // marge | |
| 26 | + | |
| 27 | +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); | |
| 28 | + | |
| 29 | +const fmt = (v: number | null | undefined) => | |
| 30 | + v == null | |
| 31 | + ? "—" | |
| 32 | + : new Intl.NumberFormat("fr-CA", { | |
| 33 | + style: "currency", | |
| 34 | + currency: "CAD", | |
| 35 | + maximumFractionDigits: 0, | |
| 36 | + }).format(v); | |
| 37 | + | |
| 38 | +const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`; | |
| 39 | +const fmtDist = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`); | |
| 40 | + | |
| 41 | +const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT }; | |
| 42 | +const CONF_TXT: Record<string, string> = { | |
| 43 | + A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince", | |
| 44 | +}; | |
| 45 | + | |
| 46 | +type Doc = InstanceType<typeof PDFDocument>; | |
| 47 | + | |
| 48 | +function logo(doc: Doc, x: number, y: number, scale = 1) { | |
| 49 | + doc.save(); | |
| 50 | + doc.font("SG-Bold").fontSize(24 * scale); | |
| 51 | + doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false }); | |
| 52 | + const w = doc.widthOfString("Vrai"); | |
| 53 | + const bx = x + w + 4 * scale; | |
| 54 | + doc.save(); | |
| 55 | + doc.rotate(-3, { origin: [bx, y + 12 * scale] }); | |
| 56 | + const bw = doc.widthOfString("Prix") + 12 * scale; | |
| 57 | + doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME); | |
| 58 | + doc.fillColor(INK).text("Prix", bx + 6 * scale, y, { lineBreak: false }); | |
| 59 | + doc.restore(); | |
| 60 | + doc.restore(); | |
| 61 | +} | |
| 62 | + | |
| 63 | +function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) { | |
| 64 | + doc.rect(x, y + 3.5, 20, 2).fill(color); | |
| 65 | + doc.font("JB-Bold").fontSize(8).fillColor(color) | |
| 66 | + .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false }); | |
| 67 | +} | |
| 68 | + | |
| 69 | +function pageFrame(doc: Doc, page: number, total: number, generated: string) { | |
| 70 | + doc.rect(0, 0, W, H).fill(PAPER); | |
| 71 | + // filet de bas de page | |
| 72 | + doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK); | |
| 73 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( | |
| 74 | + "VRAI-PRIX.COM — ESTIMATION STATISTIQUE À TITRE INDICATIF · NE REMPLACE PAS UNE ÉVALUATION PROFESSIONNELLE · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", | |
| 75 | + M, H - 38, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false } | |
| 76 | + ); | |
| 77 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK) | |
| 78 | + .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" }); | |
| 79 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 80 | + .text(generated, W - M - 160, H - 30, { width: 160, align: "right" }); | |
| 81 | +} | |
| 82 | + | |
| 83 | +function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) { | |
| 84 | + if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9"); | |
| 85 | + doc.roundedRect(x, y, w, h, 8).fillAndStroke(opts?.fill ?? "#ffffff", INK); | |
| 86 | + doc.lineWidth(1.3); | |
| 87 | +} | |
| 88 | + | |
| 89 | +function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) { | |
| 90 | + doc.lineWidth(0.8); | |
| 91 | + doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE); | |
| 92 | + doc.font("JB-Bold").fontSize(5.8).fillColor(INK3) | |
| 93 | + .text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true }); | |
| 94 | + doc.font("SG-Bold").fontSize(9.2).fillColor(INK) | |
| 95 | + .text(v, x + 7, y + 16.5, { width: w - 14, height: h - 20, ellipsis: true }); | |
| 96 | + doc.lineWidth(1.3); | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** Plan radar vectoriel : sujet au centre, comparables par azimut/distance. */ | |
| 100 | +function radar(doc: Doc, cx: number, cy: number, R: number, data: UnitEstimate) { | |
| 101 | + const u = data.unit!; | |
| 102 | + const comps = data.result.comps; | |
| 103 | + const cos = Math.cos((u.lat * Math.PI) / 180); | |
| 104 | + const pts = comps.map((c) => ({ | |
| 105 | + ...c, | |
| 106 | + x: (c.lng - u.lng) * 111320 * cos, | |
| 107 | + y: (c.lat - u.lat) * 110574, | |
| 108 | + })); | |
| 109 | + const maxM = Math.max(120, ...pts.map((p) => Math.hypot(p.x, p.y))); | |
| 110 | + const ringSets = [ | |
| 111 | + [100, 250, 500], [250, 500, 1000], [500, 1000, 2000], | |
| 112 | + [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000], | |
| 113 | + ]; | |
| 114 | + const rings = ringSets.find((s) => maxM <= s[2]) ?? ringSets[ringSets.length - 1]; | |
| 115 | + const k = R / rings[2]; | |
| 116 | + | |
| 117 | + // cadre + grille | |
| 118 | + doc.save(); | |
| 119 | + doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).clip(); | |
| 120 | + doc.rect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16)).fill("#ffffff"); | |
| 121 | + doc.lineWidth(0.5).strokeColor("#eceae3"); | |
| 122 | + for (let g = -R - 16; g <= R + 16; g += 24) { | |
| 123 | + doc.moveTo(cx + g, cy - R - 16).lineTo(cx + g, cy + R + 16).stroke(); | |
| 124 | + doc.moveTo(cx - R - 16, cy + g).lineTo(cx + R + 16, cy + g).stroke(); | |
| 125 | + } | |
| 126 | + // anneaux | |
| 127 | + rings.forEach((m, i) => { | |
| 128 | + const rr = m * k; | |
| 129 | + doc.lineWidth(i === 2 ? 1.3 : 0.8).strokeColor(i === 2 ? INK : "#b9b7ae"); | |
| 130 | + if (i !== 2) doc.dash(3, { space: 3 }); | |
| 131 | + doc.circle(cx, cy, rr).stroke(); | |
| 132 | + doc.undash(); | |
| 133 | + doc.font("JB-Reg").fontSize(5.5).fillColor(INK3) | |
| 134 | + .text(fmtDist(m), cx + rr * 0.7071 + 2, cy - rr * 0.7071 - 7, { lineBreak: false }); | |
| 135 | + }); | |
| 136 | + // croix | |
| 137 | + doc.lineWidth(0.6).strokeColor("#d5d3ca"); | |
| 138 | + doc.moveTo(cx, cy - R).lineTo(cx, cy + R).stroke(); | |
| 139 | + doc.moveTo(cx - R, cy).lineTo(cx + R, cy).stroke(); | |
| 140 | + // comparables numérotés | |
| 141 | + pts.forEach((p, i) => { | |
| 142 | + const px = cx + p.x * k; | |
| 143 | + const py = cy - p.y * k; | |
| 144 | + const rr = 6.5 + p.weight * 3.5; | |
| 145 | + doc.circle(px, py, rr).lineWidth(1.1).fillAndStroke(GREEN, INK); | |
| 146 | + doc.font("JB-Bold").fontSize(rr > 8.5 ? 7 : 6).fillColor(PAPER) | |
| 147 | + .text(String(i + 1), px - rr, py - 3, { width: 2 * rr, align: "center", lineBreak: false }); | |
| 148 | + }); | |
| 149 | + // sujet : losange lime | |
| 150 | + doc.save().translate(cx, cy).rotate(45); | |
| 151 | + doc.rect(-8, -8, 16, 16).lineWidth(1.6).fillAndStroke(LIME, INK); | |
| 152 | + doc.rect(-2.5, -2.5, 5, 5).fill(INK); | |
| 153 | + doc.restore(); | |
| 154 | + // nord | |
| 155 | + doc.save().translate(cx + R + 2, cy - R - 2); | |
| 156 | + doc.path("M0,-9 L4,5 L0,2 L-4,5 Z").fill(INK); | |
| 157 | + doc.font("JB-Bold").fontSize(6).fillColor(INK).text("N", -2.2, 8, { lineBreak: false }); | |
| 158 | + doc.restore(); | |
| 159 | + doc.restore(); | |
| 160 | + // bordure du cadre | |
| 161 | + doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).lineWidth(1.3).stroke(INK); | |
| 162 | +} | |
| 163 | + | |
| 164 | +export async function buildReport(data: UnitEstimate): Promise<Buffer> { | |
| 165 | + const u = data.unit!; | |
| 166 | + const s = u.specs; | |
| 167 | + const r = data.result; | |
| 168 | + const generated = new Date().toISOString().slice(0, 16).replace("T", " "); | |
| 169 | + | |
| 170 | + const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `Vrai-Prix — ${u.adresse ?? ""}` } }); | |
| 171 | + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); | |
| 172 | + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf")); | |
| 173 | + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); | |
| 174 | + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); | |
| 175 | + doc.registerFont("Inter", F("Inter-Regular.ttf")); | |
| 176 | + | |
| 177 | + const chunks: Buffer[] = []; | |
| 178 | + doc.on("data", (c: Buffer) => chunks.push(c)); | |
| 179 | + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); | |
| 180 | + | |
| 181 | + /* =============================== PAGE 1 =============================== */ | |
| 182 | + pageFrame(doc, 1, 2, generated); | |
| 183 | + | |
| 184 | + // bandeau encre | |
| 185 | + doc.rect(0, 0, W, 96).fill(INK); | |
| 186 | + logo(doc, M, 30); | |
| 187 | + doc.font("JB-Reg").fontSize(7).fillColor(LIME) | |
| 188 | + .text("RAPPORT D'ÉVALUATION · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false }); | |
| 189 | + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") | |
| 190 | + .text(`ID ${u.id}`, W - M - 200, 34, { width: 200, align: "right" }) | |
| 191 | + .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 46, { width: 200, align: "right" }) | |
| 192 | + .text(`MATRICULE ${s.matricule ?? "—"}`, W - M - 200, 58, { width: 200, align: "right" }); | |
| 193 | + doc.rect(0, 96, W, 3).fill(LIME); | |
| 194 | + | |
| 195 | + // adresse + estimation | |
| 196 | + let y = 122; | |
| 197 | + kicker(doc, "Valeur estimée au marché", M, y); | |
| 198 | + y += 16; | |
| 199 | + doc.font("SG-Bold").fontSize(15).fillColor(INK) | |
| 200 | + .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`, | |
| 201 | + M, y, { width: W - 2 * M }); | |
| 202 | + y = doc.y + 6; | |
| 203 | + doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(r.estimate), M, y); | |
| 204 | + y = doc.y + 10; | |
| 205 | + | |
| 206 | + // rangée fourchette / confiance / rôle / écart | |
| 207 | + const rowY = y; | |
| 208 | + const cols = [ | |
| 209 | + ["FOURCHETTE HONNÊTE", `${fmt(r.low)} → ${fmt(r.high)}`], | |
| 210 | + ["ÉVALUATION MUNICIPALE 2026", fmt(u.valeurRole)], | |
| 211 | + ["ÉCART VS RÔLE", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %` : "—"], | |
| 212 | + ] as const; | |
| 213 | + let cx0 = M; | |
| 214 | + for (const [k, v] of cols) { | |
| 215 | + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(k, cx0, rowY, { characterSpacing: 0.8, lineBreak: false }); | |
| 216 | + const lw = doc.widthOfString(k) + cols.length * 0.8; | |
| 217 | + doc.font("SG-Bold").fontSize(13).fillColor(INK).text(v, cx0, rowY + 11, { lineBreak: false }); | |
| 218 | + cx0 += Math.max(doc.widthOfString(v), lw) + 34; | |
| 219 | + } | |
| 220 | + // pastille confiance | |
| 221 | + const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} % — ${CONF_TXT[r.confidenceLevel].toUpperCase()}`; | |
| 222 | + doc.font("JB-Bold").fontSize(7.5); | |
| 223 | + const cw = doc.widthOfString(confTxt) + 20; | |
| 224 | + doc.roundedRect(M, rowY + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK); | |
| 225 | + doc.fillColor(r.confidenceLevel === "D" ? "#b3423a" : INK).text(confTxt, M + 10, rowY + 39.5, { lineBreak: false }); | |
| 226 | + | |
| 227 | + // fiche de la propriété | |
| 228 | + y = rowY + 72; | |
| 229 | + kicker(doc, "Fiche de la propriété — registre officiel (rôle 2026)", M, y); | |
| 230 | + y += 16; | |
| 231 | + const groups: [string, [string, string][]][] = [ | |
| 232 | + ["BÂTIMENT", [ | |
| 233 | + ["Année constr.", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (est.)" : ""}` : "—"], | |
| 234 | + ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"], | |
| 235 | + ["Étages", u.specs.nbEtages != null ? String(u.specs.nbEtages) : "—"], | |
| 236 | + ["Genre", s.genreConstruction ?? "—"], | |
| 237 | + ["Lien phys.", s.lienPhysique ?? "—"], | |
| 238 | + ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"], | |
| 239 | + ["Locaux n-rés.", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"], | |
| 240 | + ["Chambres loc.", s.nbChambresLocatives != null ? String(s.nbChambresLocatives) : "—"], | |
| 241 | + ]], | |
| 242 | + ["TERRAIN", [ | |
| 243 | + ["Superficie", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"], | |
| 244 | + ["Mes. frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"], | |
| 245 | + ["CUBF", s.cubf ? String(s.cubf) : "—"], | |
| 246 | + ["Usage", s.cubfLibelle ?? "—"], | |
| 247 | + ["Unité voisin.", s.uniteVoisinage || "—"], | |
| 248 | + ["Arrond.", s.arrond || "—"], | |
| 249 | + ]], | |
| 250 | + ["RÔLE D'ÉVALUATION", [ | |
| 251 | + ["Val. terrain", fmt(s.valeurTerrain)], | |
| 252 | + ["Val. bâtiment", fmt(s.valeurBatiment)], | |
| 253 | + ["Val. totale", fmt(u.valeurRole)], | |
| 254 | + ["Rôle antér.", fmt(s.valeurAnterieure)], | |
| 255 | + ["Cond. marché", s.datCondMarche ?? "—"], | |
| 256 | + ["Adresses", s.nAdresses != null ? String(s.nAdresses) : "—"], | |
| 257 | + ]], | |
| 258 | + ]; | |
| 259 | + const gW = (W - 2 * M - 2 * 14) / 3; | |
| 260 | + const cellH = 30; | |
| 261 | + const gH = 4 * (cellH + 6) + 34; | |
| 262 | + groups.forEach(([title, rows], gi) => { | |
| 263 | + const gx = M + gi * (gW + 14); | |
| 264 | + card(doc, gx, y, gW, gH); | |
| 265 | + doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP) | |
| 266 | + .text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false }); | |
| 267 | + doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash(); | |
| 268 | + const cw2 = (gW - 24 - 6) / 2; | |
| 269 | + rows.forEach(([k, v], i) => { | |
| 270 | + const col = i % 2, row = Math.floor(i / 2); | |
| 271 | + kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v); | |
| 272 | + }); | |
| 273 | + }); | |
| 274 | + y += gH + 24; | |
| 275 | + | |
| 276 | + // historique | |
| 277 | + kicker(doc, "Valeur estimée par année — 2021 → 2026", M, y); | |
| 278 | + y += 16; | |
| 279 | + const hist = u.history; | |
| 280 | + const hMax = Math.max(...hist.map((h) => h.value ?? 0), 1); | |
| 281 | + const histH = hist.length * 21 + 24; | |
| 282 | + card(doc, M, y, W - 2 * M, histH); | |
| 283 | + hist.forEach((h, i) => { | |
| 284 | + const ly = y + 14 + i * 21; | |
| 285 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(String(h.year), M + 14, ly + 2, { lineBreak: false }); | |
| 286 | + const trackX = M + 52, trackW = W - 2 * M - 52 - 100; | |
| 287 | + doc.rect(trackX, ly, trackW, 12).fill(SURFACE2); | |
| 288 | + doc.rect(trackX, ly, Math.max(2, ((h.value ?? 0) / hMax) * trackW), 12).fill(h.year === 2026 ? INK : GREEN); | |
| 289 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK) | |
| 290 | + .text(fmt(h.value), W - M - 92, ly + 2, { width: 80, align: "right" }); | |
| 291 | + }); | |
| 292 | + | |
| 293 | + /* =============================== PAGE 2 =============================== */ | |
| 294 | + doc.addPage({ size: "LETTER", margin: 0 }); | |
| 295 | + pageFrame(doc, 2, 2, generated); | |
| 296 | + doc.rect(0, 0, W, 8).fill(INK); | |
| 297 | + doc.rect(0, 8, W, 2.5).fill(LIME); | |
| 298 | + | |
| 299 | + y = 34; | |
| 300 | + kicker(doc, "Pourquoi ce prix — le calcul, sans boîte noire", M, y); | |
| 301 | + y += 16; | |
| 302 | + const tileW = (W - 2 * M - 14) / 2; | |
| 303 | + card(doc, M, y, tileW, 64); | |
| 304 | + doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("MODÈLE HÉDONIQUE (LIGHTGBM · 690 000 VENTES QC)", M + 14, y + 11, { characterSpacing: 0.6 }); | |
| 305 | + doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.modelEstimate), M + 14, y + 24); | |
| 306 | + doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(`POIDS : ${(r.modelWeight * 100).toFixed(0)} %`, M + 14, y + 49); | |
| 307 | + card(doc, M + tileW + 14, y, tileW, 64); | |
| 308 | + 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 }); | |
| 309 | + doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.compsEstimate), M + tileW + 28, y + 24); | |
| 310 | + doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text( | |
| 311 | + `POIDS : ${((1 - r.modelWeight) * 100).toFixed(0)} % · ${r.nCompsUsed} COMPS${r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}`, | |
| 312 | + M + tileW + 28, y + 49); | |
| 313 | + y += 88; | |
| 314 | + | |
| 315 | + // radar + tableau | |
| 316 | + kicker(doc, `Plan des comparables — ${r.nCompsUsed} ventes réelles · azimut + distance`, M, y); | |
| 317 | + y += 18; | |
| 318 | + const R = 118; | |
| 319 | + radar(doc, M + R + 16, y + R + 16, R, data); | |
| 320 | + | |
| 321 | + const tX = M + 2 * (R + 16) + 16; | |
| 322 | + const tW = W - M - tX; | |
| 323 | + doc.font("JB-Bold").fontSize(6.2).fillColor(INK3); | |
| 324 | + let ty = y + 2; | |
| 325 | + r.comps.slice(0, 12).forEach((c, i) => { | |
| 326 | + const rowH = 22.5; | |
| 327 | + if (i % 2 === 0) doc.rect(tX, ty - 2, tW, rowH).fill(SURFACE2); | |
| 328 | + doc.circle(tX + 8, ty + 7, 6).lineWidth(0.9).fillAndStroke(GREEN, INK); | |
| 329 | + doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), tX + 2, ty + 4.5, { width: 12, align: "center", lineBreak: false }); | |
| 330 | + doc.font("SG-Bold").fontSize(7.3).fillColor(INK) | |
| 331 | + .text(`${c.street ?? ""}`, tX + 19, ty, { width: tW - 90, height: 9, ellipsis: true, lineBreak: false }); | |
| 332 | + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) | |
| 333 | + .text(`${c.date} · ${fmtDist(c.distanceM)} · VENDU ${fmt(c.amount)} · T ${signed(c.adjTime)} · S ${signed(c.adjArea)} · Â ${signed(c.adjAge)}`, | |
| 334 | + tX + 19, ty + 10, { width: tW - 78, height: 8, ellipsis: true, lineBreak: false }); | |
| 335 | + doc.font("SG-Bold").fontSize(8).fillColor(INK) | |
| 336 | + .text(fmt(Math.round(c.adjustedPrice)), tX + tW - 68, ty + 3, { width: 68, align: "right", lineBreak: false }); | |
| 337 | + ty += rowH; | |
| 338 | + }); | |
| 339 | + // légende radar | |
| 340 | + const legY = y + 2 * (R + 16) + 8; | |
| 341 | + doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text( | |
| 342 | + "◆ = PROPRIÉTÉ ÉVALUÉE (CENTRE) · ● = VENTE COMPARABLE (TAILLE = POIDS) · T/S/Â = AJUSTEMENTS TEMPS / SUPERFICIE / ÂGE", | |
| 343 | + M, legY, { width: W - 2 * M, characterSpacing: 0.4 }); | |
| 344 | + | |
| 345 | + // méthodo | |
| 346 | + const my = Math.max(legY + 20, ty + 14); | |
| 347 | + card(doc, M, my, W - 2 * M, 92, { fill: INK, shadow: false }); | |
| 348 | + doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("MÉTHODOLOGIE", M + 16, my + 12, { characterSpacing: 1.4 }); | |
| 349 | + doc.font("Inter").fontSize(7.8).fillColor("#e8e6df").text( | |
| 350 | + "L'estimation combine (1) un modèle hédonique de gradient boosting entraîné sur ~690 000 transactions québécoises " + | |
| 351 | + "(2021-2026) appariées au rôle d'évaluation foncière géoréférencé du MAMH — erreur médiane de 11 % — et (2) une médiane " + | |
| 352 | + "pondérée des ventes comparables voisines, chacune ajustée pour l'évolution du marché (indice mensuel), l'écart de " + | |
| 353 | + "superficie (50 % du $/m²) et l'écart d'âge (0,5 %/an, plafonné à ±10 %). La fourchette correspond aux 10e et 90e " + | |
| 354 | + "centiles du modèle recentrés sur l'estimation finale. L'indice de confiance (A-D) reflète le nombre de comparables, " + | |
| 355 | + "leur dispersion et la largeur de la fourchette. Validation selon la norme IAAO sur études de ratios.", | |
| 356 | + M + 16, my + 26, { width: W - 2 * M - 32, lineGap: 1.5 }); | |
| 357 | + | |
| 358 | + doc.end(); | |
| 359 | + return done; | |
| 360 | +} | |
added
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 | +}); | |
| 10 | ||