SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: carte propriété dédiée (zoom, marqueur pulsant, anneaux 5/15 min à pied) + stats survalorisation vs Vrai-Prix par bannière

- PropertyMap.tsx : la fiche montre enfin la carte centrée sur LA propriété (l'ancienne mini-carte passait un filtre q ignoré par l'API)
- /api/stats : écart médian prix demandé vs estimation Vrai-Prix par bannière (médiane, P25-P75, % sous/juste/survalorisé)
- Stats.tsx : jauges divergentes par bannière + répartition

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent e05532c

6 changed files +244 −5

modified frontend/src/api.ts +16 −0
@@ -89,6 +89,22 @@ export interface Stats {
89 89 source: string; ts: number; found: number; added: number;
90 90 updated: number; removed: number; ok: number; message: string;
91 91 }[];
92 + vraiprix?: {
93 + ensemble: VpBanniere | null;
94 + bannieres: VpBanniere[];
95 + };
96 +}
97 +
98 +/** Écart prix demandé vs estimation Vrai-Prix, agrégé par bannière. */
99 +export interface VpBanniere {
100 + banniere: string;
101 + n: number;
102 + median_delta_pct: number;
103 + p25: number;
104 + p75: number;
105 + pct_sur10: number;
106 + pct_juste: number;
107 + pct_sous5: number;
92 108 }
93 109
94 110 // --- Noms d'agences (jolis libellés) ----------------------------------------
added frontend/src/components/PropertyMap.tsx +92 −0
@@ -0,0 +1,92 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/PropertyMap.tsx : carte d'emplacement de la fiche propriété
5 +// · centrée et zoomée sur LA propriété (fond « Groupe Ka » maison)
6 +// · marqueur pulsant + pastille de prix, rouge si sous l'estimation Vrai-Prix
7 +// · anneaux « 5 min / 15 min à pied » — signature visuelle Immo-Ka
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef } from "react";
10 +import maplibregl from "maplibre-gl";
11 +import "maplibre-gl/dist/maplibre-gl.css";
12 +import immokaStyle from "../map/immokaStyle";
13 +import { shortPrice } from "../map/pills";
14 +
15 +/** Cercle géodésique approx. (polygone 72 côtés) autour d'un point. */
16 +function circle(lng: number, lat: number, radiusM: number): GeoJSON.Feature {
17 + const pts: [number, number][] = [];
18 + const dLat = radiusM / 111_000;
19 + const dLng = radiusM / (111_000 * Math.cos((lat * Math.PI) / 180));
20 + for (let i = 0; i <= 72; i++) {
21 + const a = (i / 72) * 2 * Math.PI;
22 + pts.push([lng + dLng * Math.cos(a), lat + dLat * Math.sin(a)]);
23 + }
24 + return { type: "Feature", properties: {},
25 + geometry: { type: "Polygon", coordinates: [pts] } };
26 +}
27 +
28 +interface Props {
29 + lat: number;
30 + lng: number;
31 + price?: number | null;
32 + deal?: boolean; // prix sous l'estimation Vrai-Prix
33 +}
34 +
35 +export default function PropertyMap({ lat, lng, price, deal }: Props) {
36 + const div = useRef<HTMLDivElement>(null);
37 +
38 + useEffect(() => {
39 + if (!div.current) return;
40 + const map = new maplibregl.Map({
41 + container: div.current,
42 + style: immokaStyle,
43 + center: [lng, lat],
44 + zoom: 14.6,
45 + attributionControl: false,
46 + scrollZoom: false, // ne pas piéger le défilement de la page
47 + dragRotate: false,
48 + });
49 + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
50 + map.touchZoomRotate.disableRotation();
51 +
52 + map.on("load", () => {
53 + // anneaux piéton — 400 m ≈ 5 min, 1,2 km ≈ 15 min
54 + map.addSource("rings", {
55 + type: "geojson",
56 + data: { type: "FeatureCollection",
57 + features: [circle(lng, lat, 400), circle(lng, lat, 1200)] },
58 + });
59 + map.addLayer({
60 + id: "rings-fill", type: "fill", source: "rings",
61 + paint: { "fill-color": "#e23744", "fill-opacity": 0.035 },
62 + });
63 + map.addLayer({
64 + id: "rings-line", type: "line", source: "rings",
65 + paint: { "line-color": "#b3202b", "line-opacity": 0.55,
66 + "line-width": 1.4, "line-dasharray": [3, 3] },
67 + });
68 + });
69 +
70 + // marqueur maison : halo pulsant + pastille de prix
71 + const el = document.createElement("div");
72 + el.className = "pm-marker";
73 + el.innerHTML = `
74 + <div class="pm-pill${deal ? " pm-pill-deal" : ""}">${shortPrice(price)}${deal ? " ↓" : ""}</div>
75 + <div class="pm-pulse"></div>
76 + <div class="pm-dot"></div>`;
77 + const marker = new maplibregl.Marker({ element: el, anchor: "bottom" })
78 + .setLngLat([lng, lat]).addTo(map);
79 +
80 + return () => { marker.remove(); map.remove(); };
81 + }, [lat, lng, price, deal]);
82 +
83 + return (
84 + <div className="pm-wrap">
85 + <div ref={div} className="mapview" role="img" aria-label="Emplacement de la propriété" />
86 + <div className="pm-rings-legend" aria-hidden="true">
87 + <span>◌ 5 min à pied</span><span>◌ 15 min à pied</span>
88 + </div>
89 + <div className="mv-credit" aria-hidden="true">Cartographie <b>Groupe&nbsp;Ka</b></div>
90 + </div>
91 + );
92 +}
modified frontend/src/pages/Listing.tsx +6 −2
@@ -12,7 +12,7 @@ import {
12 12 registerSourceNames, sourceName,
13 13 } from "../api";
14 14
15 −const MiniMap = lazy(() => import("../components/MapView"));
15 +const PropertyMap = lazy(() => import("../components/PropertyMap"));
16 16 import QuartierBlock from "../components/QuartierBlock";
17 17
18 18 // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
@@ -197,7 +197,11 @@ export default function ListingPage() {
197 197 <h2>Emplacement</h2>
198 198 <div className="mini-map">
199 199 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
200 − <MiniMap filters={{ q: l.mls || l.address }} />
200 + <PropertyMap
201 + lat={l.lat} lng={l.lng} price={l.price}
202 + deal={l.price != null && l.vraiprix?.value != null
203 + && l.price <= l.vraiprix.value * 0.95}
204 + />
201 205 </Suspense>
202 206 </div>
203 207 </section>
modified frontend/src/pages/Stats.tsx +54 −1
@@ -8,11 +8,43 @@
8 8 import { useEffect, useMemo, useState } from "react";
9 9 import { Link } from "react-router-dom";
10 10 import {
11 − Facets, Listing, Stats,
11 + Facets, Listing, Stats, VpBanniere,
12 12 fetchFacets, fetchListings, fetchSources, fetchStats,
13 13 registerSourceNames, sourceName,
14 14 } from "../api";
15 15
16 +// --- Survalorisation vs Vrai-Prix : jauge divergente par bannière ------------
17 +const VP_SCALE = 30; // la jauge couvre −30 % … +30 %
18 +const pctPos = (d: number) => `${((Math.max(-VP_SCALE, Math.min(VP_SCALE, d)) + VP_SCALE) / (2 * VP_SCALE)) * 100}%`;
19 +
20 +function VpGauge({ b }: { b: VpBanniere }) {
21 + const tone = b.median_delta_pct > 10 ? "vp-sur" : b.median_delta_pct < 0 ? "vp-sous" : "vp-juste";
22 + return (
23 + <div className="vpg-row">
24 + <div className="vpg-name">
25 + {b.banniere}
26 + <em>{b.n.toLocaleString("fr-CA")} annonces estimées</em>
27 + </div>
28 + <div className="vpg-track">
29 + <span className="vpg-zero" />
30 + <span
31 + className="vpg-band"
32 + style={{ left: pctPos(Math.min(b.p25, b.p75)), width: `calc(${pctPos(Math.max(b.p25, b.p75))} - ${pctPos(Math.min(b.p25, b.p75))})` }}
33 + />
34 + <span className={`vpg-median ${tone}`} style={{ left: pctPos(b.median_delta_pct) }} />
35 + </div>
36 + <div className={`vpg-value ${tone}`}>
37 + {b.median_delta_pct > 0 ? "+" : ""}{b.median_delta_pct.toLocaleString("fr-CA")} %
38 + </div>
39 + <div className="vpg-split" title={`${b.pct_sous5} % sous l'estimation · ${b.pct_juste} % dans l'estimation · ${b.pct_sur10} % à +10 % et plus`}>
40 + <span className="vps-sous" style={{ width: `${b.pct_sous5}%` }} />
41 + <span className="vps-juste" style={{ width: `${b.pct_juste}%` }} />
42 + <span className="vps-sur" style={{ width: `${b.pct_sur10}%` }} />
43 + </div>
44 + </div>
45 + );
46 +}
47 +
16 48 function Bars({ rows, unit }: { rows: { key: string; n: number; href?: string }[]; unit?: string }) {
17 49 const max = Math.max(1, ...rows.map((r) => r.n));
18 50 return (
@@ -102,6 +134,27 @@ export default function StatsPage() {
102 134 </div>
103 135 </div>
104 136
137 + {stats?.vraiprix?.bannieres && stats.vraiprix.bannieres.length > 0 && (
138 + <div className="viz-card">
139 + <h2>Prix demandé vs valeur Vrai-Prix</h2>
140 + <div className="viz-sub">
141 + Écart médian entre le prix demandé et l'estimation indépendante Vrai-Prix, par bannière.
142 + La bande grise couvre la moitié centrale des annonces (P25–P75) ; le trait est la médiane.
143 + </div>
144 + <div className="vpg-axis">
145 + <div><span>−{VP_SCALE} %</span><span>estimation Vrai-Prix</span><span>+{VP_SCALE} %</span></div>
146 + </div>
147 + {stats.vraiprix.ensemble && <VpGauge b={stats.vraiprix.ensemble} />}
148 + <div className="vpg-sep" />
149 + {stats.vraiprix.bannieres.map((b) => <VpGauge b={b} key={b.banniere} />)}
150 + <div className="vpg-legend">
151 + <span><i className="vps-sous" /> sous l'estimation (≤ −5 %)</span>
152 + <span><i className="vps-juste" /> dans l'estimation</span>
153 + <span><i className="vps-sur" /> survalorisé (≥ +10 %)</span>
154 + </div>
155 + </div>
156 + )}
157 +
105 158 <div className="viz-card">
106 159 <h2>Par ville</h2>
107 160 <div className="viz-sub">Top 15 · échantillon des annonces actives</div>
modified frontend/src/styles.css +39 −1
@@ -351,6 +351,33 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
351 351 .viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
352 352 .stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
353 353
354 +/* ---- Survalorisation vs Vrai-Prix (jauges divergentes) ---- */
355 +.vpg-axis { display: grid; grid-template-columns: 220px 1fr 74px 90px; gap: 10px; margin-bottom: 2px; }
356 +.vpg-axis > div { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
357 +.vpg-row { display: grid; grid-template-columns: 220px 1fr 74px 90px; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px dashed var(--line); }
358 +.vpg-row:last-of-type { border-bottom: none; }
359 +.vpg-name { font-size: 13.5px; font-weight: 600; }
360 +.vpg-name em { display: block; font-style: normal; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
361 +.vpg-track { position: relative; height: 18px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
362 +.vpg-zero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: var(--line-strong); opacity: 0.5; }
363 +.vpg-band { position: absolute; top: 3px; bottom: 3px; background: rgba(26, 18, 20, 0.14); border-radius: 999px; }
364 +.vpg-median { position: absolute; top: 1px; bottom: 1px; width: 4px; margin-left: -2px; border-radius: 2px; }
365 +.vpg-median.vp-sur { background: var(--lime); }
366 +.vpg-median.vp-juste { background: #d9a942; }
367 +.vpg-median.vp-sous { background: #4c8b4f; }
368 +.vpg-value { font-family: var(--font-display); font-weight: 700; font-size: 15px; text-align: right; }
369 +.vpg-value.vp-sur { color: var(--green); }
370 +.vpg-value.vp-juste { color: #a97b1e; }
371 +.vpg-value.vp-sous { color: #3c7440; }
372 +.vpg-split { display: flex; height: 10px; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
373 +.vps-sous { background: #7fb283; }
374 +.vps-juste { background: #d9cfc7; }
375 +.vps-sur { background: var(--lime); }
376 +.vpg-sep { border-top: 1.5px solid var(--line-strong); margin: 4px 0; }
377 +.vpg-legend { display: flex; gap: 16px; margin-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); flex-wrap: wrap; }
378 +.vpg-legend i { display: inline-block; width: 14px; height: 9px; border-radius: 999px; margin-right: 5px; vertical-align: -1px; }
379 +@media (max-width: 780px) { .vpg-row { grid-template-columns: 1fr 64px; } .vpg-track { grid-column: 1 / -1; } .vpg-split { grid-column: 1 / -1; } .vpg-axis { display: none; } }
380 +
354 381 /* ================= Carte ================= */
355 382 .map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
356 383 .map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
@@ -366,8 +393,19 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
366 393 .mv-lg-deal { background: var(--lime); border-color: var(--green); }
367 394 .mv-credit { position: absolute; bottom: 8px; right: 8px; z-index: 3; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
368 395 .mv-credit b { color: var(--ink); }
369 −.mini-map .mv-brand, .mini-map .mv-legend, .mini-map .mv-credit { display: none; }
396 +.mini-map .mv-brand, .mini-map .mv-legend { display: none; }
370 397 .mini-map .mapwrap { height: 100%; }
398 +
399 +/* ---- carte propriété (fiche) : marqueur pulsant + anneaux piéton ---- */
400 +.pm-wrap { position: relative; width: 100%; height: 100%; }
401 +.pm-marker { display: flex; flex-direction: column; align-items: center; }
402 +.pm-pill { padding: 5px 13px; background: #fff; border: 1.5px solid var(--line-strong); border-radius: 999px; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--ink); box-shadow: var(--shadow-off-soft); white-space: nowrap; margin-bottom: 4px; }
403 +.pm-pill-deal { background: var(--lime); border-color: var(--green); color: #fff; }
404 +.pm-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--lime); border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(26, 18, 20, 0.4); }
405 +.pm-pulse { position: absolute; bottom: -8px; width: 30px; height: 30px; border-radius: 50%; background: var(--lime); opacity: 0.35; animation: pm-pulse 2s ease-out infinite; }
406 +@keyframes pm-pulse { 0% { transform: scale(0.5); opacity: 0.45; } 80% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } }
407 +.pm-rings-legend { position: absolute; bottom: 8px; left: 8px; z-index: 3; display: flex; gap: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; color: var(--green); pointer-events: none; }
408 +.mini-map .pm-wrap .mv-credit { display: block; }
371 409 .map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
372 410 @media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
373 411 .maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
modified immoka/web.py +37 −1
@@ -315,8 +315,44 @@ def stats():
315 315 FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone()
316 316 log = [dict(r) for r in con.execute(
317 317 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
318 +
319 + # --- écart prix demandé vs estimation Vrai-Prix, par bannière -----------
320 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
321 + source_names = {s["id"]: s["name"] for s in registry}
322 + deltas: dict[str, list[float]] = {}
323 + for r in con.execute(
324 + "SELECT source, price,"
325 + " CAST(json_extract(vraiprix, '$.value') AS REAL) vp"
326 + " FROM listings WHERE active=1" + DEDUP_CLAUSE +
327 + " AND vraiprix LIKE '%estimation%'"):
328 + if not r["vp"] or r["vp"] <= 0:
329 + continue
330 + d = (r["price"] - r["vp"]) / r["vp"] * 100.0
331 + if -80.0 <= d <= 300.0: # coupe les aberrations (terrains, données sales)
332 + deltas.setdefault(_franchise_of(r["source"], source_names), []).append(d)
318 333 con.close()
319 − return {**dict(row), "recent_syncs": log}
334 +
335 + def _agg(name: str, ds: list[float]) -> dict:
336 + ds = sorted(ds)
337 + n = len(ds)
338 + med = ds[n // 2] if n % 2 else (ds[n // 2 - 1] + ds[n // 2]) / 2
339 + return {
340 + "banniere": name, "n": n,
341 + "median_delta_pct": round(med, 1),
342 + "p25": round(ds[n // 4], 1), "p75": round(ds[(3 * n) // 4], 1),
343 + "pct_sur10": round(100 * sum(1 for d in ds if d > 10) / n, 1),
344 + "pct_juste": round(100 * sum(1 for d in ds if -5 <= d <= 10) / n, 1),
345 + "pct_sous5": round(100 * sum(1 for d in ds if d < -5) / n, 1),
346 + }
347 +
348 + tous = [d for ds in deltas.values() for d in ds]
349 + vraiprix = {
350 + "ensemble": _agg("Toutes bannières", tous) if tous else None,
351 + "bannieres": sorted(
352 + (_agg(k, v) for k, v in deltas.items() if len(v) >= 30),
353 + key=lambda x: -x["median_delta_pct"]),
354 + }
355 + return {**dict(row), "vraiprix": vraiprix, "recent_syncs": log}
320 356
321 357
322 358 @app.post("/api/sync")
323 359