146 lines
5 KiB
TypeScript
146 lines
5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ExposureCard — ликвидность: scatter цена (₽) × срок экспозиции (дней) по
|
||
* аналогам. Дешевле → быстрее продаётся. Цена оценки отмечена reference-линией.
|
||
* Reuses recharts (как PriceHistoryChart.tsx). Degrade gracefully: рендерим
|
||
* только если ≥3 аналогов имеют непустой days_on_market.
|
||
*/
|
||
import { useMemo } from "react";
|
||
import {
|
||
ScatterChart,
|
||
Scatter,
|
||
XAxis,
|
||
YAxis,
|
||
ZAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
CartesianGrid,
|
||
ReferenceLine,
|
||
} from "recharts";
|
||
|
||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||
|
||
interface Props {
|
||
estimate: AggregatedEstimate;
|
||
}
|
||
|
||
const MIN_CHART_ANALOGS = 8;
|
||
const fmtM = (n: number) => `${(n / 1_000_000).toFixed(1)}`;
|
||
|
||
interface Point {
|
||
price: number; // ₽
|
||
days: number; // срок экспозиции
|
||
address: string;
|
||
}
|
||
|
||
export function ExposureCard({ estimate }: Props) {
|
||
const points = useMemo<Point[]>(
|
||
() =>
|
||
estimate.analogs
|
||
.filter(
|
||
(l) =>
|
||
typeof l.days_on_market === "number" &&
|
||
(l.days_on_market as number) > 0 &&
|
||
l.price_rub > 0,
|
||
)
|
||
.map((l) => ({
|
||
price: l.price_rub,
|
||
days: l.days_on_market as number,
|
||
address: l.address,
|
||
})),
|
||
[estimate.analogs],
|
||
);
|
||
|
||
// Degrade gracefully: <3 точек со сроком — карточку не показываем
|
||
// (сводится в DistributionCard, который не требует days_on_market).
|
||
if (points.length < 3) return null;
|
||
|
||
const subjectPrice = estimate.median_price_rub;
|
||
|
||
return (
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Аналитика · Ликвидность</div>
|
||
<h2>Цена и срок экспозиции</h2>
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
По <b className="mono">{points.length}</b> аналогам
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>дешевле → быстрее</div>
|
||
</div>
|
||
</div>
|
||
|
||
{points.length < MIN_CHART_ANALOGS ? (
|
||
<div className="card-body">
|
||
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
|
||
Недостаточно данных для графика — нужно не менее {MIN_CHART_ANALOGS} аналогов со сроком.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* #835: чарт декоративный (данные — в заголовке/легенде/тексте карточки);
|
||
aria-hidden убирает recharts symbol'ы role="img" без accessible-name. */}
|
||
<div aria-hidden="true" style={{ width: "100%", height: 280, padding: "12px 8px 0" }}>
|
||
<ResponsiveContainer>
|
||
<ScatterChart margin={{ top: 8, right: 20, left: 8, bottom: 16 }}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||
<XAxis
|
||
type="number"
|
||
dataKey="price"
|
||
name="Цена"
|
||
tickFormatter={fmtM}
|
||
tick={{ fontSize: 11 }}
|
||
domain={["auto", "auto"]}
|
||
label={{ value: "млн ₽", position: "insideBottomRight", offset: -8, fontSize: 11 }}
|
||
/>
|
||
<YAxis
|
||
type="number"
|
||
dataKey="days"
|
||
name="Срок"
|
||
tick={{ fontSize: 11 }}
|
||
label={{ value: "дней", angle: -90, position: "insideLeft", fontSize: 11 }}
|
||
/>
|
||
<ZAxis range={[60, 60]} />
|
||
<Tooltip
|
||
cursor={{ strokeDasharray: "3 3" }}
|
||
formatter={(value: number, name: string) =>
|
||
name === "Цена"
|
||
? [`${value.toLocaleString("ru-RU")} ₽`, name]
|
||
: [`${value} дн.`, name]
|
||
}
|
||
/>
|
||
<Scatter
|
||
data={points}
|
||
fill="var(--accent)"
|
||
fillOpacity={0.6}
|
||
/>
|
||
<ReferenceLine
|
||
x={subjectPrice}
|
||
stroke="var(--accent-2)"
|
||
strokeWidth={2}
|
||
strokeDasharray="4 2"
|
||
label={{
|
||
value: "ваша цена",
|
||
position: "top",
|
||
fontSize: 11,
|
||
fill: "var(--accent-2)",
|
||
}}
|
||
/>
|
||
</ScatterChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
|
||
<div className="table-foot">
|
||
<span>
|
||
Каждая точка — аналог в продаже · вертикаль — ваша оценка{" "}
|
||
<b style={{ color: "var(--fg)" }}>{fmtM(subjectPrice)} млн ₽</b>
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|