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
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
156 lines
5.2 KiB
TypeScript
156 lines
5.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|