gendesign/tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx
bot-backend 237c18597f
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/ui): убрать debug cache-key, гейт тонких графиков, дедуп динамики цен
2026-06-20 09:49:15 +03:00

146 lines
5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}