fix(tradein/v2): polish АНАЛИТИКА ДОМА — растянуть scatter, клэмп оси к 0, честные легенды/бакеты, убрать фейк-тренд
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s

Overlay 06 "АНАЛИТИКА ДОМА" had several display/chart defects vs the design:

- scatter "Цена × срок продажи" letterboxed into a narrow column
  (missing preserveAspectRatio="none", unlike the price-history svg).
- Y-domain wasn't clamped to 0 in buildScatterDetail AND buildScatterMini
  (same root-cause bug in both), so a thin/high price cluster could show
  a negative "−1M" axis tick.
- "сделки" legend/series rendered even with 0 deal points (Rosreestr ДКП
  always have days_on_market=null → never plotted) — now gated like the
  avito/yandex conditional legend.
- the "линия тренда" was a literal hardcoded x1/y1/x2/y2 line, entirely
  data-independent — removed (a real regression is a separate feature).
- bargainColor sign was inverted: it greened a price RISE, but the
  displayed bargainSigned value negates the raw pct, and the fixture
  (kpis[1], "−3.0%") greens the price-DROP case actually shown on screen.
- sell-time buckets ignored the backend insufficient_data flag; threaded
  it through SellTimeBucket -> SellTimeTier -> sellTimeTierValid so a
  bucket the backend calls too thin renders as "—", not a confident value.
- subject scatter point silently used a hardcoded 90-day fallback with no
  marker when est_days_on_market was null; now flagged (subjectApprox)
  and rendered as a dashed/lower-opacity estimate instead of passing off
  as measured data.
- sold_rate_pct lacked a Number.isFinite guard like the other formatters.
- PH_GRID_Y gridlines were unevenly spaced (last gap 40px vs 45px
  elsewhere); evened out and exported as the single source of truth so
  AnalyticsView's background gridlines can no longer drift from the
  yTick label positions (they previously duplicated the same y's as a
  second hardcoded literal).
This commit is contained in:
bot-backend 2026-07-12 17:32:32 +03:00
parent 8dd35c01d8
commit b2bd827507
5 changed files with 107 additions and 57 deletions

View file

