SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

Fiche : bloc Commerces et transport (Mapbox Search Box + cache POI/OSM)

- louka/commerces.py : point de vente le plus proche de 14 grandes bannières
  (Costco, Walmart, Metro, IGA, Maxi, Super C, Provigo, Canadian Tire,
  Dollarama, SAQ, Pharmaprix, Jean Coutu, Home Depot, RONA) via l API Mapbox
  Search Box (jeton public kamaps, validation du nom retourné, anti
  « Station Métro »), cache par cellule ~1 km TTL 30 j ; station de métro et
  arrêt de bus les plus proches repris du cache POI du projet (repli
  Overpass) ; GET /api/commerces
- bloc fiche : pastilles SVG monogrammes aux couleurs des bannières,
  nom du point de vente et distance — métro/bus en tête

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent f57db10 Browse files

7 changed files +381 −0

modified .gitignore +1 −0
@@ -31,3 +31,4 @@ data/rdl.db
31 data/inondation.db 31 data/inondation.db
32 data/air.db 32 data/air.db
33 data/gaz.db 33 data/gaz.db
34+data/commerces.db
modified frontend/src/api.ts +13 −0
@@ -624,3 +624,16 @@ export interface GazNearby {
624 /** Stations-service à proximité et prix courants (gazquebec.ca). */ 624 /** Stations-service à proximité et prix courants (gazquebec.ca). */
625 export const fetchGaz = (lat: number, lng: number) => 625 export const fetchGaz = (lat: number, lng: number) =>
626 get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`); 626 get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`);
627+
628+export interface CommerceItem {
629+ id: string; commerce: string; nom: string; adresse: string;
630+ dist_m: number; lat: number; lng: number;
631+}
632+
633+export interface CommercesNearby {
634+ n: number; commerces: CommerceItem[]; transit?: CommerceItem[];
635+}
636+
637+/** Grands commerces + métro/bus les plus proches (Mapbox / OSM). */
638+export const fetchCommerces = (lat: number, lng: number) =>
639+ get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`);
added frontend/src/components/CommercesProches.tsx +83 −0
@@ -0,0 +1,83 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/CommercesProches.tsx : bloc « Commerces et transport » (fiche)
5+// Distance au point de vente le plus proche de chaque grande bannière
6+// (Costco, Metro, IGA, Walmart… via l'API Mapbox Search Box) + station de
7+// métro et arrêt de bus les plus proches. Pastilles SVG monogrammes aux
8+// couleurs des bannières (pas de logos déposés).
9+// -----------------------------------------------------------------------------
10+import { useEffect, useState } from "react";
11+import { CommercesNearby, fetchCommerces, fmtDist } from "../api";
12+
13+// id -> [couleur de fond, monogramme, couleur du texte]
14+const ICONES: Record<string, [string, string, string?]> = {
15+ metro_station: ["#0083C9", "M"],
16+ arret_bus: ["#4E5357", "B"],
17+ costco: ["#005DAA", "C"],
18+ walmart: ["#0071CE", "W"],
19+ metro: ["#EF3E42", "M"],
20+ iga: ["#D50032", "IGA"],
21+ maxi: ["#0079C1", "Mx"],
22+ superc: ["#E4002B", "SC"],
23+ provigo: ["#DA291C", "P"],
24+ canadiantire: ["#D6001C", "CT"],
25+ dollarama: ["#00B140", "D", "#FFDD00"],
26+ saq: ["#892034", "SAQ"],
27+ pharmaprix: ["#E11B22", "Ph"],
28+ jeancoutu: ["#003DA5", "JC"],
29+ homedepot: ["#F96302", "HD"],
30+ rona: ["#1B4298", "R"],
31+};
32+
33+function Pastille({ id }: { id: string }) {
34+ const [bg, mono, fg] = ICONES[id] ?? ["#777", "•"];
35+ const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
36+ return (
37+ <svg className="cm-ico" viewBox="0 0 28 28" width="28" height="28"
38+ aria-hidden="true">
39+ {id === "metro_station" || id === "arret_bus"
40+ ? <circle cx="14" cy="14" r="13" fill={bg} />
41+ : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
42+ <text x="14" y="14" textAnchor="middle" dominantBaseline="central"
43+ fontSize={fs} fontWeight="800" fontFamily="inherit"
44+ fill={fg ?? "#fff"}>{mono}</text>
45+ </svg>
46+ );
47+}
48+
49+export default function CommercesProches({ lat, lng }:
50+ { lat: number | null; lng: number | null }) {
51+ const [d, setD] = useState<CommercesNearby | null>(null);
52+ useEffect(() => {
53+ setD(null);
54+ if (lat == null || lng == null) return;
55+ fetchCommerces(lat, lng).then(setD).catch(() => setD(null));
56+ }, [lat, lng]);
57+ if (lat == null || lng == null || !d) return null;
58+ const tous = [...(d.transit ?? []), ...(d.commerces ?? [])];
59+ if (tous.length === 0) return null;
60+
61+ return (
62+ <section className="f-bloc f-commerces" id="commerces">
63+ <h2>Commerces et transport</h2>
64+ <ul className="cm-grille">
65+ {tous.map((c) => (
66+ <li key={c.id} className="cm-item"
67+ title={c.adresse || undefined}>
68+ <Pastille id={c.id} />
69+ <span className="cm-txt">
70+ <span className="cm-nom">{c.commerce}</span>
71+ <span className="cm-poi">{c.nom}</span>
72+ </span>
73+ <span className="cm-dist">{fmtDist(c.dist_m)}</span>
74+ </li>
75+ ))}
76+ </ul>
77+ <p className="fine">
78+ Point de vente le plus proche de chaque bannière — distances à vol
79+ d'oiseau (recherche Mapbox ; métro et bus : OpenStreetMap).
80+ </p>
81+ </section>
82+ );
83+}
modified frontend/src/pages/Listing.tsx +3 −0
@@ -21,6 +21,7 @@ import RegistreLoyers from "../components/RegistreLoyers";
21 import RisqueInondation from "../components/RisqueInondation"; 21 import RisqueInondation from "../components/RisqueInondation";
22 import QualiteAir from "../components/QualiteAir"; 22 import QualiteAir from "../components/QualiteAir";
23 import EssenceProche from "../components/EssenceProche"; 23 import EssenceProche from "../components/EssenceProche";
24+import CommercesProches from "../components/CommercesProches";
24 import { IcoAlert, IcoDoc } from "../components/Icons"; 25 import { IcoAlert, IcoDoc } from "../components/Icons";
25 import KaScoresBlock from "../components/KaScoresBlock"; 26 import KaScoresBlock from "../components/KaScoresBlock";
26 import { markSeen } from "../search/seen"; 27 import { markSeen } from "../search/seen";
@@ -481,6 +482,8 @@ export default function ListingPage() {
481 482
482 <QualiteAir lat={l.lat} lng={l.lng} /> 483 <QualiteAir lat={l.lat} lng={l.lng} />
483 484
485+ <CommercesProches lat={l.lat} lng={l.lng} />
486+
484 <EssenceProche lat={l.lat} lng={l.lng} /> 487 <EssenceProche lat={l.lat} lng={l.lng} />
485 488
486 {l.kascores && <KaScoresBlock ks={l.kascores} />} 489 {l.kascores && <KaScoresBlock ks={l.kascores} />}
modified frontend/src/styles.css +16 −0
@@ -1956,3 +1956,19 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1956 border-radius: 999px; background: #e7f4ea; color: #1e6b34; 1956 border-radius: 999px; background: #e7f4ea; color: #1e6b34;
1957 font-size: 10.5px; font-weight: 700; } 1957 font-size: 10.5px; font-weight: 700; }
1958 .gaz-adr { display: block; font-size: 11px; color: var(--ink-3); } 1958 .gaz-adr { display: block; font-size: 11px; color: var(--ink-3); }
1959+
1960+
1961+/* ---- Commerces et transport (fiche) ---- */
1962+.cm-grille { list-style: none; margin: 6px 0 0; padding: 0;
1963+ display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; }
1964+@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } }
1965+.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0;
1966+ border-bottom: 1px solid var(--line, #eeece7); min-width: 0; }
1967+.cm-ico { flex: 0 0 28px; }
1968+.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
1969+.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222);
1970+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1971+.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f);
1972+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1973+.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700;
1974+ color: var(--ink-2, #4c4a45); white-space: nowrap; }
added louka/commerces.py +258 −0
@@ -0,0 +1,258 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# commerces.py : grands commerces à proximité — API Mapbox Search Box
5+#
6+# Pour chaque grande bannière (Costco, Metro, IGA, Walmart…), on interroge
7+# l'API Search Box de Mapbox (jeton PUBLIC pk.… lu dans
8+# frontend/src/kamaps/config.ts — source de vérité du projet) avec la
9+# position de l'annonce en `proximity`, et on retient le point de vente le
10+# plus proche. Cache par cellule d'environ 1 km (data/commerces.db,
11+# TTL 30 jours) : les fiches d'un même secteur ne recoûtent rien.
12+# -----------------------------------------------------------------------------
13+from __future__ import annotations
14+
15+import json
16+import math
17+import re
18+import sqlite3
19+import time
20+import urllib.parse
21+import urllib.request
22+from concurrent.futures import ThreadPoolExecutor
23+from pathlib import Path
24+
25+ROOT = Path(__file__).resolve().parent.parent
26+DB_PATH = ROOT / "data" / "commerces.db"
27+UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"
28+TTL = 30 * 86400
29+API = "https://api.mapbox.com/search/searchbox/v1/forward"
30+
31+# id, libellé, requête Mapbox, mot-clé de validation (le nom du POI doit le
32+# contenir, sans accents ni casse — écarte « Station Métro », « Super Qualité »…)
33+BRANDS = [
34+ ("costco", "Costco", "Costco Wholesale", "costco"),
35+ ("walmart", "Walmart", "Walmart Supercentre", "walmart"),
36+ ("metro", "Metro", "Metro", "metro"),
37+ ("iga", "IGA", "IGA", "iga"),
38+ ("maxi", "Maxi", "Maxi", "maxi"),
39+ ("superc", "Super C", "Super C", "super c"),
40+ ("provigo", "Provigo", "Provigo", "provigo"),
41+ ("canadiantire", "Canadian Tire", "Canadian Tire", "canadian tire"),
42+ ("dollarama", "Dollarama", "Dollarama", "dollarama"),
43+ ("saq", "SAQ", "SAQ", "saq"),
44+ ("pharmaprix", "Pharmaprix", "Pharmaprix", "pharmaprix"),
45+ ("jeancoutu", "Jean Coutu", "Jean Coutu pharmacie", "jean coutu"),
46+ ("homedepot", "Home Depot", "Home Depot", "home depot"),
47+ ("rona", "RONA", "RONA", "rona"),
48+]
49+
50+_BAN = ("station", "stationnement")
51+
52+
53+def _norm(s: str) -> str:
54+ import unicodedata
55+ s = unicodedata.normalize("NFD", s or "")
56+ return "".join(c for c in s if unicodedata.category(c) != "Mn").lower()
57+
58+_token_cache: list[str] = []
59+
60+
61+def _token() -> str:
62+ if not _token_cache:
63+ cfg = (ROOT / "frontend" / "src" / "kamaps" / "config.ts").read_text()
64+ m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg)
65+ if not m:
66+ raise RuntimeError("jeton Mapbox introuvable (kamaps/config.ts)")
67+ _token_cache.append(m.group(1))
68+ return _token_cache[0]
69+
70+
71+def _connect() -> sqlite3.Connection:
72+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
73+ con = sqlite3.connect(DB_PATH, timeout=15)
74+ con.row_factory = sqlite3.Row
75+ con.execute("""CREATE TABLE IF NOT EXISTS commerces_cache (
76+ cellule TEXT, brand TEXT, nom TEXT, adresse TEXT,
77+ lat REAL, lng REAL, fetched_at REAL,
78+ PRIMARY KEY (cellule, brand))""")
79+ return con
80+
81+
82+def _dist_m(lat1, lng1, lat2, lng2) -> float:
83+ dlat = math.radians(lat2 - lat1)
84+ dlng = math.radians(lng2 - lng1)
85+ a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))
86+ * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2)
87+ return 6371000 * 2 * math.asin(math.sqrt(a))
88+
89+
90+def _fetch_brand(brand_q: str, lat: float, lng: float,
91+ match: str = "") -> dict | None:
92+ params = urllib.parse.urlencode({
93+ "q": brand_q, "proximity": f"{lng},{lat}", "limit": 5,
94+ "types": "poi", "language": "fr", "country": "CA",
95+ "access_token": _token()})
96+ req = urllib.request.Request(f"{API}?{params}",
97+ headers={"User-Agent": UA})
98+ try:
99+ with urllib.request.urlopen(req, timeout=12) as r:
100+ feats = json.load(r).get("features") or []
101+ except Exception:
102+ return None
103+ for f in feats:
104+ p = f.get("properties") or {}
105+ nom = _norm(p.get("name") or "")
106+ if match and match not in nom:
107+ continue
108+ if any(b in nom for b in _BAN):
109+ continue
110+ lng2, lat2 = f["geometry"]["coordinates"][:2]
111+ return {"nom": p.get("name") or brand_q,
112+ "adresse": p.get("full_address")
113+ or p.get("place_formatted") or "",
114+ "lat": lat2, "lng": lng2}
115+ return None
116+
117+
118+OVERPASS = ["https://overpass.kumi.systems/api/interpreter",
119+ "https://overpass-api.de/api/interpreter"]
120+
121+
122+def _fetch_transit(lat: float, lng: float) -> list[tuple[str, dict]]:
123+ """Station de métro et arrêt de bus les plus proches (OpenStreetMap)."""
124+ q = f"""[out:json][timeout:20];
125+(
126+ node["railway"="station"]["station"="subway"](around:3000,{lat},{lng});
127+ node["highway"="bus_stop"](around:1000,{lat},{lng});
128+);
129+out body;"""
130+ data = None
131+ for url in OVERPASS:
132+ try:
133+ req = urllib.request.Request(
134+ url, data=urllib.parse.urlencode({"data": q}).encode(),
135+ headers={"User-Agent": UA})
136+ with urllib.request.urlopen(req, timeout=25) as r:
137+ data = json.load(r)
138+ break
139+ except Exception:
140+ continue
141+ if not data:
142+ return []
143+ best: dict[str, tuple[float, dict]] = {}
144+ for el in data.get("elements", []):
145+ tags = el.get("tags") or {}
146+ kind = ("metro_station" if tags.get("railway") == "station"
147+ else "arret_bus")
148+ d = _dist_m(lat, lng, el["lat"], el["lon"])
149+ if kind not in best or d < best[kind][0]:
150+ best[kind] = (d, {"nom": tags.get("name")
151+ or ("Station de métro" if kind == "metro_station"
152+ else "Arrêt de bus"),
153+ "adresse": "", "lat": el["lat"],
154+ "lng": el["lon"]})
155+ return [(k, v[1]) for k, v in best.items()]
156+
157+
158+TRANSIT = [("metro_station", "Station de métro"), ("arret_bus", "Arrêt de bus")]
159+
160+_POI_CAT = {"metro": "metro_station", "bus": "arret_bus"}
161+
162+
163+def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]:
164+ """Métro/bus depuis le cache POI du projet (louka.db, déjà calculé par
165+ immeuble) — la fiche interroge avec les mêmes coordonnées que poi.py."""
166+ db_main = ROOT / "data" / next(
167+ (n for n in ("louka.db", "immoka.db", "immo.db")
168+ if (ROOT / "data" / n).exists()), "louka.db")
169+ if not db_main.exists():
170+ return []
171+ try:
172+ con = sqlite3.connect(f"file:{db_main}?mode=ro", uri=True)
173+ con.row_factory = sqlite3.Row
174+ d = 300 / 111320.0
175+ row = con.execute(
176+ "SELECT pois, lat, lng FROM poi_cache WHERE lat BETWEEN ? AND ? "
177+ "AND lng BETWEEN ? AND ? ORDER BY (lat-?)*(lat-?)+(lng-?)*(lng-?) "
178+ "LIMIT 1", (lat - d, lat + d, lng - d, lng + d,
179+ lat, lat, lng, lng)).fetchone()
180+ con.close()
181+ except sqlite3.Error:
182+ return []
183+ if row is None:
184+ return []
185+ out = []
186+ for e in json.loads(row["pois"] or "[]"):
187+ k = _POI_CAT.get(e.get("cat"))
188+ if k:
189+ out.append((k, {"nom": e.get("name") or "", "adresse": "",
190+ "lat": lat, "lng": lng,
191+ "_dist": e.get("dist_m")}))
192+ return out
193+
194+
195+def nearby(lat: float, lng: float) -> dict:
196+ """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j)."""
197+ cell = f"{round(lat, 2)},{round(lng, 2)}"
198+ con = _connect()
199+ now = time.time()
200+ cached = {r["brand"]: r for r in con.execute(
201+ "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
202+ (cell, now - TTL))}
203+ manquants = [(bid, q, m) for bid, _, q, m in BRANDS
204+ if bid not in cached]
205+ transit_manquant = any(k not in cached for k, _ in TRANSIT)
206+ if manquants or transit_manquant:
207+ res: list[tuple[str, dict | None]] = []
208+ if manquants:
209+ with ThreadPoolExecutor(max_workers=6) as ex:
210+ res = list(ex.map(
211+ lambda b: (b[0], _fetch_brand(b[1], lat, lng, b[2])),
212+ manquants))
213+ if transit_manquant:
214+ tr = _transit_from_poi(lat, lng) or _fetch_transit(lat, lng)
215+ res.extend(tr)
216+ with con:
217+ for bid, hit in res:
218+ if hit is None:
219+ continue
220+ con.execute(
221+ "INSERT OR REPLACE INTO commerces_cache VALUES "
222+ "(?,?,?,?,?,?,?)",
223+ (cell, bid, hit["nom"],
224+ hit.get("adresse") or (str(hit["_dist"])
225+ if hit.get("_dist") is not None
226+ else ""),
227+ hit["lat"], hit["lng"], now))
228+ cached = {r["brand"]: r for r in con.execute(
229+ "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
230+ (cell, now - TTL))}
231+ con.close()
232+
233+ items = []
234+ transit = []
235+ for bid, label in TRANSIT:
236+ r = cached.get(bid)
237+ if r is not None:
238+ # distance : celle du cache POI si disponible (adresse numérique)
239+ d = (float(r["adresse"]) if (r["adresse"] or "").replace(
240+ ".", "").isdigit() else _dist_m(lat, lng, r["lat"], r["lng"]))
241+ if d <= 5000:
242+ transit.append({"id": bid, "commerce": label,
243+ "nom": r["nom"], "adresse": "",
244+ "dist_m": round(d),
245+ "lat": r["lat"], "lng": r["lng"]})
246+ for bid, label, _q, _m in BRANDS:
247+ r = cached.get(bid)
248+ if r is None:
249+ continue
250+ d = _dist_m(lat, lng, r["lat"], r["lng"])
251+ if d > 40000: # au-delà de 40 km : non pertinent
252+ continue
253+ items.append({"id": bid, "commerce": label, "nom": r["nom"],
254+ "adresse": r["adresse"], "dist_m": round(d),
255+ "lat": r["lat"], "lng": r["lng"]})
256+ items.sort(key=lambda x: x["dist_m"])
257+ transit.sort(key=lambda x: x["dist_m"])
258+ return {"n": len(items), "commerces": items, "transit": transit}
modified louka/web.py +7 −0
@@ -542,6 +542,13 @@ def fairvalue_detail(uid: str):
542 return d 542 return d
543 543
544 544
545+@app.get("/api/commerces")
546+def commerces_at(lat: float, lng: float):
547+ """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""
548+ from . import commerces
549+ return commerces.nearby(lat, lng)
550+
551+
545 @app.get("/api/air") 552 @app.get("/api/air")
546 def air_at(lat: float, lng: float): 553 def air_at(lat: float, lng: float):
547 """Qualité de l'air : station RSQAQ la plus proche (MELCCFP).""" 554 """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""
548 555
Menu

Theme