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