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 | const nextConfig: NextConfig = { | 4 | const nextConfig: NextConfig = { | ||
| 5 | serverExternalPackages: ["better-sqlite3", "pdfkit"], | 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 | export default nextConfig; | 10 | export default nextConfig; | ||
modified
package-lock.json
+32 −19
@@ -9,8 +9,9 @@ | |||||
| 9 | "version": "0.1.0", | 9 | "version": "0.1.0", | ||
| 10 | "dependencies": { | 10 | "dependencies": { | ||
| 11 | "@anthropic-ai/sdk": "^0.116.0", | 11 | "@anthropic-ai/sdk": "^0.116.0", | ||
| 12 | + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz", | ||||
| 12 | "better-sqlite3": "^13.0.3", | 13 | "better-sqlite3": "^13.0.3", | ||
| 13 | − "leaflet": "^1.9.4", | 14 | + "mapbox-gl": "^3.28.1", | ||
| 14 | "next": "16.3.0", | 15 | "next": "16.3.0", | ||
| 15 | "pdfkit": "^0.19.1", | 16 | "pdfkit": "^0.19.1", | ||
| 16 | "react": "19.2.8", | 17 | "react": "19.2.8", | ||
@@ -19,7 +20,6 @@ | |||||
| 19 | "devDependencies": { | 20 | "devDependencies": { | ||
| 20 | "@tailwindcss/postcss": "^4", | 21 | "@tailwindcss/postcss": "^4", | ||
| 21 | "@types/better-sqlite3": "^9.6.0", | 22 | "@types/better-sqlite3": "^9.6.0", | ||
| 22 | − "@types/leaflet": "^1.9.22", | ||||
| 23 | "@types/node": "^20", | 23 | "@types/node": "^20", | ||
| 24 | "@types/pdfkit": "^0.17.6", | 24 | "@types/pdfkit": "^0.17.6", | ||
| 25 | "@types/react": "^19", | 25 | "@types/react": "^19", | ||
@@ -492,6 +492,24 @@ | |||||
| 492 | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | 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 | "node_modules/@humanfs/core": { | 513 | "node_modules/@humanfs/core": { | ||
| 496 | "version": "0.19.2", | 514 | "version": "0.19.2", | ||
| 497 | "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", | 515 | "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", | ||
@@ -2075,7 +2093,6 @@ | |||||
| 2075 | "version": "7946.0.16", | 2093 | "version": "7946.0.16", | ||
| 2076 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", | 2094 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", | ||
| 2077 | "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", | 2095 | "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", | ||
| 2078 | − "dev": true, | ||||
| 2079 | "license": "MIT" | 2096 | "license": "MIT" | ||
| 2080 | }, | 2097 | }, | ||
| 2081 | "node_modules/@types/json-schema": { | 2098 | "node_modules/@types/json-schema": { | ||
@@ -2092,16 +2109,6 @@ | |||||
| 2092 | "dev": true, | 2109 | "dev": true, | ||
| 2093 | "license": "MIT" | 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 | "node_modules/@types/node": { | 2112 | "node_modules/@types/node": { | ||
| 2106 | "version": "20.19.43", | 2113 | "version": "20.19.43", | ||
| 2107 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", | 2114 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", | ||
@@ -5507,12 +5514,6 @@ | |||||
| 5507 | "node": ">=0.10" | 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 | "node_modules/levn": { | 5517 | "node_modules/levn": { | ||
| 5517 | "version": "0.4.1", | 5518 | "version": "0.4.1", | ||
| 5518 | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | 5519 | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | ||
@@ -5875,6 +5876,18 @@ | |||||
| 5875 | "@jridgewell/sourcemap-codec": "^1.5.5" | 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 | "node_modules/math-intrinsics": { | 5891 | "node_modules/math-intrinsics": { | ||
| 5879 | "version": "1.1.0", | 5892 | "version": "1.1.0", | ||
| 5880 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", | 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 | "dependencies": { | 12 | "dependencies": { | ||
| 13 | "@anthropic-ai/sdk": "^0.116.0", | 13 | "@anthropic-ai/sdk": "^0.116.0", | ||
| 14 | + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz", | ||||
| 14 | "better-sqlite3": "^13.0.3", | 15 | "better-sqlite3": "^13.0.3", | ||
| 15 | − "leaflet": "^1.9.4", | 16 | + "mapbox-gl": "^3.28.1", | ||
| 16 | "next": "16.3.0", | 17 | "next": "16.3.0", | ||
| 17 | "pdfkit": "^0.19.1", | 18 | "pdfkit": "^0.19.1", | ||
| 18 | "react": "19.2.8", | 19 | "react": "19.2.8", | ||
@@ -21,7 +22,6 @@ | |||||
| 21 | "devDependencies": { | 22 | "devDependencies": { | ||
| 22 | "@tailwindcss/postcss": "^4", | 23 | "@tailwindcss/postcss": "^4", | ||
| 23 | "@types/better-sqlite3": "^9.6.0", | 24 | "@types/better-sqlite3": "^9.6.0", | ||
| 24 | − "@types/leaflet": "^1.9.22", | ||||
| 25 | "@types/node": "^20", | 25 | "@types/node": "^20", | ||
| 26 | "@types/pdfkit": "^0.17.6", | 26 | "@types/pdfkit": "^0.17.6", | ||
| 27 | "@types/react": "^19", | 27 | "@types/react": "^19", | ||
modified
src/app/api/nearby/route.ts
+6 −3
@@ -4,9 +4,12 @@ | |||||
| 4 | import { NextRequest, NextResponse } from "next/server"; | 4 | import { NextRequest, NextResponse } from "next/server"; | ||
| 5 | import { unitsNear } from "@/lib/db"; | 5 | import { unitsNear } from "@/lib/db"; | ||
| 6 | 6 | ||||
| 7 | −const MAX_HALF_LAT = 0.02; // ~2,2 km | 7 | +// Plafonds relevés pour Vrai-Prix Maps (Ka Maps) : les valeurs par défaut | ||
| 8 | −const MAX_HALF_LNG = 0.03; | 8 | +// restent inchangées — l'app iOS 1.1.0 en place reçoit exactement le même | ||
| 9 | −const MAX_LIMIT = 400; | 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 | export function GET(req: NextRequest) { | 14 | export function GET(req: NextRequest) { | ||
| 12 | const q = req.nextUrl.searchParams; | 15 | const q = req.nextUrl.searchParams; | ||
modified
src/app/carte/CartePage.tsx
+22 −8
@@ -1,13 +1,27 @@ | |||||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||
| 2 | +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix) | ||||
| 2 | "use client"; | 3 | "use client"; | ||
| 3 | −import MapSearch from "@/components/MapSearch"; | 4 | +import dynamic from "next/dynamic"; | ||
| 4 | import { useLang } from "@/components/LangContext"; | 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 | export default function CartePage() { | 20 | export default function CartePage() { | ||
| 7 | const { lang } = useLang(); | 21 | const { lang } = useLang(); | ||
| 8 | const fr = lang === "fr"; | 22 | const fr = lang === "fr"; | ||
| 9 | return ( | 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 | <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p> | 25 | <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p> | ||
| 12 | <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink"> | 26 | <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink"> | ||
| 13 | {fr ? "Votre quartier, " : "Your neighbourhood, "} | 27 | {fr ? "Votre quartier, " : "Your neighbourhood, "} | ||
@@ -15,17 +29,17 @@ export default function CartePage() { | |||||
| 15 | </h1> | 29 | </h1> | ||
| 16 | <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2"> | 30 | <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2"> | ||
| 17 | {fr | 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." | 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." | ||
| 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."} | 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 | </p> | 34 | </p> | ||
| 21 | <div className="mt-7"> | 35 | <div className="mt-7"> | ||
| 22 | − <MapSearch /> | 36 | + <KaCarte /> | ||
| 23 | </div> | 37 | </div> | ||
| 24 | <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> | 38 | <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> | ||
| 25 | {fr | 39 | {fr | ||
| 26 | − ? "Fond de carte © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)." | 40 | + ? "Vrai-Prix Maps — framework Ka Maps par Groupe Ka · Fond © Mapbox © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)." | ||
| 27 | − : "Basemap © OpenStreetMap · Statistical estimates for information only (OEAQ)."} | 41 | + : "Vrai-Prix Maps — Ka Maps framework by Groupe Ka · Basemap © Mapbox © OpenStreetMap · Statistical estimates for information only (OEAQ)."} | ||
| 28 | </p> | 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 | .hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } | 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 */ | 236 | +/* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */ | ||
| 237 | −.vp-map-popup .leaflet-popup-content-wrapper { | 237 | +.vpm-wrap { | ||
| 238 | − background: var(--surface); | 238 | + position: relative; | ||
| 239 | + height: 62vh; | ||||
| 240 | + min-height: 420px; | ||||
| 241 | + width: 100%; | ||||
| 239 | border: 1.5px solid var(--ink); | 242 | border: 1.5px solid var(--ink); | ||
| 240 | − border-radius: 14px; | 243 | + border-radius: 16px; | ||
| 241 | − box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.25); | 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; } | 260 | +.vpm-chip { | ||
| 244 | −.vp-map-popup .leaflet-popup-tip { | 261 | + position: absolute; | ||
| 262 | + bottom: 12px; | ||||
| 263 | + left: 12px; | ||||
| 264 | + z-index: 9; | ||||
| 245 | background: var(--surface); | 265 | background: var(--surface); | ||
| 266 | + color: var(--ink); | ||||
| 246 | border: 1.5px solid var(--ink); | 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 { | 275 | +.vpm-pop { padding: 16px 16px 14px; min-width: 230px; } | ||
| 249 | − font-size: 18px; | 276 | +.vpm-pop-value { | ||
| 250 | − color: var(--ink); | 277 | + font-family: var(--font-space-grotesk), sans-serif; | ||
| 251 | − padding: 6px 8px 0 0; | 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 | |||||