Vrai-Prix Maps — recherche par carte propulsée par Ka Maps (framework Groupe Ka)
Remplace la carte Leaflet/OSM par le framework partagé @groupe-ka/ka-maps (moteur Mapbox GL, style Standard 3D par défaut + bouton 2D) : pastilles de VALEURS ESTIMÉES 2026 (encre, liseré rouge — jamais présentées comme un prix demandé), grappes avec compte + valeur moyenne clampée, chargement piloté par le viewport (débounce, annulation, cache, mode auto), aperçu React bilingue FR/EN, « Me localiser » sur action explicite, badge Vrai-Prix Maps · Groupe Ka. /api/nearby : plafonds relevés (halfLat 0,08 / halfLng 0,12 / limit 1500) — changement additif, contrat iOS 1.1.0 inchangé (défauts identiques). Consommé via tarball npm (file:../../ka-maps/*.tgz) — Turbopack ne résout pas les liens symboliques hors racine. leaflet/@types/leaflet retirés. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 changed files +346 −249
modified
next.config.ts
+2 −0
@@ -3,6 +3,8 @@ import type { NextConfig } from "next"; | ||
| 3 | 3 | |
| 4 | 4 | const nextConfig: NextConfig = { |
| 5 | 5 | serverExternalPackages: ["better-sqlite3", "pdfkit"], |
| 6 | + // Ka Maps (framework Groupe Ka) est lié en local (file:../../ka-maps) | |
| 7 | + transpilePackages: ["@groupe-ka/ka-maps"], | |
| 6 | 8 | }; |
| 7 | 9 | |
| 8 | 10 | export default nextConfig; |
modified
package-lock.json
+32 −19
@@ -9,8 +9,9 @@ | ||
| 9 | 9 | "version": "0.1.0", |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "@anthropic-ai/sdk": "^0.116.0", |
| 12 | + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz", | |
| 12 | 13 | "better-sqlite3": "^13.0.3", |
| 13 | − "leaflet": "^1.9.4", | |
| 14 | + "mapbox-gl": "^3.28.1", | |
| 14 | 15 | "next": "16.3.0", |
| 15 | 16 | "pdfkit": "^0.19.1", |
| 16 | 17 | "react": "19.2.8", |
@@ -19,7 +20,6 @@ | ||
| 19 | 20 | "devDependencies": { |
| 20 | 21 | "@tailwindcss/postcss": "^4", |
| 21 | 22 | "@types/better-sqlite3": "^9.6.0", |
| 22 | − "@types/leaflet": "^1.9.22", | |
| 23 | 23 | "@types/node": "^20", |
| 24 | 24 | "@types/pdfkit": "^0.17.6", |
| 25 | 25 | "@types/react": "^19", |
@@ -492,6 +492,24 @@ | ||
| 492 | 492 | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" |
| 493 | 493 | } |
| 494 | 494 | }, |
| 495 | + "node_modules/@groupe-ka/ka-maps": { | |
| 496 | + "version": "0.1.0", | |
| 497 | + "resolved": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz", | |
| 498 | + "integrity": "sha512-zXL7KpTGQ2qIhS+w2Eh3I50lIafLLtinQ1r4+6/aQD/W0Ch3SYYVTxch4LwsdOzVGZ+re1uKeKuMGpEF4HJTdw==", | |
| 499 | + "license": "UNLICENSED", | |
| 500 | + "dependencies": { | |
| 501 | + "@types/geojson": "^7946.0.16", | |
| 502 | + "mapbox-gl": "^3.28.1" | |
| 503 | + }, | |
| 504 | + "peerDependencies": { | |
| 505 | + "react": ">=18" | |
| 506 | + }, | |
| 507 | + "peerDependenciesMeta": { | |
| 508 | + "react": { | |
| 509 | + "optional": true | |
| 510 | + } | |
| 511 | + } | |
| 512 | + }, | |
| 495 | 513 | "node_modules/@humanfs/core": { |
| 496 | 514 | "version": "0.19.2", |
| 497 | 515 | "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", |
@@ -2075,7 +2093,6 @@ | ||
| 2075 | 2093 | "version": "7946.0.16", |
| 2076 | 2094 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", |
| 2077 | 2095 | "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", |
| 2078 | − "dev": true, | |
| 2079 | 2096 | "license": "MIT" |
| 2080 | 2097 | }, |
| 2081 | 2098 | "node_modules/@types/json-schema": { |
@@ -2092,16 +2109,6 @@ | ||
| 2092 | 2109 | "dev": true, |
| 2093 | 2110 | "license": "MIT" |
| 2094 | 2111 | }, |
| 2095 | − "node_modules/@types/leaflet": { | |
| 2096 | − "version": "1.9.22", | |
| 2097 | − "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", | |
| 2098 | − "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", | |
| 2099 | − "dev": true, | |
| 2100 | − "license": "MIT", | |
| 2101 | − "dependencies": { | |
| 2102 | − "@types/geojson": "*" | |
| 2103 | − } | |
| 2104 | − }, | |
| 2105 | 2112 | "node_modules/@types/node": { |
| 2106 | 2113 | "version": "20.19.43", |
| 2107 | 2114 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", |
@@ -5507,12 +5514,6 @@ | ||
| 5507 | 5514 | "node": ">=0.10" |
| 5508 | 5515 | } |
| 5509 | 5516 | }, |
| 5510 | − "node_modules/leaflet": { | |
| 5511 | − "version": "1.9.4", | |
| 5512 | − "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", | |
| 5513 | − "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", | |
| 5514 | − "license": "BSD-2-Clause" | |
| 5515 | − }, | |
| 5516 | 5517 | "node_modules/levn": { |
| 5517 | 5518 | "version": "0.4.1", |
| 5518 | 5519 | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", |
@@ -5875,6 +5876,18 @@ | ||
| 5875 | 5876 | "@jridgewell/sourcemap-codec": "^1.5.5" |
| 5876 | 5877 | } |
| 5877 | 5878 | }, |
| 5879 | + "node_modules/mapbox-gl": { | |
| 5880 | + "version": "3.28.1", | |
| 5881 | + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz", | |
| 5882 | + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==", | |
| 5883 | + "license": "SEE LICENSE IN LICENSE.txt", | |
| 5884 | + "workspaces": [ | |
| 5885 | + "src/style-spec", | |
| 5886 | + "plugins/mapbox-gl-pmtiles-provider", | |
| 5887 | + "test/bundlers/*", | |
| 5888 | + "test/build/typings" | |
| 5889 | + ] | |
| 5890 | + }, | |
| 5878 | 5891 | "node_modules/math-intrinsics": { |
| 5879 | 5892 | "version": "1.1.0", |
| 5880 | 5893 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", |
modified
package.json
+2 −2
@@ -11,8 +11,9 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | 13 | "@anthropic-ai/sdk": "^0.116.0", |
| 14 | + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz", | |
| 14 | 15 | "better-sqlite3": "^13.0.3", |
| 15 | − "leaflet": "^1.9.4", | |
| 16 | + "mapbox-gl": "^3.28.1", | |
| 16 | 17 | "next": "16.3.0", |
| 17 | 18 | "pdfkit": "^0.19.1", |
| 18 | 19 | "react": "19.2.8", |
@@ -21,7 +22,6 @@ | ||
| 21 | 22 | "devDependencies": { |
| 22 | 23 | "@tailwindcss/postcss": "^4", |
| 23 | 24 | "@types/better-sqlite3": "^9.6.0", |
| 24 | − "@types/leaflet": "^1.9.22", | |
| 25 | 25 | "@types/node": "^20", |
| 26 | 26 | "@types/pdfkit": "^0.17.6", |
| 27 | 27 | "@types/react": "^19", |
modified
src/app/api/nearby/route.ts
+6 −3
@@ -4,9 +4,12 @@ | ||
| 4 | 4 | import { NextRequest, NextResponse } from "next/server"; |
| 5 | 5 | import { unitsNear } from "@/lib/db"; |
| 6 | 6 | |
| 7 | −const MAX_HALF_LAT = 0.02; // ~2,2 km | |
| 8 | −const MAX_HALF_LNG = 0.03; | |
| 9 | −const MAX_LIMIT = 400; | |
| 7 | +// Plafonds relevés pour Vrai-Prix Maps (Ka Maps) : les valeurs par défaut | |
| 8 | +// restent inchangées — l'app iOS 1.1.0 en place reçoit exactement le même | |
| 9 | +// contrat (changement additif ; ses gouttières disparaissent même). | |
| 10 | +const MAX_HALF_LAT = 0.08; // ~8,9 km | |
| 11 | +const MAX_HALF_LNG = 0.12; | |
| 12 | +const MAX_LIMIT = 1500; | |
| 10 | 13 | |
| 11 | 14 | export function GET(req: NextRequest) { |
| 12 | 15 | const q = req.nextUrl.searchParams; |
modified
src/app/carte/CartePage.tsx
+22 −8
@@ -1,13 +1,27 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix) | |
| 2 | 3 | "use client"; |
| 3 | −import MapSearch from "@/components/MapSearch"; | |
| 4 | +import dynamic from "next/dynamic"; | |
| 4 | 5 | import { useLang } from "@/components/LangContext"; |
| 5 | 6 | |
| 7 | +// Vrai-Prix Maps (framework Ka Maps) — Mapbox GL est navigateur-seul : | |
| 8 | +// chargé dynamiquement sans SSR (voir docs Next : lazy-loading). | |
| 9 | +const KaCarte = dynamic(() => import("@/components/KaCarte"), { | |
| 10 | + ssr: false, | |
| 11 | + loading: () => ( | |
| 12 | + <div className="vpm-wrap flex items-center justify-center"> | |
| 13 | + <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3"> | |
| 14 | + Chargement de la carte… | |
| 15 | + </span> | |
| 16 | + </div> | |
| 17 | + ), | |
| 18 | +}); | |
| 19 | + | |
| 6 | 20 | export default function CartePage() { |
| 7 | 21 | const { lang } = useLang(); |
| 8 | 22 | const fr = lang === "fr"; |
| 9 | 23 | return ( |
| 10 | − <main className="mx-auto w-full max-w-5xl px-4 pb-16 pt-8 md:pt-12"> | |
| 24 | + <div className="mx-auto w-full max-w-5xl px-4 pb-16 pt-8 md:pt-12"> | |
| 11 | 25 | <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p> |
| 12 | 26 | <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink"> |
| 13 | 27 | {fr ? "Votre quartier, " : "Your neighbourhood, "} |
@@ -15,17 +29,17 @@ export default function CartePage() { | ||
| 15 | 29 | </h1> |
| 16 | 30 | <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2"> |
| 17 | 31 | {fr |
| 18 | − ? "Déplacez la carte ou géolocalisez-vous : chaque point est une unité d'évaluation avec son estimation 2026. Touchez un point pour l'aperçu, puis passez à l'estimation complète." | |
| 19 | − : "Pan the map or use your location: every dot is an assessment unit with its 2026 estimate. Tap a dot for a preview, then get the full estimate."} | |
| 32 | + ? "Déplacez la carte ou géolocalisez-vous : chaque pastille est une unité d'évaluation avec sa valeur estimée 2026 — en 3D, comme dans votre quartier. Touchez une pastille pour l'aperçu, puis passez à l'estimation complète." | |
| 33 | + : "Pan the map or use your location: every pill is an assessment unit with its 2026 estimated value — in 3D, like your neighbourhood. Tap a pill for a preview, then get the full estimate."} | |
| 20 | 34 | </p> |
| 21 | 35 | <div className="mt-7"> |
| 22 | − <MapSearch /> | |
| 36 | + <KaCarte /> | |
| 23 | 37 | </div> |
| 24 | 38 | <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> |
| 25 | 39 | {fr |
| 26 | − ? "Fond de carte © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)." | |
| 27 | − : "Basemap © OpenStreetMap · Statistical estimates for information only (OEAQ)."} | |
| 40 | + ? "Vrai-Prix Maps — framework Ka Maps par Groupe Ka · Fond © Mapbox © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)." | |
| 41 | + : "Vrai-Prix Maps — Ka Maps framework by Groupe Ka · Basemap © Mapbox © OpenStreetMap · Statistical estimates for information only (OEAQ)."} | |
| 28 | 42 | </p> |
| 29 | − </main> | |
| 43 | + </div> | |
| 30 | 44 | ); |
| 31 | 45 | } |
modified
src/app/globals.css
+56 −11
@@ -233,20 +233,65 @@ select.vp-input { | ||
| 233 | 233 | .hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } |
| 234 | 234 | |
| 235 | 235 | |
| 236 | −/* recherche par carte — popup Leaflet au style éditorial */ | |
| 237 | −.vp-map-popup .leaflet-popup-content-wrapper { | |
| 238 | − background: var(--surface); | |
| 236 | +/* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */ | |
| 237 | +.vpm-wrap { | |
| 238 | + position: relative; | |
| 239 | + height: 62vh; | |
| 240 | + min-height: 420px; | |
| 241 | + width: 100%; | |
| 239 | 242 | border: 1.5px solid var(--ink); |
| 240 | − border-radius: 14px; | |
| 241 | − box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.25); | |
| 243 | + border-radius: 16px; | |
| 244 | + overflow: hidden; | |
| 245 | + box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.22); | |
| 246 | + z-index: 0; | |
| 247 | +} | |
| 248 | +.vpm-wrap .ka-map { | |
| 249 | + --ka-accent: var(--lime); | |
| 250 | + --ka-on-accent: #ffffff; | |
| 251 | + --ka-surface: var(--surface); | |
| 252 | + --ka-ink: var(--ink); | |
| 253 | + --ka-line: var(--ink); | |
| 254 | + --ka-radius: 10px; | |
| 255 | + --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); | |
| 256 | + --ka-font: var(--font-inter), system-ui, sans-serif; | |
| 257 | + position: absolute; | |
| 258 | + inset: 0; | |
| 242 | 259 | } |
| 243 | −.vp-map-popup .leaflet-popup-content { margin: 14px 16px; } | |
| 244 | −.vp-map-popup .leaflet-popup-tip { | |
| 260 | +.vpm-chip { | |
| 261 | + position: absolute; | |
| 262 | + bottom: 12px; | |
| 263 | + left: 12px; | |
| 264 | + z-index: 9; | |
| 245 | 265 | background: var(--surface); |
| 266 | + color: var(--ink); | |
| 246 | 267 | border: 1.5px solid var(--ink); |
| 268 | + border-radius: 999px; | |
| 269 | + padding: 6px 12px; | |
| 270 | + font-family: var(--font-jetbrains), monospace; | |
| 271 | + font-size: 10.5px; | |
| 272 | + text-transform: uppercase; | |
| 273 | + letter-spacing: 0.06em; | |
| 247 | 274 | } |
| 248 | −.vp-map-popup .leaflet-popup-close-button { | |
| 249 | − font-size: 18px; | |
| 250 | − color: var(--ink); | |
| 251 | − padding: 6px 8px 0 0; | |
| 275 | +.vpm-pop { padding: 16px 16px 14px; min-width: 230px; } | |
| 276 | +.vpm-pop-value { | |
| 277 | + font-family: var(--font-space-grotesk), sans-serif; | |
| 278 | + font-weight: 700; | |
| 279 | + font-size: 22px; | |
| 280 | + letter-spacing: -0.02em; | |
| 281 | + margin-top: 2px; | |
| 282 | +} | |
| 283 | +.vpm-pop-addr { font-size: 13.5px; color: var(--ink); margin-top: 6px; } | |
| 284 | +.vpm-pop-meta { font-size: 12px; color: var(--ink-2); margin-top: 2px; } | |
| 285 | +.vpm-pop-cta { | |
| 286 | + display: inline-block; | |
| 287 | + margin-top: 12px; | |
| 288 | + padding: 9px 14px; | |
| 289 | + background: var(--ink); | |
| 290 | + color: var(--lime); | |
| 291 | + border: 1.5px solid var(--ink); | |
| 292 | + border-radius: 999px; | |
| 293 | + font-size: 12.5px; | |
| 294 | + font-weight: 600; | |
| 295 | + cursor: pointer; | |
| 252 | 296 | } |
| 297 | +.vpm-pop-cta:active { transform: translate(2px, 2px); } | |
added
src/components/KaCarte.tsx
+107 −0
@@ -0,0 +1,107 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix) | |
| 4 | +// components/KaCarte.tsx : Vrai-Prix Maps — recherche par carte propulsée | |
| 5 | +// par le framework partagé Ka Maps (moteur Mapbox GL, style Standard 3D par | |
| 6 | +// défaut + bouton 2D). Remplace l'ancienne carte Leaflet/OSM : pastilles de | |
| 7 | +// VALEURS ESTIMÉES 2026 (grappes + moyennes), chargement piloté par le | |
| 8 | +// viewport (annulation, cache), aperçu React, bilingue FR/EN. | |
| 9 | +// À charger via next/dynamic({ ssr: false }) — Mapbox GL est navigateur-seul. | |
| 10 | + | |
| 11 | +import { useState } from "react"; | |
| 12 | +import { useRouter } from "next/navigation"; | |
| 13 | +import "mapbox-gl/dist/mapbox-gl.css"; | |
| 14 | +import "@groupe-ka/ka-maps/styles.css"; | |
| 15 | +import type { MapProperty } from "@groupe-ka/ka-maps"; | |
| 16 | +import { formatFullPrice } from "@groupe-ka/ka-maps"; | |
| 17 | +import { | |
| 18 | + KaBrandBadge, | |
| 19 | + KaMapView, | |
| 20 | + LoadingIndicator, | |
| 21 | + LocateControl, | |
| 22 | + PropertyPreview, | |
| 23 | + Tilt3DControl, | |
| 24 | +} from "@groupe-ka/ka-maps/react"; | |
| 25 | +import { useLang } from "@/components/LangContext"; | |
| 26 | +import { | |
| 27 | + MAPBOX_TOKEN, | |
| 28 | + MIN_ZOOM_FETCH, | |
| 29 | + vraiPrixMapAdapter, | |
| 30 | + vraiPrixMapTheme, | |
| 31 | +} from "@/lib/kamaps"; | |
| 32 | + | |
| 33 | +const DEFAULT_CENTER = { lat: 45.5421, lng: -73.6646 }; // Montréal | |
| 34 | + | |
| 35 | +/** Aperçu d'une unité d'évaluation — valeur estimée, jamais un prix. */ | |
| 36 | +function PreviewCard({ p, fr, close }: { p: MapProperty; fr: boolean; close: () => void }) { | |
| 37 | + const router = useRouter(); | |
| 38 | + return ( | |
| 39 | + <div className="vpm-pop"> | |
| 40 | + <div className="klabel">{fr ? "Estimation 2026" : "2026 estimate"}</div> | |
| 41 | + <div className="vpm-pop-value"> | |
| 42 | + {p.estimatedValue != null ? formatFullPrice(p.estimatedValue) : "—"} | |
| 43 | + </div> | |
| 44 | + <div className="vpm-pop-addr">{p.address}</div> | |
| 45 | + <div className="vpm-pop-meta"> | |
| 46 | + {[p.city, p.propertyType].filter(Boolean).join(" · ")} | |
| 47 | + </div> | |
| 48 | + <button | |
| 49 | + type="button" | |
| 50 | + className="vpm-pop-cta" | |
| 51 | + onClick={() => { | |
| 52 | + close(); | |
| 53 | + router.push(p.originalUrl ?? "/"); | |
| 54 | + }} | |
| 55 | + > | |
| 56 | + {fr ? "Voir l'estimation complète →" : "See full estimate →"} | |
| 57 | + </button> | |
| 58 | + </div> | |
| 59 | + ); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export default function KaCarte() { | |
| 63 | + const { lang } = useLang(); | |
| 64 | + const fr = lang === "fr"; | |
| 65 | + const [zoom, setZoom] = useState(13); | |
| 66 | + const [count, setCount] = useState<number | null>(null); | |
| 67 | + | |
| 68 | + const zoomedOut = zoom < MIN_ZOOM_FETCH; | |
| 69 | + | |
| 70 | + return ( | |
| 71 | + <div className="vpm-wrap"> | |
| 72 | + <KaMapView | |
| 73 | + theme={vraiPrixMapTheme} | |
| 74 | + adapter={vraiPrixMapAdapter} | |
| 75 | + mapboxToken={MAPBOX_TOKEN} | |
| 76 | + center={DEFAULT_CENTER} | |
| 77 | + zoom={13.5} | |
| 78 | + pitch={50} | |
| 79 | + searchMode="auto" | |
| 80 | + cluster={{ maxZoom: 15, radius: 44, valueClamp: [40_000, 5_000_000] }} | |
| 81 | + onMoveEnd={(_c, z) => setZoom(z)} | |
| 82 | + onData={(c) => setCount(c)} | |
| 83 | + > | |
| 84 | + <KaBrandBadge /> | |
| 85 | + <Tilt3DControl /> | |
| 86 | + <LocateControl label={fr ? "Me localiser" : "Locate me"} /> | |
| 87 | + <LoadingIndicator label={fr ? "Mise à jour des estimations…" : "Updating estimates…"} /> | |
| 88 | + <PropertyPreview | |
| 89 | + render={(p, close) => <PreviewCard p={p} fr={fr} close={close} />} | |
| 90 | + /> | |
| 91 | + <div className="vpm-chip" role="status"> | |
| 92 | + {zoomedOut | |
| 93 | + ? fr | |
| 94 | + ? "Zoomez pour voir les valeurs estimées" | |
| 95 | + : "Zoom in to see estimated values" | |
| 96 | + : count != null | |
| 97 | + ? fr | |
| 98 | + ? `${count.toLocaleString("fr-CA")} propriétés affichées` | |
| 99 | + : `${count.toLocaleString("en-CA")} properties shown` | |
| 100 | + : fr | |
| 101 | + ? "Chargement…" | |
| 102 | + : "Loading…"} | |
| 103 | + </div> | |
| 104 | + </KaMapView> | |
| 105 | + </div> | |
| 106 | + ); | |
| 107 | +} | |
deleted
src/components/MapSearch.tsx
+0 −206
@@ -1,206 +0,0 @@ | ||
| 1 | −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | −/** | |
| 3 | − * Recherche par carte : propriétés avoisinantes sur fond OpenStreetMap, | |
| 4 | − * géolocalisation de l'utilisateur, popup d'aperçu → estimation complète. | |
| 5 | − * Leaflet est chargé dynamiquement dans useEffect (jamais côté serveur). | |
| 6 | − */ | |
| 7 | −"use client"; | |
| 8 | −import "leaflet/dist/leaflet.css"; | |
| 9 | −import { useEffect, useRef, useState } from "react"; | |
| 10 | −import type { Map as LeafletMap, LayerGroup } from "leaflet"; | |
| 11 | −import { useLang } from "./LangContext"; | |
| 12 | − | |
| 13 | −interface NearbyUnit { | |
| 14 | − id: string; | |
| 15 | − adresse: string | null; | |
| 16 | − apt: string | null; | |
| 17 | − municipalite: string | null; | |
| 18 | − typeProp: string; | |
| 19 | − lat: number; | |
| 20 | − lng: number; | |
| 21 | − est2026: number | null; | |
| 22 | −} | |
| 23 | − | |
| 24 | −const MIN_ZOOM_FETCH = 15; | |
| 25 | −const DEFAULT_CENTER: [number, number] = [45.5421, -73.6646]; // Montréal | |
| 26 | −const TYPE_FR: Record<string, string> = { | |
| 27 | − unifamilial: "Unifamiliale", | |
| 28 | − plex: "Plex", | |
| 29 | − condo_ou_multi: "Condo / multi", | |
| 30 | − chalet: "Chalet", | |
| 31 | − maison_mobile: "Maison mobile", | |
| 32 | − terrain: "Terrain", | |
| 33 | − autre: "Autre", | |
| 34 | −}; | |
| 35 | − | |
| 36 | −const fmtCad = (v: number | null, lang: string) => | |
| 37 | − v == null | |
| 38 | − ? "—" | |
| 39 | − : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { | |
| 40 | − style: "currency", | |
| 41 | − currency: "CAD", | |
| 42 | − maximumFractionDigits: 0, | |
| 43 | − }).format(v); | |
| 44 | − | |
| 45 | −export default function MapSearch() { | |
| 46 | − const { lang } = useLang(); | |
| 47 | − const fr = lang === "fr"; | |
| 48 | − const box = useRef<HTMLDivElement>(null); | |
| 49 | − const mapRef = useRef<LeafletMap | null>(null); | |
| 50 | − const layerRef = useRef<LayerGroup | null>(null); | |
| 51 | − const abortRef = useRef<AbortController | null>(null); | |
| 52 | − const langRef = useRef(lang); | |
| 53 | − useEffect(() => { | |
| 54 | − langRef.current = lang; | |
| 55 | − }, [lang]); | |
| 56 | − const [count, setCount] = useState(0); | |
| 57 | − const [zoomedOut, setZoomedOut] = useState(false); | |
| 58 | − const [locating, setLocating] = useState(false); | |
| 59 | − | |
| 60 | − useEffect(() => { | |
| 61 | − let cancelled = false; | |
| 62 | − (async () => { | |
| 63 | − const L = await import("leaflet"); | |
| 64 | − if (cancelled || !box.current || mapRef.current) return; | |
| 65 | − | |
| 66 | − const map = L.map(box.current, { | |
| 67 | − center: DEFAULT_CENTER, | |
| 68 | − zoom: 16, | |
| 69 | − zoomControl: true, | |
| 70 | − }); | |
| 71 | − mapRef.current = map; | |
| 72 | − L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { | |
| 73 | − maxZoom: 19, | |
| 74 | − attribution: | |
| 75 | − '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', | |
| 76 | − }).addTo(map); | |
| 77 | − const layer = L.layerGroup().addTo(map); | |
| 78 | − layerRef.current = layer; | |
| 79 | − | |
| 80 | − const dot = () => | |
| 81 | − L.divIcon({ | |
| 82 | − className: "", | |
| 83 | − iconSize: [16, 16], | |
| 84 | − iconAnchor: [8, 8], | |
| 85 | − popupAnchor: [0, -10], | |
| 86 | − html: `<div style="width:16px;height:16px;border-radius:50%;background:#141814;border:2.5px solid #ff5148;box-shadow:0 1px 4px rgba(20,24,20,.4)"></div>`, | |
| 87 | − }); | |
| 88 | − | |
| 89 | − const popupHtml = (u: NearbyUnit) => { | |
| 90 | − const isFr = langRef.current === "fr"; | |
| 91 | − const addr = `${u.adresse ?? "—"}${u.apt ? ` app. ${u.apt}` : ""}`; | |
| 92 | − return ` | |
| 93 | − <div style="font-family:var(--font-inter),Inter,sans-serif;min-width:210px"> | |
| 94 | − <div style="font-weight:700;font-size:14px;color:#141814">${addr}</div> | |
| 95 | − <div style="font-size:12px;color:#4d5551;margin-top:2px"> | |
| 96 | − ${u.municipalite ?? "—"} · ${TYPE_FR[u.typeProp] ?? u.typeProp} | |
| 97 | − </div> | |
| 98 | − <div style="margin-top:8px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#8b928c"> | |
| 99 | − ${isFr ? "Estimation 2026" : "2026 estimate"} | |
| 100 | − </div> | |
| 101 | − <div style="font-size:19px;font-weight:700;color:#141814">${fmtCad(u.est2026, langRef.current)}</div> | |
| 102 | − <a href="/estimation/${u.id}" | |
| 103 | − style="display:inline-block;margin-top:10px;background:#141814;color:#ff5148;font-weight:700;font-size:13px;padding:8px 16px;border-radius:999px;text-decoration:none"> | |
| 104 | − ${isFr ? "Estimer en détail →" : "Full estimate →"} | |
| 105 | − </a> | |
| 106 | − </div>`; | |
| 107 | − }; | |
| 108 | − | |
| 109 | − const refresh = async () => { | |
| 110 | − const z = map.getZoom(); | |
| 111 | − if (z < MIN_ZOOM_FETCH) { | |
| 112 | − setZoomedOut(true); | |
| 113 | − layer.clearLayers(); | |
| 114 | − setCount(0); | |
| 115 | − return; | |
| 116 | − } | |
| 117 | − setZoomedOut(false); | |
| 118 | − const b = map.getBounds(); | |
| 119 | − const c = b.getCenter(); | |
| 120 | − const halfLat = Math.abs(b.getNorth() - c.lat); | |
| 121 | − const halfLng = Math.abs(b.getEast() - c.lng); | |
| 122 | − abortRef.current?.abort(); | |
| 123 | − const ctrl = new AbortController(); | |
| 124 | − abortRef.current = ctrl; | |
| 125 | − try { | |
| 126 | − const res = await fetch( | |
| 127 | − `/api/nearby?lat=${c.lat}&lng=${c.lng}&halfLat=${halfLat}&halfLng=${halfLng}&limit=400`, | |
| 128 | − { signal: ctrl.signal } | |
| 129 | − ); | |
| 130 | − if (!res.ok) return; | |
| 131 | − const data = (await res.json()) as { results: NearbyUnit[] }; | |
| 132 | − layer.clearLayers(); | |
| 133 | − for (const u of data.results) { | |
| 134 | − L.marker([u.lat, u.lng], { icon: dot() }) | |
| 135 | − .bindPopup(popupHtml(u), { className: "vp-map-popup" }) | |
| 136 | − .addTo(layer); | |
| 137 | − } | |
| 138 | − setCount(data.results.length); | |
| 139 | − } catch { | |
| 140 | − /* requête annulée ou réseau — silencieux */ | |
| 141 | − } | |
| 142 | − }; | |
| 143 | − | |
| 144 | − map.on("moveend", refresh); | |
| 145 | − void refresh(); | |
| 146 | − })(); | |
| 147 | − return () => { | |
| 148 | − cancelled = true; | |
| 149 | − abortRef.current?.abort(); | |
| 150 | − mapRef.current?.remove(); | |
| 151 | − mapRef.current = null; | |
| 152 | − }; | |
| 153 | − }, []); | |
| 154 | − | |
| 155 | − const locate = () => { | |
| 156 | − if (!navigator.geolocation || !mapRef.current) return; | |
| 157 | − setLocating(true); | |
| 158 | − navigator.geolocation.getCurrentPosition( | |
| 159 | − async (pos) => { | |
| 160 | − setLocating(false); | |
| 161 | − const { latitude, longitude } = pos.coords; | |
| 162 | − const L = await import("leaflet"); | |
| 163 | − const map = mapRef.current; | |
| 164 | − if (!map) return; | |
| 165 | − map.setView([latitude, longitude], 16); | |
| 166 | − L.marker([latitude, longitude], { | |
| 167 | − icon: L.divIcon({ | |
| 168 | − className: "", | |
| 169 | − iconSize: [22, 22], | |
| 170 | − iconAnchor: [11, 11], | |
| 171 | − html: `<div style="width:22px;height:22px;border-radius:50%;background:#ff5148;border:3px solid #ffffff;box-shadow:0 0 0 4px rgba(255,81,72,.3)"></div>`, | |
| 172 | − }), | |
| 173 | − }).addTo(map); | |
| 174 | − }, | |
| 175 | − () => setLocating(false), | |
| 176 | − { enableHighAccuracy: true, timeout: 10000 } | |
| 177 | − ); | |
| 178 | − }; | |
| 179 | − | |
| 180 | − return ( | |
| 181 | − <div className="relative"> | |
| 182 | − <div | |
| 183 | − ref={box} | |
| 184 | − className="h-[62vh] min-h-[420px] w-full rounded-2xl border-[1.5px] border-ink" | |
| 185 | − style={{ boxShadow: "6px 6px 0 rgba(20,24,20,0.22)", zIndex: 0 }} | |
| 186 | − /> | |
| 187 | − {/* bouton géolocalisation */} | |
| 188 | − <button | |
| 189 | − onClick={locate} | |
| 190 | − className="vp-display absolute right-3 top-3 z-[1000] inline-flex min-h-[40px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-ink px-4 py-2 text-[13px] font-bold text-lime shadow-[3px_3px_0_rgba(20,24,20,0.3)]" | |
| 191 | − > | |
| 192 | − {locating ? (fr ? "Localisation…" : "Locating…") : fr ? "◎ Me localiser" : "◎ Locate me"} | |
| 193 | − </button> | |
| 194 | − {/* compteur / garde de zoom */} | |
| 195 | − <div className="vp-mono absolute bottom-3 left-3 z-[1000] rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.06em] text-ink"> | |
| 196 | − {zoomedOut | |
| 197 | − ? fr | |
| 198 | − ? "Zoomez pour voir les propriétés" | |
| 199 | − : "Zoom in to see properties" | |
| 200 | − : fr | |
| 201 | − ? `${count} propriétés affichées` | |
| 202 | − : `${count} properties shown`} | |
| 203 | − </div> | |
| 204 | − </div> | |
| 205 | − ); | |
| 206 | −} | |
added
src/lib/kamaps.ts
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix) | |
| 3 | +// lib/kamaps.ts : thème Vrai-Prix Maps + adaptateur /api/nearby. | |
| 4 | +// La carte affiche des VALEURS ESTIMÉES (jamais des prix demandés) : | |
| 5 | +// pastilles encre à liseré rouge #ff5148, sélection rouge — le langage | |
| 6 | +// visuel du site (encre #141814, « lime » rouge, bordeaux #9e2a25). | |
| 7 | + | |
| 8 | +import type { | |
| 9 | + BoundsQuery, | |
| 10 | + BoundsQueryResult, | |
| 11 | + KaDataAdapter, | |
| 12 | + KaMapTheme, | |
| 13 | + MapProperty, | |
| 14 | +} from "@groupe-ka/ka-maps"; | |
| 15 | + | |
| 16 | +const INK = "#141814"; | |
| 17 | +const RED = "#ff5148"; // --lime (nom hérité) | |
| 18 | +const BORDEAUX = "#9e2a25"; // --green (nom hérité) | |
| 19 | + | |
| 20 | +export const MAPBOX_TOKEN: string = | |
| 21 | + process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? | |
| 22 | + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"; | |
| 23 | + | |
| 24 | +/** Zoom minimal de chargement : sous ce niveau, on invite à zoomer. */ | |
| 25 | +export const MIN_ZOOM_FETCH = 12; | |
| 26 | + | |
| 27 | +export const TYPE_FR: Record<string, string> = { | |
| 28 | + unifamilial: "Unifamiliale", | |
| 29 | + condo_ou_multi: "Condo / multi", | |
| 30 | + plex: "Plex", | |
| 31 | + chalet: "Chalet", | |
| 32 | + terrain: "Terrain", | |
| 33 | + maison_mobile: "Maison mobile", | |
| 34 | + autre: "Autre", | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export const vraiPrixMapTheme: KaMapTheme = { | |
| 38 | + id: "vrai-prix", | |
| 39 | + productName: "Vrai-Prix Maps", | |
| 40 | + accent: RED, | |
| 41 | + onAccent: "#ffffff", | |
| 42 | + fontFamily: "var(--font-inter), system-ui, sans-serif", | |
| 43 | + markers: { | |
| 44 | + sale: { | |
| 45 | + background: INK, | |
| 46 | + text: "#ffffff", | |
| 47 | + halo: RED, | |
| 48 | + selectedBackground: RED, | |
| 49 | + selectedText: "#ffffff", | |
| 50 | + }, | |
| 51 | + // Valeurs estimées : pastille encre, liseré rouge — visuellement | |
| 52 | + // distinctes d'un prix demandé (jamais présentées comme une annonce). | |
| 53 | + valuation: { | |
| 54 | + background: INK, | |
| 55 | + text: "#ffffff", | |
| 56 | + halo: RED, | |
| 57 | + selectedBackground: RED, | |
| 58 | + selectedText: "#ffffff", | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + cluster: { | |
| 62 | + background: BORDEAUX, | |
| 63 | + text: "#ffffff", | |
| 64 | + border: "rgba(255,81,72,0.5)", | |
| 65 | + }, | |
| 66 | + supportsDark: false, | |
| 67 | +}; | |
| 68 | + | |
| 69 | +interface NearbyUnit { | |
| 70 | + id: string; | |
| 71 | + adresse: string | null; | |
| 72 | + apt: string | null; | |
| 73 | + municipalite: string | null; | |
| 74 | + typeProp: string | null; | |
| 75 | + lat: number; | |
| 76 | + lng: number; | |
| 77 | + est2026: number | null; | |
| 78 | +} | |
| 79 | + | |
| 80 | +function toMapProperty(u: NearbyUnit): MapProperty { | |
| 81 | + return { | |
| 82 | + id: u.id, | |
| 83 | + appSource: "vrai-prix", | |
| 84 | + latitude: u.lat, | |
| 85 | + longitude: u.lng, | |
| 86 | + kind: "valuation", | |
| 87 | + estimatedValue: u.est2026 ?? undefined, | |
| 88 | + propertyType: u.typeProp ? TYPE_FR[u.typeProp] ?? u.typeProp : undefined, | |
| 89 | + address: u.apt ? `${u.adresse} app. ${u.apt}` : u.adresse ?? undefined, | |
| 90 | + city: u.municipalite ?? undefined, | |
| 91 | + originalUrl: `/estimation/${u.id}`, | |
| 92 | + extra: { typeProp: u.typeProp }, | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Adaptateur viewport→données : /api/nearby (contrat partagé avec iOS). */ | |
| 97 | +export const vraiPrixMapAdapter: KaDataAdapter = { | |
| 98 | + id: "vrai-prix-nearby", | |
| 99 | + appSource: "vrai-prix", | |
| 100 | + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> { | |
| 101 | + if (query.zoom < MIN_ZOOM_FETCH) return { properties: [] }; | |
| 102 | + const { bbox } = query; | |
| 103 | + const lat = (bbox.north + bbox.south) / 2; | |
| 104 | + const lng = (bbox.east + bbox.west) / 2; | |
| 105 | + const halfLat = Math.abs(bbox.north - lat); | |
| 106 | + const halfLng = Math.abs(bbox.east - lng); | |
| 107 | + const params = new URLSearchParams({ | |
| 108 | + lat: lat.toFixed(6), | |
| 109 | + lng: lng.toFixed(6), | |
| 110 | + halfLat: halfLat.toFixed(6), | |
| 111 | + halfLng: halfLng.toFixed(6), | |
| 112 | + limit: "1500", | |
| 113 | + }); | |
| 114 | + const res = await fetch(`/api/nearby?${params}`, { signal: query.signal }); | |
| 115 | + if (!res.ok) throw new Error(`Carte : API ${res.status}`); | |
| 116 | + const data = (await res.json()) as { results: NearbyUnit[] }; | |
| 117 | + return { properties: data.results.map(toMapProperty) }; | |
| 118 | + }, | |
| 119 | +}; | |
| 120 | ||