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
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:
commit
eab3d928d6
10 changed files with 221 additions and 116 deletions
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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-й и последний этаж снижают цену на 5–10% — заполни если знаешь
|
1-й и последний этаж снижают цену на 5–10% — заполните, если знаете
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
"Не указано",
|
||||||
"Без отделки",
|
"Без отделки",
|
||||||
"Требует ремонта",
|
"Требует ремонта",
|
||||||
"Удовлетворительный",
|
"Удовлетворительный",
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */
|
||||||
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 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_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 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 =>
|
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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue