gendesign/tradein-mvp/frontend/src/components/trade-in/DistributionCard.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

156 lines
5.2 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";
/**
* DistributionCard — гистограмма распределения ₽/м² по аналогам с маркером
* «ваша квартира здесь» на median_price_per_m2 оценки. Reuses recharts (как
* PriceHistoryChart.tsx) — никаких новых зависимостей.
*/
import { useMemo } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
ReferenceLine,
Cell,
} from "recharts";
import type { AggregatedEstimate } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
}
const BIN_COUNT = 12;
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
interface Bin {
/** Левая граница бина, ₽/м². */
start: number;
/** Правая граница бина, ₽/м². */
end: number;
/** Центр бина (X-координата столбца). */
mid: number;
count: number;
/** True если в этот бин попадает median_price_per_m2 оценки. */
isSubject: boolean;
}
export function DistributionCard({ estimate }: Props) {
const subject = estimate.median_price_per_m2;
const bins = useMemo<Bin[]>(() => {
const values = estimate.analogs
.map((l) => l.price_per_m2)
.filter((v) => typeof v === "number" && v > 0);
if (values.length < 3) return [];
const min = Math.min(...values, subject);
const max = Math.max(...values, subject);
if (max <= min) return [];
const width = (max - min) / BIN_COUNT;
const out: Bin[] = Array.from({ length: BIN_COUNT }, (_, i) => {
const start = min + i * width;
const end = i === BIN_COUNT - 1 ? max : start + width;
return { start, end, mid: (start + end) / 2, count: 0, isSubject: false };
});
for (const v of values) {
let idx = Math.floor((v - min) / width);
if (idx >= BIN_COUNT) idx = BIN_COUNT - 1;
if (idx < 0) idx = 0;
out[idx].count += 1;
}
let subjIdx = Math.floor((subject - min) / width);
if (subjIdx >= BIN_COUNT) subjIdx = BIN_COUNT - 1;
if (subjIdx < 0) subjIdx = 0;
out[subjIdx].isSubject = true;
return out;
}, [estimate.analogs, subject]);
// Empty state: меньше 3 валидных аналогов — распределение не показываем.
if (bins.length === 0) return null;
const n = estimate.analogs.filter((l) => l.price_per_m2 > 0).length;
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">{n}</b> аналогам
</div>
<div style={{ marginTop: 4 }}>/м² · гистограмма</div>
</div>
</div>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
<div aria-hidden="true" style={{ width: "100%", height: 260, padding: "12px 8px 0" }}>
<ResponsiveContainer>
<BarChart data={bins} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="mid"
type="number"
domain={["dataMin", "dataMax"]}
tickFormatter={fmtK}
tick={{ fontSize: 11 }}
/>
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip
cursor={{ fill: "var(--surface-3)" }}
formatter={(value: number) => [`${value} лот.`, "В диапазоне"]}
labelFormatter={(mid: number) => {
const b = bins.find((x) => x.mid === mid);
return b
? `${Math.round(b.start).toLocaleString("ru-RU")}${Math.round(
b.end,
).toLocaleString("ru-RU")} ₽/м²`
: `${Math.round(mid).toLocaleString("ru-RU")} ₽/м²`;
}}
/>
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{bins.map((b, i) => (
<Cell
key={i}
fill={b.isSubject ? "var(--accent-2)" : "var(--accent)"}
fillOpacity={b.isSubject ? 1 : 0.55}
/>
))}
</Bar>
<ReferenceLine
x={subject}
stroke="var(--accent-2)"
strokeWidth={2}
strokeDasharray="4 2"
label={{
value: "ваша квартира",
position: "top",
fontSize: 11,
fill: "var(--accent-2)",
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="table-foot">
<span>
Ваша оценка:{" "}
<b style={{ color: "var(--fg)" }}>{subject.toLocaleString("ru-RU")} /м²</b> ·
оранжевый столбец ваш диапазон
</span>
</div>
</article>
);
}