gendesign/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
bot-backend d73b9198d5
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
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 / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после
honest-hero раунда — фиксы:

- H1: честный fallback-state флагманской карточки при expected_sold_price=null
  (вместо четырёх прочерков)
- H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн
  vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub
- M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля
  "-N% к цене объявления", дисклеймер разброса читаем)
- M4: PDF-отчёт primary после результата (без добавления lead-CTA —
  открытый продуктовый вопрос, не решался)
- M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) —
  geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах
- M6: gate немонотонного "срок продажи" при n<5 аналогов
- M7: сброс scroll оверлея при смене секции
- M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен",
  P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный
  "Обновлено: —", метро-разделители в адресе, убран внутренний
  "ПОВТОРНЫЕ АДРЕСА" label)
- Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1
  (accent → accentDeep, существующий токен той же палитры)

Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR.
Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался.

Refs #2266, #2262
2026-07-03 23:31:50 +03:00

675 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
// Accepts one composite `data` prop (default = the design fixture); page.tsx
// feeds mapAnalytics(...) output. The price-history dots navigate to the
// "Аналогичные объявления" overlay via onNavigate?.(2).
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
data?: Analytics;
onNavigate?: (i: number) => void;
}
// ---- price-history geometry -----------------------------------------------
// Parse the SVG polyline point strings into coordinates so the markers and
// the transparent hover targets can be derived from the same source. Empty
// series (graceful-degradation case) parse to [] — no dots, no hover targets.
interface Pt {
x: number;
y: number;
}
function parsePoints(s: string): Pt[] {
return s
.trim()
.split(/\s+/)
.filter((pair) => pair.length > 0)
.map((pair) => {
const [x, y] = pair.split(",").map(Number);
return { x, y };
});
}
// M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number
// of years, using the SAME geometry the mapper uses for the polyline vertices
// (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from
// ./mappers (single source of truth) rather than copy-pasted, so the two never
// drift apart. A fixed 5-slot array broke whenever the real series had ≠5
// years: the extra labels fell back to an undefined x → x=0, so a late year
// (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits
// centred under its own vertex.
// ---- sell-time tier tints --------------------------------------------------
// One-off decorative tints per variant (design lines 553-556). The soft border
// hex (#bfe3d2 / #e3d2ad / #e6c3c3) and the rgba background fills are documented
// one-off design tints with no token equivalent; the label text colour reuses
// semantic tokens (success / accent / warn / danger).
const tierStyles: Record<
SellTimeTier["variant"],
{ bg: string; border: string; label: string }
> = {
success: {
bg: "rgba(27,170,107,.07)",
border: "1px solid #bfe3d2",
label: tokens.success,
},
accent: {
bg: "rgba(46,139,255,.08)",
border: `1.5px solid ${tokens.accent}`,
label: tokens.accent,
},
gold: {
bg: "rgba(201,154,62,.09)",
border: "1px solid #e3d2ad",
label: tokens.warn,
},
danger: {
bg: "rgba(214,90,90,.07)",
border: "1px solid #e6c3c3",
label: tokens.danger,
},
};
const cardStyle = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
} as const;
// L1 — a "обычно XY дн" band is only honest with ≥2 lots AND a non-degenerate
// span. From a single analog (n<2) or when min===max ("237237") the band is a
// fake range, so we suppress it and keep just the point estimate (tier.days) +
// the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the
// parsed range endpoints, so either signal alone drops the band.
function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
if (!tier.range || tier.range === "—") return false;
const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1
if (Number.isFinite(n) && n < 2) return false;
const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18180" → [18,180]
if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false;
return true;
}
// M6 — «Срок продажи в зависимости от цены» is not a real trend when built on
// thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн
// median) off just 36 analogs, i.e. noise read as a signal. We therefore GATE
// the chart honestly instead of faking monotonicity:
// • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust);
// • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced
// by a «мало данных» note (nothing meaningful left to compare).
const SELLTIME_MIN_N = 5;
const SELLTIME_MIN_TILES = 3;
/** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */
function sellTimeTierN(tier: SellTimeTier): number {
const n = Number.parseInt(tier.count, 10);
return Number.isFinite(n) ? n : 0;
}
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
function sellTimeTierValid(tier: SellTimeTier): boolean {
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
}
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
// M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs).
const sellTimeTiles = useMemo(
() => data.sellTime.filter(sellTimeTierValid),
[data.sellTime],
);
// Price-history series + hover targets, derived from the data prop. Яндекс
// only draws dots where its series diverges from Avito (последние 3 года).
const avitoPts = useMemo(
() => parsePoints(data.priceHistory.avito),
[data.priceHistory.avito],
);
const yandexPts = useMemo(
() => parsePoints(data.priceHistory.yandex),
[data.priceHistory.yandex],
);
const yandexDots = useMemo(
() =>
yandexPts.filter(
(p, i) =>
!avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y,
),
[yandexPts, avitoPts],
);
const hoverPts = useMemo<Pt[]>(
() => [...avitoPts, ...yandexDots],
[avitoPts, yandexDots],
);
// L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any
// series that drew no points so the subtitle matches the rendered legend (the
// swatches below are already gated on *Pts.length). If the note format changes
// the replace is a graceful no-op.
const activeSeriesNames = [
avitoPts.length > 0 ? "Avito" : null,
yandexPts.length > 0 ? "Яндекс" : null,
].filter((s): s is string => s != null);
const historyNote =
activeSeriesNames.length > 0
? data.priceHistory.note.replace(
/Avito \+ Яндекс/,
activeSeriesNames.join(" + "),
)
: data.priceHistory.note;
return (
<div
role="region"
tabIndex={0}
aria-label="Аналитика дома — прокручиваемая область"
style={{ display: "flex", flexDirection: "column", gap: 22 }}
>
{/* header */}
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: tokens.ink2 }}>
Аналитика дома
</div>
<div style={{ fontSize: 10, color: tokens.muted2 }}>
{data.header.ads} · {data.header.radius}
</div>
</div>
{/* KPI row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
gap: 16,
}}
>
{data.kpis.map((kpi, i) => (
<div key={i} style={cardStyle}>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
// C1: primary KPI label → body2 (matches ParamsPanel field-label
// pattern); the muted value/sub below keep the secondary tier.
color: tokens.body2,
}}
>
{kpi.label}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 30,
fontWeight: 300,
color: kpi.color ?? tokens.ink2,
margin: "8px 0 5px",
}}
>
{kpi.value}
{kpi.unit ? (
<>
{" "}
<span style={{ fontSize: 14, color: tokens.muted }}>
{kpi.unit}
</span>
</>
) : null}
</div>
<div style={{ fontSize: 10, color: tokens.hint }}>{kpi.sub}</div>
</div>
))}
</div>
{/* срок продажи в зависимости от цены */}
<div style={cardStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Срок продажи в зависимости от цены
</div>
<div
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
>
{data.sellTimeNote}
</div>
{sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${sellTimeTiles.length},1fr)`,
gap: 12,
}}
>
{sellTimeTiles.map((tier, i) => {
const v = tierStyles[tier.variant];
return (
<div
key={i}
style={{
background: v.bg,
border: v.border,
borderRadius: 7,
padding: "13px 15px",
}}
>
<div
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
>
{tier.tier}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 26,
fontWeight: 300,
color: tokens.ink2,
margin: "6px 0 4px",
}}
>
{tier.days}
</div>
<div
style={{
fontSize: 9.5,
color: tokens.hint,
lineHeight: 1.5,
}}
>
{sellTimeRangeMeaningful(tier) ? (
<>
{tier.range}
<br />
</>
) : null}
{tier.count}
</div>
</div>
);
})}
</div>
) : (
<div
style={{
fontSize: 11,
color: tokens.muted2,
lineHeight: 1.5,
padding: "6px 0 2px",
}}
>
Мало данных для оценки чувствительности срока продажи к цене.
</div>
)}
</div>
{/* история цен */}
<div style={{ position: "relative", ...cardStyle }}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
{avitoPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
) : null}
{yandexPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
</span>
) : null}
</div>
</div>
<svg
viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
>
<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} />
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.yTicks.map((t) => (
<text key={t.label} x={0} y={t.y + 4}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.priceHistory.years.map((yr, i) => (
<text
key={yr}
x={histYearX(i, data.priceHistory.years.length)}
y={214}
>
{yr}
</text>
))}
</g>
<polyline
points={data.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={data.priceHistory.yandex}
fill="none"
stroke={tokens.gold}
strokeWidth={2.5}
/>
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
<g
fill="#fff"
stroke={tokens.accent}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
{avitoPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill={tokens.gold}
stroke={tokens.gold}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
{yandexDots.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill="transparent"
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoverHist(true)}
onMouseLeave={() => setHoverHist(false)}
onClick={() => onNavigate?.(2)}
>
{hoverPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={13} />
))}
</g>
</svg>
{hoverHist ? (
// Dark navy tooltip surface + white text — documented one-off literals
// matching the design (l.572); no token equivalent.
<div
style={{
position: "absolute",
left: 64,
top: 54,
background: "rgba(23,38,58,.96)",
color: "#fff",
borderRadius: 7,
padding: "9px 13px",
fontSize: 11,
lineHeight: 1.5,
zIndex: 6,
pointerEvents: "none",
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
}}
>
<div style={{ fontWeight: 600 }}>
Архивные объявления этого дома
</div>
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
Нажмите, чтобы открыть список
</div>
</div>
) : null}
</div>
{/* цена × срок продажи (детально) */}
<div style={cardStyle}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Цена × срок продажи
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
аналоги
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.ink,
}}
/>
объект
</span>
</div>
</div>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
>
<title>Цена × срок продажи</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} />
<line x1={60} y1={134} x2={880} y2={134} />
<line x1={60} y1={191} x2={880} y2={191} />
<line x1={60} y1={250} x2={880} y2={250} />
<line x1={265} y1={20} x2={265} y2={250} />
<line x1={470} y1={20} x2={470} y2={250} />
<line x1={675} y1={20} x2={675} y2={250} />
</g>
<line
x1={60}
y1={20}
x2={60}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={60}
y1={250}
x2={880}
y2={250}
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} />
))}
</g>
<g fill={tokens.accent}>
{data.scatterDetail.analogs.map((p, i) => (
<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
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="end"
>
{data.scatterDetail.yTicks.map((t) => (
<text key={t.label} x={52} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.scatterDetail.xTicks.map((t) => (
<text key={t.label} x={t.x} y={268}>
{t.label}
</text>
))}
</g>
<text
x={470}
y={288}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
</text>
<text
x={18}
y={135}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
transform="rotate(-90 18 135)"
>
ЦЕНА ПРОДАЖИ,
</text>
</svg>
</div>
</div>
);
}