gendesign/tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx
bot-frontend 19c69be493
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
fix(tradein): recharts a11y — aria-hidden чарты, h1, aria-label th (#835) (#844)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 20:49:19 +00:00

135 lines
4.4 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 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>
{/* #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>
);
}