@ -8,7 +8,7 @@
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX } from "./mappers";
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
@ -115,9 +115,18 @@ function sellTimeTierN(tier: SellTimeTier): number {
return Number.isFinite(n) ? n : 0;
}
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
/**
* A tier is trustworthy only with a real point estimate, SELLTIME_MIN_N lots,
* AND the backend not having flagged it as insufficient (V3) a bucket the
* backend itself calls too thin is dropped like any other untrustworthy tile,
* never shown as a confident value.
*/
function sellTimeTierValid(tier: SellTimeTier): boolean {
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
return (
!tier.insufficientData &&
tier.days !== "—" &&
sellTimeTierN(tier) >= SELLTIME_MIN_N
);
}
export default function AnalyticsView({
@ -377,11 +386,9 @@ export default function AnalyticsView({
>
<title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={40} y1={20} x2={900} y2={20} />
<line x1={40} y1={65} x2={900} y2={65} />
<line x1={40} y1={110} x2={900} y2={110} />
<line x1={40} y1={155} x2={900} y2={155} />
<line x1={40} y1={195} x2={900} y2={195} />
{PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} />
))}
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.yTicks.map((t) => (
@ -517,24 +524,26 @@ export default function AnalyticsView({
/>
аналоги
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
{data.scatterDetail.deals.length > 0 ? (
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
/>
сделки
</span>
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
) : null}
<span
style={{
display: "flex",
@ -558,6 +567,7 @@ export default function AnalyticsView({
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
>
@ -588,16 +598,6 @@ export default function AnalyticsView({
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={100}
y1={226}
x2={820}
y2={55}
stroke={tokens.accent}
strokeWidth={1.5}
strokeDasharray="5 5"
opacity={0.45}
/>
<g fill={tokens.scatterDeal}>
{data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
@ -608,21 +608,33 @@ export default function AnalyticsView({
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
strokeWidth={1.2}
opacity={0.4}
/>
<g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
{/* V4: subjectApprox marks a fallback x (est_days_on_market unknown)
dashed ring + title flag it as an estimate, not a measurement. */}
{data.scatterDetail.subjectApprox ? (
<title>
Срок продажи оценка (~90 дн.), точных данных нет
</title>
) : null}
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
strokeWidth={1.2}
strokeDasharray={
data.scatterDetail.subjectApprox ? "3 3" : undefined
}
opacity={0.4}
/>
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}

View file

@ -480,9 +480,9 @@ export const analytics: Analytics = {
yandex: "58,75 260,82 470,51 680,42 885,24",
yTicks: [
{ label: "200k", y: 20 },
{ label: "150k", y: 65 },
{ label: "100k", y: 110 },
{ label: "50k", y: 155 },
{ label: "150k", y: 64 },
{ label: "100k", y: 108 },
{ label: "50k", y: 151 },
{ label: "0k", y: 195 },
],
},

View file

@ -693,10 +693,13 @@ function buildScatterMini(
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
// C3: clamp the domain floor to 0 (mirror buildPriceHistory) — an un-clamped
// yMin-yPad can go negative, which surfaces as a "1M" axis tick on a chart
// that only ever plots prices.
const domain: ScatterDomain = {
xMin: SCATTER_X_DOMAIN.min,
xMax: SCATTER_X_DOMAIN.max,
yMin: yMin - yPad,
yMin: Math.max(0, yMin - yPad),
yMax: yMax + yPad,
};
@ -1443,8 +1446,15 @@ export function mapHistory(
// Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
// V1: median_bargain_pct > 0 means the asking price was REDUCED (see the
// bargainSigned doc comment below) — the common, honest "price dropped"
// case, which bargainSigned negates into the displayed "X%". That displayed
// drop is the one the fixture (analytics.kpis[1], "3.0%") colours success —
// NOT a raw negative pct (which would mean the price rose and bargainSigned
// would show a misleading "+X%" in green). The old `< 0` check had this
// backwards.
const bargainColor =
k?.median_bargain_pct != null && k.median_bargain_pct < 0
k?.median_bargain_pct != null && k.median_bargain_pct > 0
? tokens.success
: undefined;
return [
@ -1464,7 +1474,10 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
},
{
label: "ДОЛЯ СНЯТЫХ",
value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—",
value:
k != null && Number.isFinite(k.sold_rate_pct)
? `${Math.round(k.sold_rate_pct)}%`
: "—",
sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—",
},
];
@ -1517,6 +1530,7 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
: "—",
count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
variant: meta.variant,
insufficientData: b.insufficient_data === true,
};
});
}
@ -1534,7 +1548,10 @@ export const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
// Exported (not just local) so AnalyticsView's background gridlines share this
// EXACT geometry with the yTick labels below (C5) — a hardcoded copy in the view
// drifted out of sync with this array; single source of truth like phYearX/PH_X_*.
export const PH_GRID_Y = [20, 64, 108, 151, 195]; // 5 gridlines, evenly spaced (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
@ -1724,15 +1741,23 @@ function buildScatterDetail(
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
// C3: clamp the domain floor to 0 (mirror buildPriceHistory / buildScatterMini)
// — an un-clamped yMin-yPad can go negative, surfacing a "1M" axis tick.
const domain: ScatterDomain = {
xMin: DETAIL_X_DOMAIN.min,
xMax: DETAIL_X_DOMAIN.max,
yMin: yMin - yPad,
yMin: Math.max(0, yMin - yPad),
yMax: yMax + yPad,
};
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
// V4: est_days_on_market is honestly unknown for a meaningful share of
// estimates. Falling back to a silent 90 plotted the subject as if its
// exposure were a real measurement. Keep the fallback (dropping the point
// entirely would lose a real, known subjectPrice) but flag it so the view
// can mark the dot as an estimate instead of presenting it as data.
const subjectApprox = Number.isFinite(subjectPrice) && subjectDays == null;
const subject = Number.isFinite(subjectPrice)
? scatterProject(
[{ x: subjectDays ?? 90, y: subjectPrice }],
@ -1749,7 +1774,15 @@ function buildScatterDetail(
return { label: mlnTick(price), y: py };
});
return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS };
return {
note,
deals,
analogs,
subject,
subjectApprox,
yTicks,
xTicks: DETAIL_X_TICKS,
};
}
/**

View file

@ -208,6 +208,8 @@ export interface SellTimeTier {
range: string;
count: string;
variant: "success" | "accent" | "gold" | "danger";
/** Backend-flagged bucket too thin to trust (see SellTimeBucket.insufficient_data). */
insufficientData?: boolean;
}
export interface PriceHistory {
@ -225,6 +227,8 @@ export interface ScatterDetail {
deals: ScatterPoint[];
analogs: ScatterPoint[];
subject: ScatterPoint;
/** True when subject.x is a fallback (est_days_on_market unknown), not a real value. */
subjectApprox?: boolean;
yTicks: AxisTickY[];
xTicks: AxisTickX[];
}

View file

@ -400,6 +400,7 @@ export interface SellTimeBucket {
p25_days: number | null;
p75_days: number | null;
n_lots: number;
insufficient_data?: boolean; // backend flags a bucket too thin to trust
}
export interface SellTimeSensitivityResponse {