SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.1%
8.7 KB · 253 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * RadarMap — carte maison « plan d'arpenteur », construite from scratch (SVG pur).4 * Aucune tuile, aucune bibliothèque : le sujet est au centre, chaque comparable5 * est positionné par azimut et distance réels (projection locale équirectangulaire),6 * sur des anneaux de distance concentriques. Signature Vrai-Prix : encre, lime,7 * étiquettes mono, grille d'arpentage, flèche nord, échelle.8 */9"use client";10import { useMemo, useState } from "react";1112export interface RadarComp {13  id: string;14  lat: number;15  lng: number;16  label: string;17  price: string;18  adjusted: string;19  date: string;20  distanceM: number;21  weight: number;22}2324interface Props {25  subject: { lat: number; lng: number; label: string };26  comps: RadarComp[];27  highlight?: string | null;28  onHover?: (id: string | null) => void;29}3031const SIZE = 640; // viewBox32const C = SIZE / 2;33const PLOT_R = C - 46; // rayon utile3435/** Anneaux « ronds » couvrant la distance max. */36function pickRings(maxM: number): number[] {37  const scales = [38    [100, 250, 500],39    [250, 500, 1000],40    [500, 1000, 2000],41    [1000, 2500, 5000],42    [2500, 5000, 10000],43    [5000, 10000, 20000],44    [10000, 25000, 50000],45  ];46  for (const s of scales) if (maxM <= s[2]) return s;47  return scales[scales.length - 1];48}4950const fmtDist = (m: number) =>51  m < 1000 ? `${m} m` : `${m / 1000} km`.replace(".", ",");5253export default function RadarMap({ subject, comps, highlight, onHover }: Props) {54  const [hover, setHover] = useState<string | null>(null);55  const active = highlight ?? hover;5657  const { pts, rings, scale } = useMemo(() => {58    const cos = Math.cos((subject.lat * Math.PI) / 180);59    const raw = comps.map((c) => ({60      ...c,61      x: (c.lng - subject.lng) * 111320 * cos,62      y: (c.lat - subject.lat) * 110574,63    }));64    const maxM = Math.max(120, ...raw.map((p) => Math.hypot(p.x, p.y)));65    const ringsM = pickRings(maxM);66    const outer = ringsM[ringsM.length - 1];67    const k = PLOT_R / outer;68    return {69      pts: raw.map((p) => ({ ...p, px: C + p.x * k, py: C - p.y * k })),70      rings: ringsM,71      scale: k,72    };73  }, [subject, comps]);7475  const hovered = pts.find((p) => p.id === active) ?? null;7677  return (78    <div className="relative">79      <svg80        viewBox={`0 0 ${SIZE} ${SIZE}`}81        role="img"82        aria-label={`Plan des comparables autour de ${subject.label}`}83        className="block w-full bg-[var(--surface)]"84      >85        <defs>86          <filter id="grain">87            <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" result="n" />88            <feColorMatrix in="n" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.03 0" />89            <feComposite operator="over" in2="SourceGraphic" />90          </filter>91        </defs>9293        {/* fond papier + grain */}94        <rect width={SIZE} height={SIZE} fill="var(--surface)" />95        <rect width={SIZE} height={SIZE} fill="#f5f3ee" opacity="0.6" filter="url(#grain)" />9697        {/* grille d'arpentage */}98        {Array.from({ length: 15 }, (_, i) => (i + 1) * (SIZE / 16)).map((g) => (99          <g key={g} stroke="rgba(20,24,20,0.05)" strokeWidth="1">100            <line x1={g} y1="0" x2={g} y2={SIZE} />101            <line x1="0" y1={g} x2={SIZE} y2={g} />102          </g>103        ))}104105        {/* anneaux de distance */}106        {rings.map((m, i) => (107          <g key={m}>108            <circle109              cx={C}110              cy={C}111              r={m * scale}112              fill="none"113              stroke={i === rings.length - 1 ? "rgba(20,24,20,0.85)" : "rgba(20,24,20,0.22)"}114              strokeWidth={i === rings.length - 1 ? 1.5 : 1}115              strokeDasharray={i === rings.length - 1 ? "none" : "5 6"}116            />117            <text118              x={C + m * scale * 0.7071 + 4}119              y={C - m * scale * 0.7071 - 4}120              fontSize="10.5"121              fontFamily="var(--font-jetbrains), monospace"122              fill="rgba(20,24,20,0.45)"123              style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}124            >125              {fmtDist(m)}126            </text>127          </g>128        ))}129130        {/* croix cardinale */}131        <line x1={C} y1={C - PLOT_R} x2={C} y2={C + PLOT_R} stroke="rgba(20,24,20,0.14)" strokeWidth="1" />132        <line x1={C - PLOT_R} y1={C} x2={C + PLOT_R} y2={C} stroke="rgba(20,24,20,0.14)" strokeWidth="1" />133134        {/* flèche nord */}135        <g transform={`translate(${SIZE - 34}, 38)`}>136          <path d="M0,-16 L7,8 L0,3 L-7,8 Z" fill="var(--ink)" />137          <text y="24" textAnchor="middle" fontSize="11" fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink)">138            N139          </text>140        </g>141142        {/* échelle */}143        <g transform={`translate(28, ${SIZE - 30})`}>144          <line x1="0" y1="0" x2={rings[0] * scale} y2="0" stroke="var(--ink)" strokeWidth="2.5" />145          <line x1="0" y1="-4" x2="0" y2="4" stroke="var(--ink)" strokeWidth="2" />146          <line x1={rings[0] * scale} y1="-4" x2={rings[0] * scale} y2="4" stroke="var(--ink)" strokeWidth="2" />147          <text x={rings[0] * scale + 8} y="4" fontSize="10.5" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink-2)">148            {fmtDist(rings[0])}149          </text>150        </g>151152        {/* rayons vers comparables (au survol) */}153        {hovered && (154          <line155            x1={C}156            y1={C}157            x2={hovered.px}158            y2={hovered.py}159            stroke="var(--green)"160            strokeWidth="1.5"161            strokeDasharray="3 4"162          />163        )}164165        {/* comparables : cercles numérotés, taille = poids */}166        {pts.map((p, i) => {167          const r = 9 + p.weight * 7;168          const on = p.id === active;169          return (170            <g171              key={p.id}172              transform={`translate(${p.px}, ${p.py})`}173              onMouseEnter={() => {174                setHover(p.id);175                onHover?.(p.id);176              }}177              onMouseLeave={() => {178                setHover(null);179                onHover?.(null);180              }}181              style={{ cursor: "pointer" }}182            >183              <circle184                r={r}185                fill={on ? "var(--lime)" : "var(--green)"}186                stroke="var(--ink)"187                strokeWidth={on ? 2.5 : 1.5}188              />189              <text190                textAnchor="middle"191                dy="0.36em"192                fontSize={r > 12 ? 12 : 10.5}193                fontWeight="700"194                fontFamily="var(--font-jetbrains), monospace"195                fill={on ? "var(--ink)" : "#f5f3ee"}196              >197                {i + 1}198              </text>199            </g>200          );201        })}202203        {/* sujet : losange lime pulsant au centre */}204        <g transform={`translate(${C}, ${C})`}>205          <rect206            x={-13}207            y={-13}208            width={26}209            height={26}210            transform="rotate(45)"211            fill="var(--lime)"212            stroke="var(--ink)"213            strokeWidth="2.5"214          >215            <animate attributeName="opacity" values="1;0.75;1" dur="2.4s" repeatCount="indefinite" />216          </rect>217          <rect x={-4} y={-4} width={8} height={8} transform="rotate(45)" fill="var(--ink)" />218        </g>219220        {/* cartouche */}221        <g fontFamily="var(--font-jetbrains), monospace">222          <text x="28" y="34" fontSize="11" fontWeight="700" fill="var(--ink)" style={{ textTransform: "uppercase", letterSpacing: "0.12em" }}>223            ◆ PLAN DES COMPARABLES224          </text>225          <text x="28" y="50" fontSize="10" fill="rgba(20,24,20,0.5)" style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>226            {comps.length} ventes · azimut + distance réels227          </text>228        </g>229      </svg>230231      {/* infobulle */}232      {hovered && (233        <div234          className="pointer-events-none absolute z-10 max-w-[230px] rounded-lg border border-lime bg-ink px-3.5 py-2.5 text-[12px] leading-relaxed text-paper shadow-[0_8px_24px_rgba(16,18,16,0.35)]"235          style={{236            left: `${(hovered.px / SIZE) * 100}%`,237            top: `${(hovered.py / SIZE) * 100}%`,238            transform: `translate(${hovered.px > SIZE * 0.62 ? "-108%" : "12px"}, ${hovered.py > SIZE * 0.7 ? "-108%" : "-8px"})`,239          }}240        >241          <p className="vp-display mb-0.5 font-bold text-lime">{hovered.label}</p>242          <p className="vp-mono text-[10.5px] uppercase tracking-[0.05em]">243            {hovered.date} · {fmtDist(Math.round(hovered.distanceM))}244          </p>245          <p>246            Vendu {hovered.price} → ajusté <b>{hovered.adjusted}</b>247          </p>248        </div>249      )}250    </div>251  );252}253