fix(tradein/v2): аналитика — 2 графика в ряд, чтобы Цена×срок влезала на экран #2480

Merged
bot-backend merged 1 commit from fix/v2-analytics-2col-layout into main 2026-07-12 14:56:08 +00:00

View file

@ -327,204 +327,196 @@ export default function AnalyticsView({
)} )}
</div> </div>
{/* история цен */} {/* история цен + цена × срок продажи side by side so both fit the
<div style={{ position: "relative", ...cardStyle }}> fixed-height v2 artboard panel without scrolling (reclaims ~194px
<div of vertical space vs. stacking; chart heights unchanged). */}
style={{ <div
display: "flex", style={{
alignItems: "flex-end", display: "grid",
justifyContent: "space-between", gridTemplateColumns: "1fr 1fr",
}} gap: 16,
> alignItems: "start",
<div> }}
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}> >
История цен в этом доме {/* история цен */}
</div> <div style={{ position: "relative", ...cardStyle }}>
<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}>
{PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} />
))}
</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 <div
style={{ style={{
position: "absolute", display: "flex",
left: 64, alignItems: "flex-end",
top: 54, justifyContent: "space-between",
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={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
</div>
</div> </div>
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}> <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>
</div> </div>
) : null} <svg
</div> viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }}
{/* цена × срок продажи (детально) */} preserveAspectRatio="none"
<div style={cardStyle}> role="img"
<div aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
style={{ >
display: "flex", <title>История цен в этом доме медиана /м² по годам</title>
alignItems: "flex-end", <g stroke={tokens.lineSoft2} strokeWidth={1}>
justifyContent: "space-between", {PH_GRID_Y.map((y) => (
}} <line key={y} x1={40} y1={y} x2={900} y2={y} />
> ))}
<div> </g>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}> <g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
Цена × срок продажи {data.priceHistory.yTicks.map((t) => (
</div> <text key={t.label} x={0} y={t.y + 4}>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}> {t.label}
Зависимость цены от срока экспозиции · {data.scatterDetail.note} </text>
</div> ))}
</div> </g>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}> <g
<span 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={{ style={{
display: "flex", position: "absolute",
alignItems: "center", left: 64,
gap: 6, top: 54,
color: tokens.muted, 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)",
}} }}
> >
<span <div style={{ fontWeight: 600 }}>
style={{ Архивные объявления этого дома
width: 8, </div>
height: 8, <div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
borderRadius: "50%", Нажмите, чтобы открыть список
background: tokens.accent, </div>
}} </div>
/> ) : null}
аналоги </div>
</span>
{data.scatterDetail.deals.length > 0 ? ( {/* цена × срок продажи (детально) */}
<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 <span
style={{ style={{
display: "flex", display: "flex",
@ -538,149 +530,169 @@ export default function AnalyticsView({
width: 8, width: 8,
height: 8, height: 8,
borderRadius: "50%", borderRadius: "50%",
background: tokens.scatterDeal, background: tokens.accent,
}} }}
/> />
сделки аналоги
</span> </span>
) : null} {data.scatterDetail.deals.length > 0 ? (
<span <span
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 6,
color: tokens.muted, color: tokens.muted,
}} }}
> >
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
) : null}
<span <span
style={{ style={{
width: 8, display: "flex",
height: 8, alignItems: "center",
borderRadius: "50%", gap: 6,
background: tokens.ink, color: tokens.muted,
}} }}
/> >
объект <span
</span> style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.ink,
}}
/>
объект
</span>
</div>
</div> </div>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
preserveAspectRatio="none"
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}
/>
<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>
<g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
{/* V4: subjectApprox marks a fallback x (est_days_on_market unknown)
dashed ring + title flag it as an estimate, not a measurement. */}
{data.scatterDetail.subjectApprox ? (
<title>
Срок продажи оценка (~90 дн.), точных данных нет
</title>
) : null}
<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}
strokeDasharray={
data.scatterDetail.subjectApprox ? "3 3" : undefined
}
opacity={0.4}
/>
</g>
<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>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
preserveAspectRatio="none"
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}
/>
<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>
<g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
{/* V4: subjectApprox marks a fallback x (est_days_on_market unknown)
dashed ring + title flag it as an estimate, not a measurement. */}
{data.scatterDetail.subjectApprox ? (
<title>
Срок продажи оценка (~90 дн.), точных данных нет
</title>
) : null}
<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}
strokeDasharray={
data.scatterDetail.subjectApprox ? "3 3" : undefined
}
opacity={0.4}
/>
</g>
<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>
</div> </div>
); );