fix(tradein/v2): polish АНАЛИТИКА — растянуть scatter, клэмп оси к 0, честные легенды/бакеты, убрать фейк-тренд #2479
5 changed files with 107 additions and 57 deletions
|
|
@ -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,6 +524,7 @@ export default function AnalyticsView({
|
|||
/>
|
||||
аналоги
|
||||
</span>
|
||||
{data.scatterDetail.deals.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -535,6 +543,7 @@ export default function AnalyticsView({
|
|||
/>
|
||||
сделки
|
||||
</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,6 +608,14 @@ export default function AnalyticsView({
|
|||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||
))}
|
||||
</g>
|
||||
<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}
|
||||
|
|
@ -621,8 +629,12 @@ export default function AnalyticsView({
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue