Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/RentRegistryCard.tsx : « Registre des loyers » orienté décision —5// en tête le loyer et son écart à la médiane du même nombre de chambres,6// jauge dynamique (p10 → p90, boîte p25–p75, médiane, ce loyer), médianes7// par nombre de chambres (type du logement en avant), 3 déclarations les8// plus proches puis « Voir les N loyers comparables » → bottom sheet avec9// filtres (même nb de chambres · tri distance / date / prix).10// -----------------------------------------------------------------------------11import { useMemo, useState } from "react";12import { Listing, RdlItem, RdlNearby, fmtDist, fmtPrice } from "../api";13import { IcoWallet } from "../components/Icons";14import BottomSheet from "./BottomSheet";15import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, NBSP } from "./ui";16import { Res } from "./useFicheData";1718function fmtDate(date: string | null | undefined, year: number | null | undefined): string {19 if (date && year != null && date.startsWith(String(year))) {20 const d = new Date(date + "T00:00:00");21 if (!Number.isNaN(d.getTime()))22 return d.toLocaleDateString("fr-CA", { month: "short", year: "numeric" });23 }24 return year != null ? String(year) : (date ? date.slice(0, 4) : "—");25}2627/** Jauge : piste p10–p90, boîte p25–p75, médiane (tick) et ce loyer (point). */28function Gauge({ d, price, med }: { d: RdlNearby; price: number; med: number }) {29 const q = d.quartiles;30 if (!q || q.p90 <= q.p10) return null;31 const W = 320, H = 46, y = 18, h = 10;32 const lo = Math.min(q.p10, price), hi = Math.max(q.p90, price);33 const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * (W - 12) + 6;34 const px = x(price), mx = x(med);35 const anchor = (v: number) => (v < 50 ? "start" : v > W - 50 ? "end" : "middle");36 return (37 <div className="lk-gauge">38 <div className="lk-gauge-lbls" aria-hidden="true">39 <span className="good">Très bon prix</span><span>Médiane</span><span className="bad">Cher</span>40 </div>41 <svg viewBox={`0 0 ${W} ${H}`} role="img"42 aria-label={`Ce loyer ${fmtPrice(price)} ; loyers déclarés du secteur de ${fmtPrice(q.p10)} à ${fmtPrice(q.p90)}, médiane ${fmtPrice(med)}`}>43 <rect className="lk-gauge-track" x="6" y={y} width={W - 12} height={h} rx="5" />44 <rect className="lk-gauge-box" x={x(q.p25)} y={y} width={Math.max(4, x(q.p75) - x(q.p25))} height={h} rx="5" />45 <line className="lk-gauge-med" x1={mx} x2={mx} y1={y - 5} y2={y + h + 5} />46 <text className="lk-gauge-txt" x={mx} y={H - 2} textAnchor={anchor(mx)}>{fmtPrice(med)}</text>47 <circle className="lk-gauge-me" cx={px} cy={y + h / 2} r="7" />48 <text className="lk-gauge-txt me" x={px} y={y - 8} textAnchor={anchor(px)}>ce loyer {fmtPrice(price)}</text>49 </svg>50 </div>51 );52}5354/** Barres des médianes par nombre de chambres — type du logement en orange. */55function RoomBars({ d, rKey }: { d: RdlNearby; rKey: string | null }) {56 let entries = Object.entries(d.by_rooms ?? {}).filter(([, v]) => v.n >= 2)57 .sort(([a], [b]) => Number(a) - Number(b));58 // au plus 5 colonnes : 0–4+ (regroupement des grands logements)59 if (entries.length > 5) {60 const keep = entries.filter(([k]) => Number(k) <= 3);61 const big = entries.filter(([k]) => Number(k) >= 4);62 if (big.length) {63 const n = big.reduce((s, [, v]) => s + v.n, 0);64 const med = Math.round(big.reduce((s, [, v]) => s + v.median * v.n, 0) / n);65 keep.push(["4+", { n, median: med }]);66 }67 entries = keep;68 }69 if (entries.length < 2) return null;70 const on = (k: string) => k === rKey || (k === "4+" && rKey != null && Number(rKey) >= 4);71 const W = 320, H = 118, top = 20, bottom = 32;72 const plotH = H - top - bottom;73 const max = Math.max(...entries.map(([, v]) => v.median), 1);74 const slot = W / entries.length, bw = Math.min(40, slot * 0.6);75 return (76 <svg className="lk-bars" viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loyer médian déclaré selon le nombre de chambres">77 {entries.map(([rooms, v], i) => {78 const h = Math.max(3, (v.median / max) * plotH);79 const bx = i * slot + (slot - bw) / 2, cx = i * slot + slot / 2;80 const lbl = rooms === "0" ? "Studio" : `${rooms} ch.`;81 return (82 <g key={rooms}>83 <title>{`${lbl} : médiane ${fmtPrice(v.median)} (${v.n} déclarations)`}</title>84 <rect className={`lk-bar ${on(rooms) ? "on" : ""}`} x={bx} y={H - bottom - h} width={bw} height={h} rx="5" />85 <text className="lk-bar-val" x={cx} y={H - bottom - h - 5} textAnchor="middle">{fmtPrice(v.median)}</text>86 <text className={`lk-bar-cat ${on(rooms) ? "on" : ""}`} x={cx} y={H - bottom + 13} textAnchor="middle">{lbl}</text>87 <text className="lk-bar-n" x={cx} y={H - bottom + 25} textAnchor="middle">{on(rooms) ? "ce logement" : `${v.n} décl.`}</text>88 </g>89 );90 })}91 <line className="lk-bars-axe" x1="0" x2={W} y1={H - bottom} y2={H - bottom} />92 </svg>93 );94}9596function Item({ it }: { it: RdlItem }) {97 return (98 <li className="lk-item">99 <div className="lk-item-main">100 <div className="lk-item-t">{it.address}</div>101 <div className="lk-item-s">102 {it.rooms != null ? (it.rooms === 0 ? "Studio" : `${it.rooms} ch.`) : "—"} · {fmtDate(it.date, it.year)}103 {it.heating ? " · chauffé" : ""}{it.furnished ? " · meublé" : ""}104 </div>105 </div>106 <div className="lk-item-r">107 <div className="lk-item-v">{fmtPrice(it.price)}</div>108 <div className="lk-item-m">{fmtDist(it.dist_m)}</div>109 </div>110 </li>111 );112}113114export default function RentRegistryCard({ l, rdl, onRetry }: { l: Listing; rdl: Res<RdlNearby>; onRetry: () => void }) {115 const [sheet, setSheet] = useState(false);116 const [meme, setMeme] = useState(true);117 const [tri, setTri] = useState<"dist" | "date" | "prix">("dist");118 const d = rdl.status === "ok" ? rdl.data : null;119 const rKey = l.bedrooms != null ? String(Math.round(l.bedrooms)) : null;120 const same = d && rKey && d.by_rooms?.[rKey] && d.by_rooms[rKey].n >= 5 ? d.by_rooms[rKey] : null;121 const med = d ? (same ? same.median : (d.median_recent ?? d.median ?? 0)) : 0;122 const pct = d && l.price != null && med ? Math.round(((l.price - med) / med) * 100) : null;123124 const items = useMemo(() => {125 if (!d) return [];126 let arr = d.items;127 if (meme && rKey && arr.some((i) => i.rooms != null && String(i.rooms) === rKey))128 arr = arr.filter((i) => i.rooms != null && String(i.rooms) === rKey);129 arr = [...arr];130 if (tri === "dist") arr.sort((a, b) => a.dist_m - b.dist_m);131 if (tri === "date") arr.sort((a, b) => (b.year ?? 0) - (a.year ?? 0) || a.dist_m - b.dist_m);132 if (tri === "prix") arr.sort((a, b) => a.price - b.price);133 return arr;134 }, [d, meme, rKey, tri]);135136 if (rdl.status === "na" || (d && d.n === 0)) return null;137138 return (139 <SectionCard id="loyers" title="Registre des loyers" icon={<IcoWallet size={18} />}140 sub={d ? `${d.n.toLocaleString("fr-CA")} loyers réellement payés, déclarés à moins de ${fmtDist(d.radius_m)}` : undefined}>141 {rdl.status === "loading" && <SkeletonLines n={5} />}142 {rdl.status === "error" && <ErrorState onRetry={onRetry}>Registre des loyers temporairement indisponible.</ErrorState>}143 {d && (144 <>145 <div className="lk-market-head">146 <div>147 <div className="lk-market-big">{l.price != null ? fmtPrice(l.price) : "—"}<small>/ mois</small></div>148 <div className="lk-market-sub">149 {pct != null150 ? <>{Math.abs(pct) < 3 ? "Dans la moyenne" : <><b>{Math.abs(pct)}{NBSP}%</b> {pct < 0 ? "sous" : "au-dessus"}</>} des loyers déclarés151 {same ? ` (${rKey === "0" ? "studios" : `${rKey} ch.`}, ${same.n} déclarations)` : ""}</>152 : "Loyer non comparable"}153 </div>154 </div>155 <StatTile value={fmtPrice(med)} label={same ? `Médiane ${rKey === "0" ? "studio" : `${rKey} ch.`}` : "Médiane du secteur"} anim={false} />156 </div>157 {l.price != null && med > 0 && <Gauge d={d} price={l.price} med={med} />}158 <RoomBars d={d} rKey={rKey} />159 {d.items.length > 0 && (160 <>161 <ul className="lk-list" style={{ marginTop: 8 }}>162 {[...d.items].sort((a, b) => a.dist_m - b.dist_m).slice(0, 3).map((it, i) => <Item it={it} key={i} />)}163 </ul>164 {d.items.length > 3 && (165 <MoreButton onClick={() => setSheet(true)}>166 {d.items.length < d.n167 ? `Voir les ${d.items.length.toLocaleString("fr-CA")} loyers les plus proches (sur ${d.n.toLocaleString("fr-CA")})`168 : `Voir les ${d.items.length.toLocaleString("fr-CA")} loyers comparables`}169 </MoreButton>170 )}171 </>172 )}173 <SourceLine name="Registre des loyers" href="https://registre-des-loyers.ca/fr/qc/carte"174 date={d.n_recent ? `${d.n_recent.toLocaleString("fr-CA")} déclarations depuis 2023` : undefined} />175 <Accordion title="À propos de ces données" small>176 <p>Loyers réellement payés, déclarés volontairement par des locataires au Registre des loyers177 (initiative de Vivre en ville) — données citoyennes non vérifiées, fournies à titre indicatif.178 La comparaison privilégie la médiane du même nombre de chambres quand le secteur compte au179 moins 5 déclarations ; la jauge va du 10ᵉ au 90ᵉ centile, la boîte grise couvre le quart180 central (p25–p75).</p>181 </Accordion>182 <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Loyers comparables"183 sub={`${d.n.toLocaleString("fr-CA")} déclarations à moins de ${fmtDist(d.radius_m)} · ${items.length} affichées`} tall>184 <div className="lk-sheet-filters">185 {rKey && (186 <button type="button" className={`lk-chip ${meme ? "on" : ""}`} onClick={() => setMeme(!meme)} aria-pressed={meme}>187 Même nombre de chambres188 </button>189 )}190 <div className="lk-seg" role="group" aria-label="Tri">191 {(["dist", "date", "prix"] as const).map((t) => (192 <button type="button" key={t} className={tri === t ? "on" : ""} onClick={() => setTri(t)} aria-pressed={tri === t}>193 {t === "dist" ? "Distance" : t === "date" ? "Date" : "Prix"}194 </button>195 ))}196 </div>197 </div>198 <ul className="lk-list">{items.map((it, i) => <Item it={it} key={i} />)}</ul>199 </BottomSheet>200 </>201 )}202 </SectionCard>203 );204}205