Compare commits
No commits in common. "c3d0f86f3744cb0b5efb500ff543a74998283340" and "519b482cdd3a1a89c13455b21215a5bc322fbd2d" have entirely different histories.
c3d0f86f37
...
519b482cdd
3 changed files with 9 additions and 97 deletions
|
|
@ -448,17 +448,10 @@ export default function TradeInV2Page() {
|
||||||
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
|
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
|
||||||
// inside the scaled artboard — smaller on phones, but no longer overflowing.
|
// inside the scaled artboard — smaller on phones, but no longer overflowing.
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
// Below this width the fixed 1536px artboard scales down to an unreadable
|
|
||||||
// ~25% and there is no responsive layout yet. Rather than ship that broken
|
|
||||||
// scaled-down HUD, render an honest "open on desktop" notice instead (see the
|
|
||||||
// early return below). Starts false so SSR + first client render match the
|
|
||||||
// desktop tree; the effect flips it post-hydration → no hydration mismatch.
|
|
||||||
const [isSmallViewport, setIsSmallViewport] = useState(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
setArtboardScale(Math.min(1, window.innerWidth / 1536));
|
setArtboardScale(Math.min(1, window.innerWidth / 1536));
|
||||||
setIsMobile(window.innerWidth < 768);
|
setIsMobile(window.innerWidth < 768);
|
||||||
setIsSmallViewport(window.innerWidth < 1024);
|
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
window.addEventListener("resize", compute);
|
window.addEventListener("resize", compute);
|
||||||
|
|
@ -773,48 +766,6 @@ export default function TradeInV2Page() {
|
||||||
statusMessage = "Оценка готова";
|
statusMessage = "Оценка готова";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
|
|
||||||
// layout — under ~1024px it scales to an unreadable ~25%. Show an honest
|
|
||||||
// desktop-only notice instead of the broken scaled-down copy. Placed after all
|
|
||||||
// hooks so the early return never changes hook order. Desktop (≥1024) is
|
|
||||||
// untouched.
|
|
||||||
if (isSmallViewport) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minHeight: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "40px 24px",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
gap: 14,
|
|
||||||
fontFamily: tokens.font.sans,
|
|
||||||
color: tokens.ink,
|
|
||||||
background: tokens.gradientBg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
|
|
||||||
Мера оптимизирована для десктопа
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
margin: 0,
|
|
||||||
maxWidth: 360,
|
|
||||||
color: tokens.body,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Откройте оценку на компьютере или расширьте окно браузера до 1024 px
|
|
||||||
и шире — на узком экране интерфейс отчёта нечитаем.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -460,35 +460,12 @@ export default function AnalyticsView({
|
||||||
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
{/* WCAG 2.1.1 (#4): the chart's "open archived listings" affordance
|
|
||||||
was a mouse-only <g onClick>. Expose it as a real focusable
|
|
||||||
button (role + tabIndex + Enter/Space) so keyboard/SR users can
|
|
||||||
open the «Аналогичные объявления» overlay too — the transparent
|
|
||||||
hover circles stay as the pointer target. Only interactive when
|
|
||||||
there are points to click (no empty tab stop on a data-less
|
|
||||||
chart). onFocus/onBlur mirror hover so the hint tooltip shows on
|
|
||||||
keyboard focus as well. */}
|
|
||||||
<g
|
<g
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
role={hoverPts.length > 0 ? "button" : undefined}
|
|
||||||
tabIndex={hoverPts.length > 0 ? 0 : undefined}
|
|
||||||
aria-label={
|
|
||||||
hoverPts.length > 0
|
|
||||||
? "Открыть архивные объявления этого дома"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onMouseEnter={() => setHoverHist(true)}
|
onMouseEnter={() => setHoverHist(true)}
|
||||||
onMouseLeave={() => setHoverHist(false)}
|
onMouseLeave={() => setHoverHist(false)}
|
||||||
onFocus={() => setHoverHist(true)}
|
|
||||||
onBlur={() => setHoverHist(false)}
|
|
||||||
onClick={() => onNavigate?.(2)}
|
onClick={() => onNavigate?.(2)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onNavigate?.(2);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{hoverPts.map((p, i) => (
|
{hoverPts.map((p, i) => (
|
||||||
<circle key={i} cx={p.x} cy={p.y} r={13} />
|
<circle key={i} cx={p.x} cy={p.y} r={13} />
|
||||||
|
|
@ -535,15 +512,10 @@ export default function AnalyticsView({
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||||
Цена × срок на рынке
|
Цена × срок продажи
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
{/* Honesty (#2): active analogs carry «дней на рынке так далеко»
|
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
|
||||||
(today − listing_date, нижняя граница), not realized
|
|
||||||
time-to-sell — the axis reads «срок на рынке», not «срок
|
|
||||||
продажи», so an ещё-продающийся лот isn't shown as a
|
|
||||||
measured sale time. */}
|
|
||||||
Цена в зависимости от срока на рынке · {data.scatterDetail.note}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||||
|
|
@ -610,9 +582,9 @@ export default function AnalyticsView({
|
||||||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Точечный график зависимости цены от срока лота на рынке: аналоги, сделки и ваш объект"
|
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
|
||||||
>
|
>
|
||||||
<title>Цена × срок на рынке</title>
|
<title>Цена × срок продажи</title>
|
||||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||||
<line x1={60} y1={20} x2={880} y2={20} />
|
<line x1={60} y1={20} x2={880} y2={20} />
|
||||||
<line x1={60} y1={77} x2={880} y2={77} />
|
<line x1={60} y1={77} x2={880} y2={77} />
|
||||||
|
|
@ -708,7 +680,7 @@ export default function AnalyticsView({
|
||||||
fill={tokens.muted2}
|
fill={tokens.muted2}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
СРОК НА РЫНКЕ, ДН
|
СРОК ПРОДАЖИ, ДН
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={18}
|
x={18}
|
||||||
|
|
|
||||||
|
|
@ -616,10 +616,7 @@ function resolveDealTier(
|
||||||
hiRub: sd.range_high_rub,
|
hiRub: sd.range_high_rub,
|
||||||
medianPpm: sd.median_price_per_m2,
|
medianPpm: sd.median_price_per_m2,
|
||||||
count: sd.count,
|
count: sd.count,
|
||||||
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
|
bars: bins8(sd.deals.map((d) => d.price_per_m2)),
|
||||||
// same way the ads card does — otherwise a single mis-scraped lot bins
|
|
||||||
// over [min,max] and crushes the real deals into the left buckets.
|
|
||||||
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const area = e.area_m2;
|
const area = e.area_m2;
|
||||||
|
|
@ -631,10 +628,7 @@ function resolveDealTier(
|
||||||
hiRub: c.high_ppm2 * area,
|
hiRub: c.high_ppm2 * area,
|
||||||
medianPpm: c.median_ppm2,
|
medianPpm: c.median_ppm2,
|
||||||
count: c.count,
|
count: c.count,
|
||||||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
bars: bins8(e.actual_deals.map((d) => d.price_per_m2)),
|
||||||
bars: bins8(
|
|
||||||
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (e.actual_deals.length > 0) {
|
if (e.actual_deals.length > 0) {
|
||||||
|
|
@ -649,8 +643,7 @@ function resolveDealTier(
|
||||||
hiRub: Math.max(...prices),
|
hiRub: Math.max(...prices),
|
||||||
medianPpm: medPpm,
|
medianPpm: medPpm,
|
||||||
count: e.actual_deals.length,
|
count: e.actual_deals.length,
|
||||||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
bars: bins8(ppms),
|
||||||
bars: bins8(guardPriceOutliers(ppms).clean),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1023,11 +1016,7 @@ export function mapResultPanel(
|
||||||
unit: "млн ₽",
|
unit: "млн ₽",
|
||||||
range: rangeLine(e.range_low_rub, e.range_high_rub),
|
range: rangeLine(e.range_low_rub, e.range_high_rub),
|
||||||
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
|
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
|
||||||
// Fix #1/#8: bin over the SAME outlier-guarded pool the spread/CV uses
|
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
|
||||||
// (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
|
|
||||||
// 872k выброс this card already reports as "исключён", squashing the real
|
|
||||||
// analogs into the left bins while the number says the outlier is dropped.
|
|
||||||
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue