// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * RadarMap — carte maison « plan d'arpenteur », construite from scratch (SVG pur). * Aucune tuile, aucune bibliothèque : le sujet est au centre, chaque comparable * est positionné par azimut et distance réels (projection locale équirectangulaire), * sur des anneaux de distance concentriques. Signature ValoPlex : encre, lime, * étiquettes mono, grille d'arpentage, flèche nord, échelle. */ "use client"; import { useMemo, useState } from "react"; export interface RadarComp { id: string; lat: number; lng: number; label: string; price: string; adjusted: string; date: string; distanceM: number; weight: number; } interface Props { subject: { lat: number; lng: number; label: string }; comps: RadarComp[]; highlight?: string | null; onHover?: (id: string | null) => void; } const SIZE = 640; // viewBox const C = SIZE / 2; const PLOT_R = C - 46; // rayon utile /** Anneaux « ronds » couvrant la distance max. */ function pickRings(maxM: number): number[] { const scales = [ [100, 250, 500], [250, 500, 1000], [500, 1000, 2000], [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000], ]; for (const s of scales) if (maxM <= s[2]) return s; return scales[scales.length - 1]; } const fmtDist = (m: number) => m < 1000 ? `${m} m` : `${m / 1000} km`.replace(".", ","); export default function RadarMap({ subject, comps, highlight, onHover }: Props) { const [hover, setHover] = useState(null); const active = highlight ?? hover; const { pts, rings, scale } = useMemo(() => { const cos = Math.cos((subject.lat * Math.PI) / 180); const raw = comps.map((c) => ({ ...c, x: (c.lng - subject.lng) * 111320 * cos, y: (c.lat - subject.lat) * 110574, })); const maxM = Math.max(120, ...raw.map((p) => Math.hypot(p.x, p.y))); const ringsM = pickRings(maxM); const outer = ringsM[ringsM.length - 1]; const k = PLOT_R / outer; return { pts: raw.map((p) => ({ ...p, px: C + p.x * k, py: C - p.y * k })), rings: ringsM, scale: k, }; }, [subject, comps]); const hovered = pts.find((p) => p.id === active) ?? null; return (
{/* fond papier + grain */} {/* grille d'arpentage */} {Array.from({ length: 15 }, (_, i) => (i + 1) * (SIZE / 16)).map((g) => ( ))} {/* anneaux de distance */} {rings.map((m, i) => ( {fmtDist(m)} ))} {/* croix cardinale */} {/* flèche nord */} N {/* échelle */} {fmtDist(rings[0])} {/* rayons vers comparables (au survol) */} {hovered && ( )} {/* comparables : cercles numérotés, taille = poids */} {pts.map((p, i) => { const r = 9 + p.weight * 7; const on = p.id === active; return ( { setHover(p.id); onHover?.(p.id); }} onMouseLeave={() => { setHover(null); onHover?.(null); }} style={{ cursor: "pointer" }} > 12 ? 12 : 10.5} fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill={on ? "var(--ink)" : "#f5f3ee"} > {i + 1} ); })} {/* sujet : losange lime pulsant au centre */} {/* cartouche */} ◆ PLAN DES COMPARABLES {comps.length} ventes · azimut + distance réels {/* infobulle */} {hovered && (
SIZE * 0.62 ? "-108%" : "12px"}, ${hovered.py > SIZE * 0.7 ? "-108%" : "-8px"})`, }} >

{hovered.label}

{hovered.date} · {fmtDist(Math.round(hovered.distanceM))}

Vendu {hovered.price} → ajusté {hovered.adjusted}

)}
); }