fix(tradein/ui): убрать debug cache-key, гейт тонких графиков, разграничить динамику цен #1821

Merged
lekss361 merged 1 commit from fix/tradein-estimate-ui-cleanup-v2 into main 2026-06-20 06:55:04 +00:00
5 changed files with 140 additions and 125 deletions

View file

@ -25,6 +25,7 @@ interface Props {
} }
const BIN_COUNT = 12; const BIN_COUNT = 12;
const MIN_CHART_ANALOGS = 8;
const fmtK = (n: number) => `${Math.round(n / 1000)}k`; const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
interface Bin { interface Bin {
@ -74,7 +75,7 @@ export function DistributionCard({ estimate }: Props) {
return out; return out;
}, [estimate.analogs, subject]); }, [estimate.analogs, subject]);
// Empty state: меньше 3 валидных аналогов — распределение не показываем. // Empty state: меньше 3 валидных аналогов — бины не строим вовсе.
if (bins.length === 0) return null; if (bins.length === 0) return null;
const n = estimate.analogs.filter((l) => l.price_per_m2 > 0).length; const n = estimate.analogs.filter((l) => l.price_per_m2 > 0).length;
@ -94,63 +95,73 @@ export function DistributionCard({ estimate }: Props) {
</div> </div>
</div> </div>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */} {n < MIN_CHART_ANALOGS ? (
<div aria-hidden="true" style={{ width: "100%", height: 260, padding: "12px 8px 0" }}> <div className="card-body">
<ResponsiveContainer> <p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
<BarChart data={bins} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}> Мало аналогов для графика нужно не менее {MIN_CHART_ANALOGS}.
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> </p>
<XAxis </div>
dataKey="mid" ) : (
type="number" <>
domain={["dataMin", "dataMax"]} {/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
tickFormatter={fmtK} <div aria-hidden="true" style={{ width: "100%", height: 260, padding: "12px 8px 0" }}>
tick={{ fontSize: 11 }} <ResponsiveContainer>
/> <BarChart data={bins} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} /> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<Tooltip <XAxis
cursor={{ fill: "var(--surface-3)" }} dataKey="mid"
formatter={(value: number) => [`${value} лот.`, "В диапазоне"]} type="number"
labelFormatter={(mid: number) => { domain={["dataMin", "dataMax"]}
const b = bins.find((x) => x.mid === mid); tickFormatter={fmtK}
return b tick={{ fontSize: 11 }}
? `${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}
/> />
))} <YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
</Bar> <Tooltip
<ReferenceLine cursor={{ fill: "var(--surface-3)" }}
x={subject} formatter={(value: number) => [`${value} лот.`, "В диапазоне"]}
stroke="var(--accent-2)" labelFormatter={(mid: number) => {
strokeWidth={2} const b = bins.find((x) => x.mid === mid);
strokeDasharray="4 2" return b
label={{ ? `${Math.round(b.start).toLocaleString("ru-RU")}${Math.round(
value: "ваша квартира", b.end,
position: "top", ).toLocaleString("ru-RU")} /м²`
fontSize: 11, : `${Math.round(mid).toLocaleString("ru-RU")} ₽/м²`;
fill: "var(--accent-2)", }}
}} />
/> <Bar dataKey="count" radius={[3, 3, 0, 0]}>
</BarChart> {bins.map((b, i) => (
</ResponsiveContainer> <Cell
</div> 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"> <div className="table-foot">
<span> <span>
Ваша оценка:{" "} Ваша оценка:{" "}
<b style={{ color: "var(--fg)" }}>{subject.toLocaleString("ru-RU")} /м²</b> · <b style={{ color: "var(--fg)" }}>{subject.toLocaleString("ru-RU")} /м²</b> ·
оранжевый столбец ваш диапазон оранжевый столбец ваш диапазон
</span> </span>
</div> </div>
</>
)}
</article> </article>
); );
} }

View file

@ -25,6 +25,7 @@ interface Props {
estimate: AggregatedEstimate; estimate: AggregatedEstimate;
} }
const MIN_CHART_ANALOGS = 8;
const fmtM = (n: number) => `${(n / 1_000_000).toFixed(1)}`; const fmtM = (n: number) => `${(n / 1_000_000).toFixed(1)}`;
interface Point { interface Point {
@ -72,64 +73,74 @@ export function ExposureCard({ estimate }: Props) {
</div> </div>
</div> </div>
{/* #835: чарт декоративный (данные в заголовке/легенде/тексте карточки); {points.length < MIN_CHART_ANALOGS ? (
aria-hidden убирает recharts symbol'ы role="img" без accessible-name. */} <div className="card-body">
<div aria-hidden="true" style={{ width: "100%", height: 280, padding: "12px 8px 0" }}> <p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
<ResponsiveContainer> Недостаточно данных для графика нужно не менее {MIN_CHART_ANALOGS} аналогов со сроком.
<ScatterChart margin={{ top: 8, right: 20, left: 8, bottom: 16 }}> </p>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> </div>
<XAxis ) : (
type="number" <>
dataKey="price" {/* #835: чарт декоративный (данные в заголовке/легенде/тексте карточки);
name="Цена" aria-hidden убирает recharts symbol'ы role="img" без accessible-name. */}
tickFormatter={fmtM} <div aria-hidden="true" style={{ width: "100%", height: 280, padding: "12px 8px 0" }}>
tick={{ fontSize: 11 }} <ResponsiveContainer>
domain={["auto", "auto"]} <ScatterChart margin={{ top: 8, right: 20, left: 8, bottom: 16 }}>
label={{ value: "млн ₽", position: "insideBottomRight", offset: -8, fontSize: 11 }} <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
/> <XAxis
<YAxis type="number"
type="number" dataKey="price"
dataKey="days" name="Цена"
name="Срок" tickFormatter={fmtM}
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
label={{ value: "дней", angle: -90, position: "insideLeft", fontSize: 11 }} domain={["auto", "auto"]}
/> label={{ value: "млн ₽", position: "insideBottomRight", offset: -8, fontSize: 11 }}
<ZAxis range={[60, 60]} /> />
<Tooltip <YAxis
cursor={{ strokeDasharray: "3 3" }} type="number"
formatter={(value: number, name: string) => dataKey="days"
name === "Цена" name="Срок"
? [`${value.toLocaleString("ru-RU")}`, name] tick={{ fontSize: 11 }}
: [`${value} дн.`, name] label={{ value: "дней", angle: -90, position: "insideLeft", fontSize: 11 }}
} />
/> <ZAxis range={[60, 60]} />
<Scatter <Tooltip
data={points} cursor={{ strokeDasharray: "3 3" }}
fill="var(--accent)" formatter={(value: number, name: string) =>
fillOpacity={0.6} name === "Цена"
/> ? [`${value.toLocaleString("ru-RU")}`, name]
<ReferenceLine : [`${value} дн.`, name]
x={subjectPrice} }
stroke="var(--accent-2)" />
strokeWidth={2} <Scatter
strokeDasharray="4 2" data={points}
label={{ fill="var(--accent)"
value: "ваша цена", fillOpacity={0.6}
position: "top", />
fontSize: 11, <ReferenceLine
fill: "var(--accent-2)", x={subjectPrice}
}} stroke="var(--accent-2)"
/> strokeWidth={2}
</ScatterChart> strokeDasharray="4 2"
</ResponsiveContainer> label={{
</div> value: "ваша цена",
position: "top",
fontSize: 11,
fill: "var(--accent-2)",
}}
/>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="table-foot"> <div className="table-foot">
<span> <span>
Каждая точка аналог в продаже · вертикаль ваша оценка{" "} Каждая точка аналог в продаже · вертикаль ваша оценка{" "}
<b style={{ color: "var(--fg)" }}>{fmtM(subjectPrice)} млн </b> <b style={{ color: "var(--fg)" }}>{fmtM(subjectPrice)} млн </b>
</span> </span>
</div> </div>
</>
)}
</article> </article>
); );
} }

View file

@ -46,10 +46,10 @@ export function PriceHistoryChart({ points }: Props) {
<article className="card" style={{ marginTop: 12, padding: 16 }}> <article className="card" style={{ marginTop: 12, padding: 16 }}>
<header style={{ marginBottom: 8 }}> <header style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}> <h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
Динамика цен в доме История цен в этом доме
</h4> </h4>
<small style={{ color: "var(--muted, #6b7280)" }}> <small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по году · Avito + Яндекс (по {totalLots} лотам) Медиана /м² по годам · только этот дом · Avito + Яндекс ({totalLots} лотов)
</small> </small>
</header> </header>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */} {/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}

View file

@ -58,8 +58,8 @@ export function PriceTrendCard({ estimate }: Props) {
<article className="card"> <article className="card">
<div className="card-head"> <div className="card-head">
<div> <div>
<div className="section-kicker">Аналитика · Тренд</div> <div className="section-kicker">Аналитика · Тренд рынка</div>
<h2>Динамика /м²</h2> <h2>Динамика /м² по рынку района</h2>
</div> </div>
<div className="card-meta"> <div className="card-meta">
<div> <div>
@ -101,7 +101,7 @@ export function PriceTrendCard({ estimate }: Props) {
<div className="table-foot"> <div className="table-foot">
<span> <span>
Медиана /м² по месяцам · {fmtMonth(data[0].month)} {" "} Медиана /м² по аналогам рынка · {fmtMonth(data[0].month)} {" "}
{fmtMonth(data[data.length - 1].month)} {fmtMonth(data[data.length - 1].month)}
</span> </span>
</div> </div>

View file

@ -198,13 +198,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
</div> </div>
<div className="card-foot"> <div className="card-foot">
<span className="mono" style={{ fontSize: 11, letterSpacing: "0.06em" }}>
CACHE-KEY ·{" "}
</span>
<span className="mono" style={{ fontSize: 11 }}>
{estimate?.estimate_id?.slice(0, 18) ?? "—"}
</span>{" "}
·{" "}
<span>при недоступности источника частичный результат не блокируется</span> <span>при недоступности источника частичный результат не блокируется</span>
</div> </div>
</article> </article>