gendesign/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
bot-backend f5a7f9b437
All checks were successful
CI / 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
fix(tradein/v2): UI/UX audit round 2 — AA contrast, honesty, a11y (#2081)
C1 (tokens): darken muted scale to WCAG AA (>=4.5:1 vs worst-case canvas
  #e6eef7); reclassify primary labels muted->body2 across components.
H1 (mappers): planning chip derived from rooms actually present in the
  analog set («Все планировки» when heterogeneous) — no number shifted.
H2 (ResultPanel): make «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» the headline (accent
  border/tint/glow + larger value); demote ДКП·Росреестр to a muted
  СПРАВОЧНО reference tile. All three values + mapResultPanel wiring intact.
H3 (mappers): parseAddress keeps the house number for prefix-less
  addresses («Белинского, 86»); prefixed path unchanged.
M4 (page/HeroBar/Footer): gate dead controls (КАК РАССЧИТАНО / PDF /
  ДЕЙСТВИТЕЛЕН ДО / footer validity) on hasEstimate (mounted && estimate && !insufficient).
M5 (LocationDrawer): real dialog — role=dialog/aria-modal/aria-labelledby
  + focus-trap + <button> close + Esc preventDefault/stopPropagation.
M6 (ParamsPanel): address autocomplete -> combobox ARIA + Arrow/Home/End/
  Enter/Escape keyboard nav (mirrors Dd listbox).
M7 (Sources/Cache/Analytics/History): ARIA table semantics over the grids;
  Analytics scroll region keyboard-focusable (clears the serious
  scrollable-region-focusable axe violation); chart SVGs get role=img+title+aria-label.
M8 (CacheView): normalize the ВРЕМЯ column (normTime) to consistent minute precision.
M9 (LocationDrawer): honest «как считаем» source list (Циан, Я.Недвижимость,
  Авито, N1.ru + Росреестр) — drop dead Домклик/Restate per #1968.
M10 (ParamsPanel/mapMarkers): subject pin shows the subject's own area; drop
  analog pins overlapping the subject center (was misread as subject data).
M11 (ObjectSummary/SourcesView): label «медиана объявлений»; hide all-«—» columns/rows.
M13 (page): clamp artboard scale >=0.88 so micro-type doesn't drop below ~8px.
L1 (Analytics): suppress fake «X–Y дн» range when n<2.
L2 (Analytics): drop zero-data series from price-history subtitle.
L7 (TopNav): replace hardcoded #6f8195 icon strokes with tokens.muted.

tsc + next build green. No FE test infra in tradein-mvp/frontend — parseAddress
verified via standalone harness (6/6). Backend data-contract items M1/M2/M3/H4
(source counter, ratio vs medians, bargain bases, cross-source dedup) filed
separately — not FE bugs. L6 (blueprint-grid axe-blindness) deferred: base
gradient + glass surfaces keep axe contrast «incomplete» regardless, so the
authoritative check stays the manual computed-color sweep.
2026-06-29 01:01:30 +03:00

615 lines
19 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 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 };
});
}
// X positions of the year axis labels (chart scaffolding, not in fixtures).
const histYearX = [50, 250, 460, 670, 860];
// ---- 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;
}
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
// 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>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4,1fr)",
gap: 12,
}}
>
{data.sellTime.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>
{/* история цен */}
<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}>
{data.priceHistory.years.map((yr, i) => (
<text key={yr} x={histYearX[i]} 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>
);
}