SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.2%

Recherche par carte — /carte (Leaflet+OSM) + API /api/nearby

- GET /api/nearby : unités dans une boîte englobante (idx_units_geo),
  tri par distance au centre (sinon l'index renvoie une bande sud),
  plafonds serveur ~2,2 km / 400 unités
- Page /carte : fond OpenStreetMap, points encre/accent, popup éditoriale
  (adresse, type, estimation 2026 → /estimation/[id]), géolocalisation
  « Me localiser », garde de zoom, bilingue FR/EN, entrée nav « Carte »
- Sert aussi l'app iOS native (onglet Carte MapKit, repo vrai-prix-ios)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 5 h ago (Aug 11, 2026) parent 11fbe15

Showing 10 changed files with +376 and −0

modified .gitignore +3 −0
@@ -15,3 +15,6 @@ playwright-report/
15 15
16 16 # secrets — jamais versionnés
17 17 .env*
18 +
19 +# app iOS — repo séparé (vrai-prix-ios sur git.spboucher.ai)
20 +/ios/
modified package-lock.json +25 −0
@@ -10,6 +10,7 @@
10 10 "dependencies": {
11 11 "@anthropic-ai/sdk": "^0.116.0",
12 12 "better-sqlite3": "^13.0.3",
13 + "leaflet": "^1.9.4",
13 14 "next": "16.3.0",
14 15 "pdfkit": "^0.19.1",
15 16 "react": "19.2.8",
@@ -18,6 +19,7 @@
18 19 "devDependencies": {
19 20 "@tailwindcss/postcss": "^4",
20 21 "@types/better-sqlite3": "^9.6.0",
22 + "@types/leaflet": "^1.9.22",
21 23 "@types/node": "^20",
22 24 "@types/pdfkit": "^0.17.6",
23 25 "@types/react": "^19",
@@ -2069,6 +2071,13 @@
2069 2071 "dev": true,
2070 2072 "license": "MIT"
2071 2073 },
2074 + "node_modules/@types/geojson": {
2075 + "version": "7946.0.16",
2076 + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
2077 + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
2078 + "dev": true,
2079 + "license": "MIT"
2080 + },
2072 2081 "node_modules/@types/json-schema": {
2073 2082 "version": "7.0.15",
2074 2083 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2083,6 +2092,16 @@
2083 2092 "dev": true,
2084 2093 "license": "MIT"
2085 2094 },
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 + },
2086 2105 "node_modules/@types/node": {
2087 2106 "version": "20.19.43",
2088 2107 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5488,6 +5507,12 @@
5488 5507 "node": ">=0.10"
5489 5508 }
5490 5509 },
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 + },
5491 5516 "node_modules/levn": {
5492 5517 "version": "0.4.1",
5493 5518 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
modified package.json +2 −0
@@ -12,6 +12,7 @@
12 12 "dependencies": {
13 13 "@anthropic-ai/sdk": "^0.116.0",
14 14 "better-sqlite3": "^13.0.3",
15 + "leaflet": "^1.9.4",
15 16 "next": "16.3.0",
16 17 "pdfkit": "^0.19.1",
17 18 "react": "19.2.8",
@@ -20,6 +21,7 @@
20 21 "devDependencies": {
21 22 "@tailwindcss/postcss": "^4",
22 23 "@types/better-sqlite3": "^9.6.0",
24 + "@types/leaflet": "^1.9.22",
23 25 "@types/node": "^20",
24 26 "@types/pdfkit": "^0.17.6",
25 27 "@types/react": "^19",
added src/app/api/nearby/route.ts +36 −0
@@ -0,0 +1,36 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Recherche par carte : unités d'évaluation autour d'un point (boîte englobante).
3 +// Le rayon est plafonné côté serveur — la carte doit être assez zoomée.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { unitsNear } from "@/lib/db";
6 +
7 +const MAX_HALF_LAT = 0.02; // ~2,2 km
8 +const MAX_HALF_LNG = 0.03;
9 +const MAX_LIMIT = 400;
10 +
11 +export function GET(req: NextRequest) {
12 + const q = req.nextUrl.searchParams;
13 + const lat = Number(q.get("lat"));
14 + const lng = Number(q.get("lng"));
15 + if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
16 + return NextResponse.json({ error: "lat et lng requis" }, { status: 400 });
17 + }
18 + const halfLat = Math.min(Math.abs(Number(q.get("halfLat")) || 0.008), MAX_HALF_LAT);
19 + const halfLng = Math.min(Math.abs(Number(q.get("halfLng")) || 0.012), MAX_HALF_LNG);
20 + const limit = Math.min(Math.abs(Number(q.get("limit")) || 300), MAX_LIMIT);
21 + try {
22 + const results = unitsNear(lat, lng, halfLat, halfLng, limit).map((u) => ({
23 + id: u.id_provinc,
24 + adresse: u.adresse,
25 + apt: u.apt,
26 + municipalite: u.municipalite,
27 + typeProp: u.type_prop,
28 + lat: u.lat,
29 + lng: u.lng,
30 + est2026: u.est_2026,
31 + }));
32 + return NextResponse.json({ results });
33 + } catch {
34 + return NextResponse.json({ results: [] });
35 + }
36 +}
added src/app/carte/CartePage.tsx +31 −0
@@ -0,0 +1,31 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +import MapSearch from "@/components/MapSearch";
4 +import { useLang } from "@/components/LangContext";
5 +
6 +export default function CartePage() {
7 + const { lang } = useLang();
8 + const fr = lang === "fr";
9 + return (
10 + <main 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>
12 + <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, "}
14 + <span className="hl">{fr ? "estimé" : "appraised"}</span>
15 + </h1>
16 + <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2">
17 + {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."}
20 + </p>
21 + <div className="mt-7">
22 + <MapSearch />
23 + </div>
24 + <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
25 + {fr
26 + ? "Fond de carte © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)."
27 + : "Basemap © OpenStreetMap · Statistical estimates for information only (OEAQ)."}
28 + </p>
29 + </main>
30 + );
31 +}
added src/app/carte/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import CartePage from "./CartePage";
3 +
4 +export const metadata = {
5 + title: "Vrai-Prix — Recherche par carte",
6 + description:
7 + "Explorez les propriétés du Québec sur la carte : avoisinage, estimations 2026 et géolocalisation — 3,7 millions d'unités d'évaluation.",
8 +};
9 +
10 +export default function Page() {
11 + return <CartePage />;
12 +}
modified src/app/globals.css +18 −0
@@ -232,3 +232,21 @@ select.vp-input {
232 232 }
233 233 .hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
234 234
235 +
236 +/* recherche par carte — popup Leaflet au style éditorial */
237 +.vp-map-popup .leaflet-popup-content-wrapper {
238 + background: var(--surface);
239 + border: 1.5px solid var(--ink);
240 + border-radius: 14px;
241 + box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.25);
242 +}
243 +.vp-map-popup .leaflet-popup-content { margin: 14px 16px; }
244 +.vp-map-popup .leaflet-popup-tip {
245 + background: var(--surface);
246 + border: 1.5px solid var(--ink);
247 +}
248 +.vp-map-popup .leaflet-popup-close-button {
249 + font-size: 18px;
250 + color: var(--ink);
251 + padding: 6px 8px 0 0;
252 +}
modified src/components/HeaderNav.tsx +1 −0
@@ -28,6 +28,7 @@ export default function HeaderNav() {
28 28
29 29 const items = [
30 30 { href: "/", label: fr ? "Estimer" : "Estimate" },
31 + { href: "/carte", label: fr ? "Carte" : "Map" },
31 32 { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" },
32 33 { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
33 34 { href: "/stats", label: fr ? "Statistiques" : "Statistics" },
added src/components/MapSearch.tsx +206 −0
@@ -0,0 +1,206 @@
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 + '&copy; <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 +}
modified src/lib/db.ts +42 −0
@@ -124,6 +124,48 @@ export function searchUnits(q: string, limit = 8): (UnitRow & { score: number })
124 124 .all(cleaned, limit) as (UnitRow & { score: number })[];
125 125 }
126 126
127 +export interface NearbyUnitRow {
128 + id_provinc: string;
129 + adresse: string | null;
130 + apt: string | null;
131 + municipalite: string | null;
132 + type_prop: string;
133 + lat: number;
134 + lng: number;
135 + est_2026: number | null;
136 +}
137 +
138 +/** Unités d'évaluation dans une boîte englobante (recherche par carte). */
139 +export function unitsNear(
140 + lat: number,
141 + lng: number,
142 + halfLat: number,
143 + halfLng: number,
144 + limit = 300
145 +): NearbyUnitRow[] {
146 + // ORDER BY distance au centre : sans tri, l'index (lat, lng) renverrait
147 + // les N unités les plus au sud de la boîte (bande horizontale).
148 + return getDb()
149 + .prepare(
150 + `SELECT id_provinc, adresse, apt, municipalite, type_prop, lat, lng, est_2026
151 + FROM units
152 + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
153 + ORDER BY (lat - ?) * (lat - ?) + 0.49 * (lng - ?) * (lng - ?)
154 + LIMIT ?`
155 + )
156 + .all(
157 + lat - halfLat,
158 + lat + halfLat,
159 + lng - halfLng,
160 + lng + halfLng,
161 + lat,
162 + lat,
163 + lng,
164 + lng,
165 + limit
166 + ) as NearbyUnitRow[];
167 +}
168 +
127 169 export function municipalityCenter(
128 170 name: string
129 171 ): { lat: number; lng: number; n: number } | undefined {
130 172