Merge pull request 'fix(tradein/ui): убрать debug cache-key, гейт тонких графиков, разграничить динамику цен' (#1821) from fix/tradein-estimate-ui-cleanup-v2 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 44s
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 44s
Reviewed-on: #1821
This commit is contained in:
commit
dae2b5a5d9
5 changed files with 140 additions and 125 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (данные в тексте/легенде карточки). */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue