Merge pull request 'fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)' (#2072) from fix/tradein-v2-uiux-p2-content into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled

This commit is contained in:
lekss361 2026-06-28 17:52:28 +00:00
commit eab3d928d6
10 changed files with 221 additions and 116 deletions

View file

@ -244,30 +244,36 @@ export default function AnalyticsView({
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}> <div style={{ display: "flex", gap: 16, fontSize: 10 }}>
<span {avitoPts.length > 0 ? (
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span <span
style={{ width: 14, height: 2, background: tokens.accent }} style={{
/> display: "flex",
Avito alignItems: "center",
</span> gap: 6,
<span color: tokens.muted,
style={{ }}
display: "flex", >
alignItems: "center", <span
gap: 6, style={{ width: 14, height: 2, background: tokens.accent }}
color: tokens.muted, />
}} Avito
> </span>
<span style={{ width: 14, height: 2, background: tokens.gold }} /> ) : null}
Яндекс {yandexPts.length > 0 ? (
</span> <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>
<svg <svg
@ -283,21 +289,11 @@ export default function AnalyticsView({
<line x1={40} y1={195} x2={900} y2={195} /> <line x1={40} y1={195} x2={900} y2={195} />
</g> </g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}> <g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
<text x={0} y={24}> {data.priceHistory.yTicks.map((t) => (
200k <text key={t.label} x={0} y={t.y + 4}>
</text> {t.label}
<text x={0} y={69}> </text>
150k ))}
</text>
<text x={0} y={114}>
100k
</text>
<text x={0} y={159}>
50k
</text>
<text x={8} y={199}>
0k
</text>
</g> </g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}> <g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.years.map((yr, i) => ( {data.priceHistory.years.map((yr, i) => (
@ -395,8 +391,7 @@ export default function AnalyticsView({
Цена × срок продажи Цена × срок продажи
</div> </div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}> <div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции ·{" "} Зависимость цены от срока экспозиции · {data.scatterDetail.note}
{data.scatterDetail.note}
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}> <div style={{ display: "flex", gap: 14, fontSize: 10 }}>

View file

@ -261,7 +261,20 @@ export default function HeroBar({
height: "100%", height: "100%",
objectFit: "contain", objectFit: "contain",
objectPosition: "right center", objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)", filter: "saturate(.25) brightness(1.06) contrast(.95)",
}}
/>
)}
{/* blue duotone tint toward HUD accent recedes the photo (only over
the real image; skip when it 404s so the placeholder stays clean) */}
{!imgFailed && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}} }}
/> />
)} )}

View file

@ -200,9 +200,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "} <b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}> <b style={{ color: tokens.ink2 }}>
рекомендованная цена в объявлении рекомендованная цена в объявлении
</b>{" "} </b>
(asking), <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b>{" "} , <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (с
(asking торг по ДКП),{" "} учётом торга по ДКП),{" "}
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические <b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
сделки). CV коэффициент вариации выборки (разброс цен). сделки). CV коэффициент вариации выборки (разброс цен).
</div> </div>
@ -230,7 +230,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
padding: "12px 14px", padding: "12px 14px",
}} }}
> >
Коэффициент локации (POI, транспорт, шум) {" "} Коэффициент локации (инфраструктура, транспорт, шум) {" "}
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по <b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
локации не корректируется. локации не корректируется.
</div> </div>

View file

@ -209,7 +209,7 @@ export function ObjectSummary({
> >
{row.value} {row.value}
</span> </span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span> <span style={{ color: tokens.accent, fontSize: 11 }}></span>
</span> </span>
</div> </div>
))} ))}

View file

