fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель: ТИП/РЕМОНТ → «Не указано»→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-палитру»; монотонность тиров срока продажи не форсируется (это были бы выдуманные данные).
This commit is contained in:
parent
c8e7cb1c79
commit
1722309372
10 changed files with 221 additions and 116 deletions
|
|
@ -244,30 +244,36 @@ export default function AnalyticsView({
|
|||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
{avitoPts.length > 0 ? (
|
||||
<span
|
||||
style={{ width: 14, height: 2, background: tokens.accent }}
|
||||
/>
|
||||
Avito
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 14, height: 2, background: tokens.gold }} />
|
||||
Яндекс
|
||||
</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
|
||||
|
|
@ -283,21 +289,11 @@ export default function AnalyticsView({
|
|||
<line x1={40} y1={195} x2={900} y2={195} />
|
||||
</g>
|
||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
||||
<text x={0} y={24}>
|
||||
200k
|
||||
</text>
|
||||
<text x={0} y={69}>
|
||||
150k
|
||||
</text>
|
||||
<text x={0} y={114}>
|
||||
100k
|
||||
</text>
|
||||
<text x={0} y={159}>
|
||||
50k
|
||||
</text>
|
||||
<text x={8} y={199}>
|
||||
0k
|
||||
</text>
|
||||
{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) => (
|
||||
|
|
@ -395,8 +391,7 @@ export default function AnalyticsView({
|
|||
Цена × срок продажи
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
Зависимость цены от срока экспозиции ·{" "}
|
||||
{data.scatterDetail.note}
|
||||
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||
|
|
|
|||
|
|
@ -261,7 +261,20 @@ export default function HeroBar({
|
|||
height: "100%",
|
||||
objectFit: "contain",
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -200,9 +200,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
<b style={{ color: tokens.ink2 }}>₽/м²</b> → 3 оценки:{" "}
|
||||
<b style={{ color: tokens.ink2 }}>
|
||||
рекомендованная цена в объявлении
|
||||
</b>{" "}
|
||||
(asking), <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b>{" "}
|
||||
(asking − торг по ДКП),{" "}
|
||||
</b>
|
||||
, <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (с
|
||||
учётом торга по ДКП),{" "}
|
||||
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
|
||||
сделки). CV — коэффициент вариации выборки (разброс цен).
|
||||
</div>
|
||||
|
|
@ -230,7 +230,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
Коэффициент локации (POI, транспорт, шум) —{" "}
|
||||
Коэффициент локации (инфраструктура, транспорт, шум) —{" "}
|
||||
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
||||
локации не корректируется.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export function ObjectSummary({
|
|||
>
|
||||
{row.value}
|
||||
</span>
|
||||
<span style={{ color: tokens.accent, fontSize: 11 }}>↗</span>
|
||||
<span style={{ color: tokens.accent, fontSize: 11 }}>›</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -325,11 +325,11 @@ function initRoomsLabel(rooms: number | null | 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 {
|
||||
return rs ? REPAIR_RU[rs] : "Хороший";
|
||||
return rs ? REPAIR_RU[rs] : "Не указано";
|
||||
}
|
||||
|
||||
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
||||
|
|
@ -383,8 +383,13 @@ export default function ParamsPanel({
|
|||
const [radius, setRadius] = useState(
|
||||
initRadiusLabel(initialValues?.radius_m),
|
||||
);
|
||||
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [balcony, setBalcony] = useState<boolean | null>(
|
||||
initialValues?.has_balcony ?? null,
|
||||
);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
address?: string;
|
||||
area?: string;
|
||||
}>({});
|
||||
|
||||
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
|
||||
// The typed string is debounced into `addressQuery` from inside the change
|
||||
|
|
@ -407,6 +412,8 @@ export default function ParamsPanel({
|
|||
setAddress(v);
|
||||
setCoords(null); // a manual edit invalidates a previously picked point
|
||||
setSuggestOpen(v.trim().length >= 3);
|
||||
if (fieldErrors.address)
|
||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
|
||||
};
|
||||
|
|
@ -432,19 +439,22 @@ export default function ParamsPanel({
|
|||
const handleSubmit = () => {
|
||||
const trimmedAddress = address.trim();
|
||||
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) {
|
||||
setValidationError("Укажите адрес квартиры — минимум 3 символа");
|
||||
return;
|
||||
errs.address = "Укажите адрес квартиры — минимум 3 символа";
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||
setValidationError("Площадь должна быть больше 10 м²");
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setFieldErrors({});
|
||||
setSuggestOpen(false);
|
||||
onSubmit?.({
|
||||
address: trimmedAddress,
|
||||
|
|
@ -455,7 +465,7 @@ export default function ParamsPanel({
|
|||
year_built: year.trim() ? Number(year) : undefined,
|
||||
house_type: HOUSE_TYPE_FROM_RU[houseType],
|
||||
repair_state: REPAIR_FROM_RU[repair],
|
||||
has_balcony: balcony,
|
||||
has_balcony: balcony ?? undefined,
|
||||
lat: coords?.lat ?? null,
|
||||
lon: coords?.lon ?? null,
|
||||
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
|
||||
|
|
@ -874,7 +884,11 @@ export default function ParamsPanel({
|
|||
}}
|
||||
placeholder="Город, улица, дом"
|
||||
autoComplete="off"
|
||||
style={addressField}
|
||||
style={
|
||||
fieldErrors.address
|
||||
? { ...addressField, border: `1px solid ${tokens.danger}` }
|
||||
: addressField
|
||||
}
|
||||
/>
|
||||
<svg
|
||||
style={{
|
||||
|
|
@ -922,6 +936,11 @@ export default function ParamsPanel({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{fieldErrors.address && (
|
||||
<div style={{ ...errorText, marginTop: 4 }} role="alert">
|
||||
{fieldErrors.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -933,13 +952,26 @@ export default function ParamsPanel({
|
|||
type="text"
|
||||
inputMode="decimal"
|
||||
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) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
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 style={hintLabel}>КОМНАТ</div>
|
||||
|
|
@ -960,7 +992,7 @@ export default function ParamsPanel({
|
|||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={hintLabel}>ЭТАЖ</span>
|
||||
<span style={optHint}>если знаешь</span>
|
||||
<span style={optHint}>если знаете</span>
|
||||
</div>
|
||||
<input
|
||||
className="pp-input"
|
||||
|
|
@ -978,7 +1010,7 @@ export default function ParamsPanel({
|
|||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
|
||||
<span style={optHint}>если знаешь</span>
|
||||
<span style={optHint}>если знаете</span>
|
||||
</div>
|
||||
<input
|
||||
className="pp-input"
|
||||
|
|
@ -1005,7 +1037,7 @@ export default function ParamsPanel({
|
|||
marginTop: -3,
|
||||
}}
|
||||
>
|
||||
1-й и последний этаж снижают цену на 5–10% — заполни если знаешь
|
||||
1-й и последний этаж снижают цену на 5–10% — заполните, если знаете
|
||||
</div>
|
||||
|
||||
<div style={fieldRow}>
|
||||
|
|
@ -1067,13 +1099,13 @@ export default function ParamsPanel({
|
|||
<div style={{ height: 40, display: "flex", gap: 6 }}>
|
||||
<div
|
||||
onClick={() => setBalcony(false)}
|
||||
style={{ ...balBase, ...(balcony ? balOff : balOn) }}
|
||||
style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
|
||||
>
|
||||
НЕТ
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setBalcony(true)}
|
||||
style={{ ...balBase, ...(balcony ? balOn : balOff) }}
|
||||
style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
|
||||
>
|
||||
ДА
|
||||
</div>
|
||||
|
|
@ -1152,10 +1184,11 @@ export default function ParamsPanel({
|
|||
</span>
|
||||
</button>
|
||||
|
||||
{/* inline error (validation or server) — only when present */}
|
||||
{(validationError || error) && (
|
||||
{/* inline SERVER error — only when present (field-level validation is
|
||||
surfaced at each field above). */}
|
||||
{error && (
|
||||
<div style={errorText} role="alert">
|
||||
{validationError ?? error}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1172,7 +1205,7 @@ export default function ParamsPanel({
|
|||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
<span>КЭШ ПО АДРЕСУ — 24 Ч</span>
|
||||
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -1191,7 +1224,7 @@ export default function ParamsPanel({
|
|||
boxShadow: `0 0 6px ${tokens.success}`,
|
||||
}}
|
||||
/>
|
||||
ГОТОВ
|
||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import type { ResultPanelData } from "./mappers";
|
|||
|
||||
const {
|
||||
accent,
|
||||
onAccent,
|
||||
accentGlow,
|
||||
ink,
|
||||
muted,
|
||||
muted2,
|
||||
|
|
@ -194,7 +192,7 @@ export default function ResultPanel({
|
|||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 38,
|
||||
fontWeight: 300,
|
||||
fontWeight: 400,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
|
|
@ -455,12 +453,10 @@ export default function ResultPanel({
|
|||
left: data.ranges.ads.marker.dotLeft,
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
background: onAccent,
|
||||
border: `3px solid ${accent}`,
|
||||
boxShadow: `0 0 8px ${accentGlow}`,
|
||||
width: 3,
|
||||
height: 16,
|
||||
borderRadius: 1,
|
||||
background: accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -704,12 +700,10 @@ export default function ResultPanel({
|
|||
left: data.ranges.deals.marker.dotLeft,
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
background: onAccent,
|
||||
border: `3px solid ${accent}`,
|
||||
boxShadow: `0 0 8px ${accentGlow}`,
|
||||
width: 3,
|
||||
height: 16,
|
||||
borderRadius: 1,
|
||||
background: accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import type { SourcesData } from "./mappers";
|
|||
const KPI_BG = "rgba(238,244,250,.5)";
|
||||
const BADGE_BG = "#eaf1f8";
|
||||
|
||||
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
|
||||
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
|
||||
const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr";
|
||||
const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr";
|
||||
|
||||
const card: CSSProperties = {
|
||||
background: tokens.surface.w55,
|
||||
|
|
@ -267,6 +267,7 @@ export default function SourcesView({
|
|||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ПАРАМЕТРЫ</span>
|
||||
<span>ИСТОЧНИК</span>
|
||||
<span>₽/М²</span>
|
||||
<span>СТОИМОСТЬ</span>
|
||||
|
|
@ -290,13 +291,8 @@ export default function SourcesView({
|
|||
gridTemplateColumns: AD_GRID,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
<span style={metaSpan}>
|
||||
{" · "}
|
||||
{r.meta}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: tokens.body }}>{r.addr}</span>
|
||||
<span style={metaSpan}>{r.meta}</span>
|
||||
<span>
|
||||
<span style={badge}>{r.source ?? "Avito"}</span>
|
||||
</span>
|
||||
|
|
@ -370,6 +366,7 @@ export default function SourcesView({
|
|||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ПАРАМЕТРЫ</span>
|
||||
<span>ИСТОЧНИК</span>
|
||||
<span>₽/М²</span>
|
||||
<span>ЦЕНА СДЕЛКИ</span>
|
||||
|
|
@ -386,13 +383,8 @@ export default function SourcesView({
|
|||
gridTemplateColumns: DEAL_GRID,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
<span style={metaSpan}>
|
||||
{" · "}
|
||||
{r.meta}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: tokens.body }}>{r.addr}</span>
|
||||
<span style={metaSpan}>{r.meta}</span>
|
||||
<span>
|
||||
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
||||
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
||||
|
|
|
|||
|
|
@ -479,6 +479,13 @@ export const analytics: Analytics = {
|
|||
years: ["2022", "2023", "2024", "2025", "2026"],
|
||||
avito: "58,75 260,82 470,69 680,69 885,73",
|
||||
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: {
|
||||
note: "16 сделок и 6 аналогов в радиусе 300 м",
|
||||
|
|
@ -589,6 +596,7 @@ export const cacheKpi: CacheKpi[] = [
|
|||
export const dropdownOptions: DropdownOptions = {
|
||||
rooms: ["1", "2", "3", "4", "5+"],
|
||||
houseType: [
|
||||
"Не указано",
|
||||
"Панельный",
|
||||
"Кирпичный",
|
||||
"Монолитный",
|
||||
|
|
@ -596,6 +604,7 @@ export const dropdownOptions: DropdownOptions = {
|
|||
"Монолитно-кирпичный",
|
||||
],
|
||||
repair: [
|
||||
"Не указано",
|
||||
"Без отделки",
|
||||
"Требует ремонта",
|
||||
"Удовлетворительный",
|
||||
|
|
|
|||
|
|
@ -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_at−24h). */
|
||||
function fmtDateShift(iso: string | null | undefined, hours: number): string {
|
||||
if (!iso) return "—";
|
||||
|
|
@ -446,9 +468,9 @@ function numLabel(v: number | null): string {
|
|||
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
|
||||
{ name: "ЦИАН", keys: ["cian"] },
|
||||
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
|
||||
{ name: "РОСРЕЕСТР (ВНУТР.)", keys: ["rosreestr"] },
|
||||
{ name: "АВИТО ОЦЕНКА", keys: ["avito", "avito_imv"] },
|
||||
{ name: "ДОМКЛИК ПРАЙС", keys: ["domklik", "domclick"] },
|
||||
{ name: "РОСРЕЕСТР", keys: ["rosreestr"] },
|
||||
{ name: "АВИТО", keys: ["avito", "avito_imv"] },
|
||||
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
|
||||
{ name: "RESTATE", keys: ["restate"] },
|
||||
{ name: "N1.RU", keys: ["n1", "n1ru"] },
|
||||
];
|
||||
|
|
@ -1239,7 +1261,7 @@ export function mapHistory(
|
|||
delta: pct1(pr.discount_pct),
|
||||
deltaColor,
|
||||
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
|
||||
date: fmtDate(pr.deal_date),
|
||||
date: fmtMonthYear(pr.deal_date),
|
||||
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
|
||||
// (the chart's own gridlines), x distributes the years evenly across [58,885]
|
||||
// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source.
|
||||
// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
|
||||
// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
|
||||
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
|
||||
// so a narrow series like 120–160k fills the plot instead of flat-lining against
|
||||
// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
|
||||
const PH_X_LEFT = 58;
|
||||
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 {
|
||||
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
|
||||
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 {
|
||||
const empty: PriceHistory = {
|
||||
|
|
@ -1348,6 +1377,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
|||
years: [],
|
||||
avito: "",
|
||||
yandex: "",
|
||||
yTicks: [],
|
||||
};
|
||||
if (!a || a.price_history.length === 0) return empty;
|
||||
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 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 0–200k.
|
||||
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 =>
|
||||
ph
|
||||
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
|
||||
.sort((x, y) => x.year - y.year)
|
||||
.map((p) => {
|
||||
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}`;
|
||||
})
|
||||
.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 {
|
||||
note,
|
||||
years: yearsNum.map(String),
|
||||
avito: series("avito_imv"),
|
||||
yandex: series("yandex_valuation"),
|
||||
yTicks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1537,7 +1604,7 @@ export function mapAnalytics(
|
|||
|
||||
const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
|
||||
const sellTimeNote = `Медиана экспозиции по архивным лотам${
|
||||
sellTgt != null ? ` · эталон ${fmtPpm(sellTgt)}` : ""
|
||||
sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
|
||||
}`;
|
||||
|
||||
return {
|
||||
|
|
@ -1644,7 +1711,7 @@ export function mapSources(
|
|||
price: numRu(l.price_rub),
|
||||
source: sourceLabel(l.source),
|
||||
tier: tierLabel(l.tier),
|
||||
date: fmtDate(l.listing_date),
|
||||
date: fmtMonthYear(l.listing_date),
|
||||
}));
|
||||
|
||||
const marketAds: MarketAds = {
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@ export interface PriceHistory {
|
|||
/** SVG polyline points string (viewBox 0 0 900 220). */
|
||||
avito: string;
|
||||
yandex: string;
|
||||
/** Y-axis tick labels + pixel y, recomputed for the data-driven ₽/м² domain. */
|
||||
yTicks: AxisTickY[];
|
||||
}
|
||||
|
||||
export interface ScatterDetail {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue