fix(tradein/v2): polish АНАЛИТИКА — растянуть scatter, клэмп оси к 0, честные легенды/бакеты, убрать фейк-тренд #2479

Merged
bot-backend merged 1 commit from fix/v2-analytics-polish into main 2026-07-12 14:37:06 +00:00
5 changed files with 107 additions and 57 deletions

View file

@ -8,7 +8,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures"; 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"; import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps { interface AnalyticsViewProps {
@ -115,9 +115,18 @@ function sellTimeTierN(tier: SellTimeTier): number {
return Number.isFinite(n) ? n : 0; 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 { 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({ export default function AnalyticsView({
@ -377,11 +386,9 @@ export default function AnalyticsView({
> >
<title>История цен в этом доме медиана /м² по годам</title> <title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}> <g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={40} y1={20} x2={900} y2={20} /> {PH_GRID_Y.map((y) => (
<line x1={40} y1={65} x2={900} y2={65} /> <line key={y} x1={40} y1={y} x2={900} y2={y} />
<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} />
</g> </g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}> <g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.yTicks.map((t) => ( {data.priceHistory.yTicks.map((t) => (
@ -517,24 +524,26 @@ export default function AnalyticsView({
/> />
аналоги аналоги
</span> </span>
<span {data.scatterDetail.deals.length > 0 ? (
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span <span
style={{ style={{
width: 8, display: "flex",
height: 8, alignItems: "center",
borderRadius: "50%", gap: 6,
background: tokens.scatterDeal, color: tokens.muted,
}} }}
/> >
сделки <span
</span> style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
) : null}
<span <span
style={{ style={{
display: "flex", display: "flex",
@ -558,6 +567,7 @@ export default function AnalyticsView({
<svg <svg
viewBox="0 0 900 300" viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }} style={{ width: "100%", height: 280, marginTop: 12 }}
preserveAspectRatio="none"
role="img" role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект" aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
> >
@ -588,16 +598,6 @@ export default function AnalyticsView({
stroke={tokens.line3} stroke={tokens.line3}
strokeWidth={1.5} 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}> <g fill={tokens.scatterDeal}>
{data.scatterDetail.deals.map((p, i) => ( {data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} /> <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} /> <circle key={i} cx={p.x} cy={p.y} r={p.r} />
))} ))}
</g> </g>
<circle <g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
cx={data.scatterDetail.subject.x} {/* V4: subjectApprox marks a fallback x (est_days_on_market unknown)
cy={data.scatterDetail.subject.y} dashed ring + title flag it as an estimate, not a measurement. */}
r={data.scatterDetail.subject.r} {data.scatterDetail.subjectApprox ? (
fill={tokens.ink} <title>
/> Срок продажи оценка (~90 дн.), точных данных нет
<circle </title>
cx={data.scatterDetail.subject.x} ) : null}
cy={data.scatterDetail.subject.y} <circle
r={11} cx={data.scatterDetail.subject.x}
fill="none" cy={data.scatterDetail.subject.y}
stroke={tokens.ink} r={data.scatterDetail.subject.r}
strokeWidth={1.2} fill={tokens.ink}
opacity={0.4} />
/> <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 <g
fontFamily={tokens.font.mono} fontFamily={tokens.font.mono}
fontSize={11} fontSize={11}

View file

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

View file

@ -693,10 +693,13 @@ function buildScatterMini(
yMax *= 1.05; yMax *= 1.05;
} }
const yPad = (yMax - yMin) * 0.08; 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 = { const domain: ScatterDomain = {
xMin: SCATTER_X_DOMAIN.min, xMin: SCATTER_X_DOMAIN.min,
xMax: SCATTER_X_DOMAIN.max, xMax: SCATTER_X_DOMAIN.max,
yMin: yMin - yPad, yMin: Math.max(0, yMin - yPad),
yMax: yMax + yPad, yMax: yMax + yPad,
}; };
@ -1443,8 +1446,15 @@ export function mapHistory(
// Three KPI tiles (порядок: экспозиция · торг · доля снятых). // Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] { 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 = const bargainColor =
k?.median_bargain_pct != null && k.median_bargain_pct < 0 k?.median_bargain_pct != null && k.median_bargain_pct > 0
? tokens.success ? tokens.success
: undefined; : undefined;
return [ return [
@ -1464,7 +1474,10 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
}, },
{ {
label: "ДОЛЯ СНЯТЫХ", 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}` : "—", 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, ["аналог", "аналога", "аналогов"])}`, count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
variant: meta.variant, 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_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value) const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175 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 // 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. // the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
@ -1724,15 +1741,23 @@ function buildScatterDetail(
yMax *= 1.05; yMax *= 1.05;
} }
const yPad = (yMax - yMin) * 0.08; 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 = { const domain: ScatterDomain = {
xMin: DETAIL_X_DOMAIN.min, xMin: DETAIL_X_DOMAIN.min,
xMax: DETAIL_X_DOMAIN.max, xMax: DETAIL_X_DOMAIN.max,
yMin: yMin - yPad, yMin: Math.max(0, yMin - yPad),
yMax: yMax + yPad, yMax: yMax + yPad,
}; };
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5); const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.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) const subject = Number.isFinite(subjectPrice)
? scatterProject( ? scatterProject(
[{ x: subjectDays ?? 90, y: subjectPrice }], [{ x: subjectDays ?? 90, y: subjectPrice }],
@ -1749,7 +1774,15 @@ function buildScatterDetail(
return { label: mlnTick(price), y: py }; 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; range: string;
count: string; count: string;
variant: "success" | "accent" | "gold" | "danger"; variant: "success" | "accent" | "gold" | "danger";
/** Backend-flagged bucket too thin to trust (see SellTimeBucket.insufficient_data). */
insufficientData?: boolean;
} }
export interface PriceHistory { export interface PriceHistory {
@ -225,6 +227,8 @@ export interface ScatterDetail {
deals: ScatterPoint[]; deals: ScatterPoint[];
analogs: ScatterPoint[]; analogs: ScatterPoint[];
subject: ScatterPoint; subject: ScatterPoint;
/** True when subject.x is a fallback (est_days_on_market unknown), not a real value. */
subjectApprox?: boolean;
yTicks: AxisTickY[]; yTicks: AxisTickY[];
xTicks: AxisTickX[]; xTicks: AxisTickX[];
} }

View file

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