@ -325,11 +325,11 @@ function initRoomsLabel(rooms: number | null | undefined): string {
} }
function initHouseTypeLabel(ht: HouseType | undefined): string { function initHouseTypeLabel(ht: HouseType | undefined): string {
return ht ? HOUSE_TYPE_RU[ht] : "Панельный"; return ht ? HOUSE_TYPE_RU[ht] : "Не указано";
} }
function initRepairLabel(rs: RepairState | undefined): string { function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Хороший"; return rs ? REPAIR_RU[rs] : "Не указано";
} }
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its // РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
@ -383,8 +383,13 @@ export default function ParamsPanel({
const [radius, setRadius] = useState( const [radius, setRadius] = useState(
initRadiusLabel(initialValues?.radius_m), initRadiusLabel(initialValues?.radius_m),
); );
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true); const [balcony, setBalcony] = useState<boolean | null>(
const [validationError, setValidationError] = useState<string | null>(null); initialValues?.has_balcony ?? null,
);
const [fieldErrors, setFieldErrors] = useState<{
address?: string;
area?: string;
}>({});
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ── // ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
// The typed string is debounced into `addressQuery` from inside the change // The typed string is debounced into `addressQuery` from inside the change
@ -407,6 +412,8 @@ export default function ParamsPanel({
setAddress(v); setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point setCoords(null); // a manual edit invalidates a previously picked point
setSuggestOpen(v.trim().length >= 3); setSuggestOpen(v.trim().length >= 3);
if (fieldErrors.address)
setFieldErrors((prev) => ({ ...prev, address: undefined }));
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200); debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
}; };
@ -432,19 +439,22 @@ export default function ParamsPanel({
const handleSubmit = () => { const handleSubmit = () => {
const trimmedAddress = address.trim(); const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", ".")); const areaNum = Number(area.replace(",", "."));
// Collect ALL blockers (don't early-return) so every offending field shows
// its own message at once.
const errs: { address?: string; area?: string } = {};
if (trimmedAddress.length < 3) { if (trimmedAddress.length < 3) {
setValidationError("Укажите адрес квартиры — минимум 3 символа"); errs.address = "Укажите адрес квартиры — минимум 3 символа";
return;
} }
if (!area.trim()) { if (!area.trim()) {
setValidationError("Укажите площадь квартиры"); errs.area = "Укажите площадь квартиры";
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
errs.area = "Площадь должна быть больше 10 м²";
}
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return; return;
} }
if (!Number.isFinite(areaNum) || areaNum <= 10) { setFieldErrors({});
setValidationError("Площадь должна быть больше 10 м²");
return;
}
setValidationError(null);
setSuggestOpen(false); setSuggestOpen(false);
onSubmit?.({ onSubmit?.({
address: trimmedAddress, address: trimmedAddress,
@ -455,7 +465,7 @@ export default function ParamsPanel({
year_built: year.trim() ? Number(year) : undefined, year_built: year.trim() ? Number(year) : undefined,
house_type: HOUSE_TYPE_FROM_RU[houseType], house_type: HOUSE_TYPE_FROM_RU[houseType],
repair_state: REPAIR_FROM_RU[repair], repair_state: REPAIR_FROM_RU[repair],
has_balcony: balcony, has_balcony: balcony ?? undefined,
lat: coords?.lat ?? null, lat: coords?.lat ?? null,
lon: coords?.lon ?? null, lon: coords?.lon ?? null,
// "Авто" → null → backend keeps its two-tier default (1000 m primary / // "Авто" → null → backend keeps its two-tier default (1000 m primary /
@ -874,7 +884,11 @@ export default function ParamsPanel({
}} }}
placeholder="Город, улица, дом" placeholder="Город, улица, дом"
autoComplete="off" autoComplete="off"
style={addressField} style={
fieldErrors.address
? { ...addressField, border: `1px solid ${tokens.danger}` }
: addressField
}
/> />
<svg <svg
style={{ style={{
@ -922,6 +936,11 @@ export default function ParamsPanel({
)} )}
</div> </div>
)} )}
{fieldErrors.address && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
{fieldErrors.address}
</div>
)}
</div> </div>
</div> </div>
@ -933,13 +952,26 @@ export default function ParamsPanel({
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={area} value={area}
onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))} onChange={(e) => {
setArea(e.target.value.replace(/[^\d.,]/g, ""));
if (fieldErrors.area)
setFieldErrors((prev) => ({ ...prev, area: undefined }));
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit(); if (e.key === "Enter") handleSubmit();
}} }}
placeholder="напр. 54" placeholder="напр. 54"
style={inputField} style={
fieldErrors.area
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/> />
{fieldErrors.area && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
{fieldErrors.area}
</div>
)}
</div> </div>
<div> <div>
<div style={hintLabel}>КОМНАТ</div> <div style={hintLabel}>КОМНАТ</div>
@ -960,7 +992,7 @@ export default function ParamsPanel({
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span> <span style={hintLabel}>ЭТАЖ</span>
<span style={optHint}>если знаешь</span> <span style={optHint}>если знаете</span>
</div> </div>
<input <input
className="pp-input" className="pp-input"
@ -978,7 +1010,7 @@ export default function ParamsPanel({
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span> <span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<span style={optHint}>если знаешь</span> <span style={optHint}>если знаете</span>
</div> </div>
<input <input
className="pp-input" className="pp-input"
@ -1005,7 +1037,7 @@ export default function ParamsPanel({
marginTop: -3, marginTop: -3,
}} }}
> >
1-й и последний этаж снижают цену на 510% заполни если знаешь 1-й и последний этаж снижают цену на 510% заполните, если знаете
</div> </div>
<div style={fieldRow}> <div style={fieldRow}>
@ -1067,13 +1099,13 @@ export default function ParamsPanel({
<div style={{ height: 40, display: "flex", gap: 6 }}> <div style={{ height: 40, display: "flex", gap: 6 }}>
<div <div
onClick={() => setBalcony(false)} onClick={() => setBalcony(false)}
style={{ ...balBase, ...(balcony ? balOff : balOn) }} style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
> >
НЕТ НЕТ
</div> </div>
<div <div
onClick={() => setBalcony(true)} onClick={() => setBalcony(true)}
style={{ ...balBase, ...(balcony ? balOn : balOff) }} style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
> >
ДА ДА
</div> </div>
@ -1152,10 +1184,11 @@ export default function ParamsPanel({
</span> </span>
</button> </button>
{/* inline error (validation or server) — only when present */} {/* inline SERVER error only when present (field-level validation is
{(validationError || error) && ( surfaced at each field above). */}
{error && (
<div style={errorText} role="alert"> <div style={errorText} role="alert">
{validationError ?? error} {error}
</div> </div>
)} )}
@ -1172,7 +1205,7 @@ export default function ParamsPanel({
color: tokens.muted2, color: tokens.muted2,
}} }}
> >
<span>КЭШ ПО АДРЕСУ 24 Ч</span> <span>ДАННЫЕ АКТУАЛЬНЫ</span>
<span <span
style={{ style={{
display: "flex", display: "flex",
@ -1191,7 +1224,7 @@ export default function ParamsPanel({
boxShadow: `0 0 6px ${tokens.success}`, boxShadow: `0 0 6px ${tokens.success}`,
}} }}
/> />
ГОТОВ ДЕЙСТВИТЕЛЕН 24 Ч
</span> </span>
</div> </div>
</div> </div>

View file

@ -13,8 +13,6 @@ import type { ResultPanelData } from "./mappers";
const { const {
accent, accent,
onAccent,
accentGlow,
ink, ink,
muted, muted,
muted2, muted2,
@ -194,7 +192,7 @@ export default function ResultPanel({
style={{ style={{
fontFamily: font.mono, fontFamily: font.mono,
fontSize: 38, fontSize: 38,
fontWeight: 300, fontWeight: 400,
letterSpacing: -1, letterSpacing: -1,
}} }}
> >
@ -455,12 +453,10 @@ export default function ResultPanel({
left: data.ranges.ads.marker.dotLeft, left: data.ranges.ads.marker.dotLeft,
top: "50%", top: "50%",
transform: "translate(-50%,-50%)", transform: "translate(-50%,-50%)",
width: 13, width: 3,
height: 13, height: 16,
borderRadius: "50%", borderRadius: 1,
background: onAccent, background: accent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}} }}
/> />
</div> </div>
@ -704,12 +700,10 @@ export default function ResultPanel({
left: data.ranges.deals.marker.dotLeft, left: data.ranges.deals.marker.dotLeft,
top: "50%", top: "50%",
transform: "translate(-50%,-50%)", transform: "translate(-50%,-50%)",
width: 13, width: 3,
height: 13, height: 16,
borderRadius: "50%", borderRadius: 1,
background: onAccent, background: accent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}} }}
/> />
</div> </div>

View file

@ -16,8 +16,8 @@ import type { SourcesData } from "./mappers";
const KPI_BG = "rgba(238,244,250,.5)"; const KPI_BG = "rgba(238,244,250,.5)";
const BADGE_BG = "#eaf1f8"; const BADGE_BG = "#eaf1f8";
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr"; const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr";
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr"; const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr";
const card: CSSProperties = { const card: CSSProperties = {
background: tokens.surface.w55, background: tokens.surface.w55,
@ -267,6 +267,7 @@ export default function SourcesView({
}} }}
> >
<span>АДРЕС</span> <span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span> <span>ИСТОЧНИК</span>
<span>/М²</span> <span>/М²</span>
<span>СТОИМОСТЬ</span> <span>СТОИМОСТЬ</span>
@ -290,13 +291,8 @@ export default function SourcesView({
gridTemplateColumns: AD_GRID, gridTemplateColumns: AD_GRID,
}} }}
> >
<span style={{ color: tokens.body }}> <span style={{ color: tokens.body }}>{r.addr}</span>
{r.addr} <span style={metaSpan}>{r.meta}</span>
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span> <span>
<span style={badge}>{r.source ?? "Avito"}</span> <span style={badge}>{r.source ?? "Avito"}</span>
</span> </span>
@ -370,6 +366,7 @@ export default function SourcesView({
}} }}
> >
<span>АДРЕС</span> <span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span> <span>ИСТОЧНИК</span>
<span>/М²</span> <span>/М²</span>
<span>ЦЕНА СДЕЛКИ</span> <span>ЦЕНА СДЕЛКИ</span>
@ -386,13 +383,8 @@ export default function SourcesView({
gridTemplateColumns: DEAL_GRID, gridTemplateColumns: DEAL_GRID,
}} }}
> >
<span style={{ color: tokens.body }}> <span style={{ color: tokens.body }}>{r.addr}</span>
{r.addr} <span style={metaSpan}>{r.meta}</span>
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span> <span>
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "} <span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}> <span style={{ fontSize: "9px", color: tokens.muted2 }}>

