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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue