- Опц. поля больше не шлют непроверенные дефолты в ценовую модель: ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не трогали) → undefined. - Валидация у поля: красная рамка + сообщение под адресом/площадью, показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка. - Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ · ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI). - Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01); бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК); «эталон»→«опорная цена». - График истории цен: зум оси Y к реальному диапазону данных + динамические подписи оси; легенда серии показывается только при наличии линии. - 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик (не выглядит как draggable-ручка); фото здания — синий duotone + десатурация (не крадёт первый взгляд у цен). - Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ. Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0, а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность тиров срока продажи не форсируется (это были бы выдуманные данные).
568 lines
17 KiB
TypeScript
568 lines
17 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;
|
||
|
||
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],
|
||
);
|
||
|
||
return (
|
||
<div 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,
|
||
color: tokens.muted2,
|
||
}}
|
||
>
|
||
{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 }}
|
||
>
|
||
{tier.range}
|
||
<br />
|
||
{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 }}>
|
||
{data.priceHistory.note}
|
||
</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"
|
||
>
|
||
<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 }}
|
||
>
|
||
<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>
|
||
);
|
||
}
|