View file

@ -479,6 +479,13 @@ export const analytics: Analytics = {
years: ["2022", "2023", "2024", "2025", "2026"], years: ["2022", "2023", "2024", "2025", "2026"],
avito: "58,75 260,82 470,69 680,69 885,73", avito: "58,75 260,82 470,69 680,69 885,73",
yandex: "58,75 260,82 470,51 680,42 885,24", yandex: "58,75 260,82 470,51 680,42 885,24",
yTicks: [
{ label: "200k", y: 20 },
{ label: "150k", y: 65 },
{ label: "100k", y: 110 },
{ label: "50k", y: 155 },
{ label: "0k", y: 195 },
],
}, },
scatterDetail: { scatterDetail: {
note: "16 сделок и 6 аналогов в радиусе 300 м", note: "16 сделок и 6 аналогов в радиусе 300 м",
@ -589,6 +596,7 @@ export const cacheKpi: CacheKpi[] = [
export const dropdownOptions: DropdownOptions = { export const dropdownOptions: DropdownOptions = {
rooms: ["1", "2", "3", "4", "5+"], rooms: ["1", "2", "3", "4", "5+"],
houseType: [ houseType: [
"Не указано",
"Панельный", "Панельный",
"Кирпичный", "Кирпичный",
"Монолитный", "Монолитный",
@ -596,6 +604,7 @@ export const dropdownOptions: DropdownOptions = {
"Монолитно-кирпичный", "Монолитно-кирпичный",
], ],
repair: [ repair: [
"Не указано",
"Без отделки", "Без отделки",
"Требует ремонта", "Требует ремонта",
"Удовлетворительный", "Удовлетворительный",

View file

@ -177,6 +177,28 @@ export function fmtDate(iso: string | null | undefined): string {
}); });
} }
const RU_MONTHS = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
function fmtMonthYear(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at24h). */ /** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at24h). */
function fmtDateShift(iso: string | null | undefined, hours: number): string { function fmtDateShift(iso: string | null | undefined, hours: number): string {
if (!iso) return "—"; if (!iso) return "—";
@ -446,9 +468,9 @@ function numLabel(v: number | null): string {
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [ const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ЦИАН", keys: ["cian"] }, { name: "ЦИАН", keys: ["cian"] },
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] }, { name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
{ name: "РОСРЕЕСТР (ВНУТР.)", keys: ["rosreestr"] }, { name: "РОСРЕЕСТР", keys: ["rosreestr"] },
{ name: "АВИТО ОЦЕНКА", keys: ["avito", "avito_imv"] }, { name: "АВИТО", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК ПРАЙС", keys: ["domklik", "domclick"] }, { name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
{ name: "RESTATE", keys: ["restate"] }, { name: "RESTATE", keys: ["restate"] },
{ name: "N1.RU", keys: ["n1", "n1ru"] }, { name: "N1.RU", keys: ["n1", "n1ru"] },
]; ];
@ -1239,7 +1261,7 @@ export function mapHistory(
delta: pct1(pr.discount_pct), delta: pct1(pr.discount_pct),
deltaColor, deltaColor,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined, addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
date: fmtDate(pr.deal_date), date: fmtMonthYear(pr.deal_date),
source: pr.listing_source, source: pr.listing_source,
}; };
}); });
@ -1329,18 +1351,25 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
}); });
} }
// Price-history polyline geometry. viewBox 0 0 900 220; y maps 0→195 … 200k→20 // Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
// (the chart's own gridlines), x distributes the years evenly across [58,885] // y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source. // [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
// so a narrow series like 120160k fills the plot instead of flat-lining against
// a fixed 0200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
const PH_X_LEFT = 58; const PH_X_LEFT = 58;
const PH_X_RIGHT = 885; const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
function phYearX(i: number, n: number): number { function phYearX(i: number, n: number): number {
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2; if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1); return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
} }
function phPriceY(ppm: number): number {
return clamp(195 - (ppm / 200000) * 175, 20, 195);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = { const empty: PriceHistory = {
@ -1348,6 +1377,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
years: [], years: [],
avito: "", avito: "",
yandex: "", yandex: "",
yTicks: [],
}; };
if (!a || a.price_history.length === 0) return empty; if (!a || a.price_history.length === 0) return empty;
const ph = a.price_history; const ph = a.price_history;
@ -1358,13 +1388,44 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const)); const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
const n = yearsNum.length; const n = yearsNum.length;
// Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw
// so the polylines fill the plot band instead of squashing against 0200k.
const allPpm = ph
.filter(
(p) =>
PH_SERIES_SOURCES.includes(p.source) &&
Number.isFinite(p.median_price_per_m2),
)
.map((p) => p.median_price_per_m2);
if (allPpm.length === 0) return empty;
let yMin = Math.min(...allPpm);
let yMax = Math.max(...allPpm);
if (yMin === yMax) {
yMin = yMin * 0.97;
yMax = yMax * 1.03;
} else {
const pad = (yMax - yMin) * 0.08;
yMin -= pad;
yMax += pad;
}
yMin = Math.max(0, yMin);
const span = yMax - yMin || 1;
const priceY = (ppm: number): number =>
clamp(
PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN,
PH_Y_TOP,
PH_Y_BOTTOM,
);
const series = (src: string): string => const series = (src: string): string =>
ph ph
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2)) .filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
.sort((x, y) => x.year - y.year) .sort((x, y) => x.year - y.year)
.map((p) => { .map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n)); const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
const y = Math.round(phPriceY(p.median_price_per_m2)); const y = Math.round(priceY(p.median_price_per_m2));
return `${x},${y}`; return `${x},${y}`;
}) })
.join(" "); .join(" ");
@ -1375,11 +1436,17 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
["лот", "лота", "лотов"], ["лот", "лота", "лотов"],
)})`; )})`;
const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => {
const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span;
return { label: `${Math.round(v / 1000)}k`, y: gy };
});
return { return {
note, note,
years: yearsNum.map(String), years: yearsNum.map(String),
avito: series("avito_imv"), avito: series("avito_imv"),
yandex: series("yandex_valuation"), yandex: series("yandex_valuation"),
yTicks,
}; };
} }
@ -1537,7 +1604,7 @@ export function mapAnalytics(
const sellTgt = sellTime?.target_median_price_per_m2 ?? null; const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
const sellTimeNote = `Медиана экспозиции по архивным лотам${ const sellTimeNote = `Медиана экспозиции по архивным лотам${
sellTgt != null ? ` · эталон ${fmtPpm(sellTgt)}` : "" sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
}`; }`;
return { return {
@ -1644,7 +1711,7 @@ export function mapSources(
price: numRu(l.price_rub), price: numRu(l.price_rub),
source: sourceLabel(l.source), source: sourceLabel(l.source),
tier: tierLabel(l.tier), tier: tierLabel(l.tier),
date: fmtDate(l.listing_date), date: fmtMonthYear(l.listing_date),
})); }));
const marketAds: MarketAds = { const marketAds: MarketAds = {

View file

@ -212,6 +212,8 @@ export interface PriceHistory {
/** SVG polyline points string (viewBox 0 0 900 220). */ /** SVG polyline points string (viewBox 0 0 900 220). */
avito: string; avito: string;
yandex: string; yandex: string;
/** Y-axis tick labels + pixel y, recomputed for the data-driven ₽/м² domain. */
yTicks: AxisTickY[];
} }
export interface ScatterDetail { export interface ScatterDetail {