SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
11.0 KB · 205 lines tsx
Raw Blame History
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