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.
615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
"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 "обычно X–Y дн" band is only honest with ≥2 lots AND a non-degenerate
|
||
// span. From a single analog (n<2) or when min===max ("237–237") 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) ?? []; // "18–180" → [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>
|
||
);
|
||
}
|