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>
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 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 | |