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>
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 | + '© <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 | |