SPB Git

spb/vrai-prix

Public

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

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 (Aug 11, 2026) parent 11fbe15 Browse files

10 changed files +376 −0

modified .gitignore +3 −0
@@ -15,3 +15,6 @@ playwright-report/
15 15
16 # secrets — jamais versionnés 16 # secrets — jamais versionnés
17 .env* 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 "dependencies": { 10 "dependencies": {
11 "@anthropic-ai/sdk": "^0.116.0", 11 "@anthropic-ai/sdk": "^0.116.0",
12 "better-sqlite3": "^13.0.3", 12 "better-sqlite3": "^13.0.3",
13+ "leaflet": "^1.9.4",
13 "next": "16.3.0", 14 "next": "16.3.0",
14 "pdfkit": "^0.19.1", 15 "pdfkit": "^0.19.1",
15 "react": "19.2.8", 16 "react": "19.2.8",
@@ -18,6 +19,7 @@
18 "devDependencies": { 19 "devDependencies": {
19 "@tailwindcss/postcss": "^4", 20 "@tailwindcss/postcss": "^4",
20 "@types/better-sqlite3": "^9.6.0", 21 "@types/better-sqlite3": "^9.6.0",
22+ "@types/leaflet": "^1.9.22",
21 "@types/node": "^20", 23 "@types/node": "^20",
22 "@types/pdfkit": "^0.17.6", 24 "@types/pdfkit": "^0.17.6",
23 "@types/react": "^19", 25 "@types/react": "^19",
@@ -2069,6 +2071,13 @@
2069 "dev": true, 2071 "dev": true,
2070 "license": "MIT" 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 "node_modules/@types/json-schema": { 2081 "node_modules/@types/json-schema": {
2073 "version": "7.0.15", 2082 "version": "7.0.15",
2074 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 2083 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2083,6 +2092,16 @@
2083 "dev": true, 2092 "dev": true,
2084 "license": "MIT" 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 "node_modules/@types/node": { 2105 "node_modules/@types/node": {
2087 "version": "20.19.43", 2106 "version": "20.19.43",
2088 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", 2107 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5488,6 +5507,12 @@
5488 "node": ">=0.10" 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 "node_modules/levn": { 5516 "node_modules/levn": {
5492 "version": "0.4.1", 5517 "version": "0.4.1",
5493 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", 5518 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
modified package.json +2 −0
@@ -12,6 +12,7 @@
12 "dependencies": { 12 "dependencies": {
13 "@anthropic-ai/sdk": "^0.116.0", 13 "@anthropic-ai/sdk": "^0.116.0",
14 "better-sqlite3": "^13.0.3", 14 "better-sqlite3": "^13.0.3",
15+ "leaflet": "^1.9.4",
15 "next": "16.3.0", 16 "next": "16.3.0",
16 "pdfkit": "^0.19.1", 17 "pdfkit": "^0.19.1",
17 "react": "19.2.8", 18 "react": "19.2.8",
@@ -20,6 +21,7 @@
20 "devDependencies": { 21 "devDependencies": {
21 "@tailwindcss/postcss": "^4", 22 "@tailwindcss/postcss": "^4",
22 "@types/better-sqlite3": "^9.6.0", 23 "@types/better-sqlite3": "^9.6.0",
24+ "@types/leaflet": "^1.9.22",
23 "@types/node": "^20", 25 "@types/node": "^20",
24 "@types/pdfkit": "^0.17.6", 26 "@types/pdfkit": "^0.17.6",
25 "@types/react": "^19", 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 .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+
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 const items = [ 29 const items = [
30 { href: "/", label: fr ? "Estimer" : "Estimate" }, 30 { href: "/", label: fr ? "Estimer" : "Estimate" },
31+ { href: "/carte", label: fr ? "Carte" : "Map" },
31 { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" }, 32 { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" },
32 { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" }, 33 { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
33 { href: "/stats", label: fr ? "Statistiques" : "Statistics" }, 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 .all(cleaned, limit) as (UnitRow & { score: number })[]; 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 export function municipalityCenter( 169 export function municipalityCenter(
128 name: string 170 name: string
129 ): { lat: number; lng: number; n: number } | undefined { 171 ): { lat: number; lng: number; n: number } | undefined {
130 172
Menu

Theme