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
Живой визуальный аудит /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
675 lines
22 KiB
TypeScript
675 lines
22 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 { 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 "обычно 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;
|
||
}
|
||
|
||
// 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 3–6 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>
|
||
);
|
||
}
|