fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062)
Честная полировка живого /trade-in/v2 без правок архитектуры (FE-only). - Провенанс из data, без хардкодов: footer «ПОСТРОЕНО ПО N АНАЛОГАМ И M СДЕЛКАМ» из meta.builtOn; единый счётчик источников (activeSourceCount = залитые плитки) в шапке 02, СВОДКЕ и оверлее; убраны «6/10», «2/7», «Avito·10», «из 2 источников». - Единый ru-форматтер: запятая везде (CV/площадь/%), NBSP число+единица, один en-dash и одна точность для денежных диапазонов. - CTA «ОЦЕНИТЬ КВАРТИРУ» → solid brand-blue + белый текст; :focus-visible HUD-glow на инпутах и кнопке. - Лендмарки <nav>/<main>/<footer> (display:contents, 0 визуальных изменений); scale-fit артборда — на 1366×768 / 1440×900 ужимается, не режется. - Мини-карта: честный зум (min=1, без пустых краёв при отдалении), убран мёртвый crosshair-двойник, расклеены налезающие метки цен. - CRM-дропдаун явно задизейблен («скоро», без ▼); убран «ШАГ 1/1». - Карточка 02 → «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ · с учётом торга» + строка-примирение с медианой ДКП; КОЭФ.ЛОКАЦИИ → muted-пилюля «скоро». - Бейдж СВОДКИ «В РАСЧЁТЕ» → «АКТУАЛЬНО»; скрыты целиком-прочерк колонки (ИСТОЧНИКОВ в 07, ОБЪЯВЛЕНИЕ ДО СДЕЛКИ в 04) data-driven.
This commit is contained in:
parent
83ae6f8a47
commit
30e158ad2a
10 changed files with 554 additions and 379 deletions
|
|
@ -24,7 +24,11 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
|
||||||
import { Footer } from "@/components/trade-in/v2/Footer";
|
import { Footer } from "@/components/trade-in/v2/Footer";
|
||||||
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
||||||
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
||||||
import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
|
import type {
|
||||||
|
Analytics,
|
||||||
|
ObjectInfo,
|
||||||
|
Report,
|
||||||
|
} from "@/components/trade-in/v2/types";
|
||||||
import type {
|
import type {
|
||||||
CacheData,
|
CacheData,
|
||||||
HistoryData,
|
HistoryData,
|
||||||
|
|
@ -156,13 +160,7 @@ const retryBtnStyle: CSSProperties = {
|
||||||
// ── Result/summary area state panels (kept inside the page — these are layout
|
// ── Result/summary area state panels (kept inside the page — these are layout
|
||||||
// states, not reusable design components, so they do not belong in v2/). ───
|
// states, not reusable design components, so they do not belong in v2/). ───
|
||||||
|
|
||||||
function SkeletonBlock({
|
function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
|
||||||
height,
|
|
||||||
flex,
|
|
||||||
}: {
|
|
||||||
height?: number;
|
|
||||||
flex?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="v2-skel"
|
className="v2-skel"
|
||||||
|
|
@ -399,11 +397,25 @@ export default function TradeInV2Page() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
|
||||||
|
// instead of clipping. Never upscale (capped at 1); recomputed on resize.
|
||||||
|
const [artboardScale, setArtboardScale] = useState(1);
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () =>
|
||||||
|
setArtboardScale(
|
||||||
|
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
|
||||||
|
);
|
||||||
|
compute();
|
||||||
|
window.addEventListener("resize", compute);
|
||||||
|
return () => window.removeEventListener("resize", compute);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mutation = useEstimateMutation();
|
const mutation = useEstimateMutation();
|
||||||
// Skip the restore fetch once we already hold a fresh result.
|
// Skip the restore fetch once we already hold a fresh result.
|
||||||
const restored = useEstimate(freshResult === null ? urlId : null);
|
const restored = useEstimate(freshResult === null ? urlId : null);
|
||||||
|
|
||||||
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null;
|
const estimate: AggregatedEstimate | null =
|
||||||
|
freshResult ?? restored.data ?? null;
|
||||||
const currentEstimateId = freshResult?.estimate_id ?? urlId;
|
const currentEstimateId = freshResult?.estimate_id ?? urlId;
|
||||||
|
|
||||||
// Per-user side data, independent of the current estimate. Both fail-soft
|
// Per-user side data, independent of the current estimate. Both fail-soft
|
||||||
|
|
@ -454,7 +466,8 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
const loading = mutation.isPending || restoreLoading;
|
const loading = mutation.isPending || restoreLoading;
|
||||||
const insufficient =
|
const insufficient =
|
||||||
estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0);
|
estimate != null &&
|
||||||
|
(estimate.insufficient_data || estimate.n_analogs === 0);
|
||||||
const apiError = mutation.error?.message ?? null;
|
const apiError = mutation.error?.message ?? null;
|
||||||
|
|
||||||
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
||||||
|
|
@ -472,7 +485,8 @@ export default function TradeInV2Page() {
|
||||||
[estimate, streetDealsData],
|
[estimate, streetDealsData],
|
||||||
);
|
);
|
||||||
const summaryData = useMemo(
|
const summaryData = useMemo(
|
||||||
() => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null),
|
() =>
|
||||||
|
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
|
||||||
[estimate, analyticsData, streetDealsData],
|
[estimate, analyticsData, streetDealsData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -604,103 +618,116 @@ export default function TradeInV2Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: 1536,
|
|
||||||
height: 1024,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: tokens.gradientBg,
|
|
||||||
fontFamily: tokens.font.sans,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<style>{HELPER_STYLES}</style>
|
|
||||||
|
|
||||||
{/* OUTER HUD FRAME */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
inset: FRAME_INSET,
|
width: 1536,
|
||||||
border: `1px solid ${tokens.line}`,
|
height: 1024,
|
||||||
borderRadius: 4,
|
overflow: "hidden",
|
||||||
clipPath: FRAME_CLIP,
|
background: tokens.gradientBg,
|
||||||
pointerEvents: "none",
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
color: tokens.ink,
|
||||||
/>
|
transform: `scale(${artboardScale})`,
|
||||||
{/* 4 corner brackets */}
|
transformOrigin: "top left",
|
||||||
{brackets.map((b) => (
|
|
||||||
<div
|
|
||||||
key={b.key}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: BRACKET_ARM,
|
|
||||||
height: BRACKET_ARM,
|
|
||||||
pointerEvents: "none",
|
|
||||||
...b.style,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* CONTENT WRAP */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 14,
|
|
||||||
padding: "16px 28px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopNav
|
<style>{HELPER_STYLES}</style>
|
||||||
active={nav}
|
|
||||||
onNavigate={setNav}
|
|
||||||
reports={reportsCount ?? 0}
|
|
||||||
user={topNavUser}
|
|
||||||
onLogout={logout}
|
|
||||||
/>
|
|
||||||
<HeroBar
|
|
||||||
data={{ report, object: objectInfo }}
|
|
||||||
estimateId={mounted ? currentEstimateId : null}
|
|
||||||
onOpenInfo={() => setDrawerOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* OUTER HUD FRAME */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
position: "absolute",
|
||||||
gridTemplateColumns: "474px 1fr 250px",
|
inset: FRAME_INSET,
|
||||||
gap: 18,
|
border: `1px solid ${tokens.line}`,
|
||||||
flex: 1,
|
borderRadius: 4,
|
||||||
minHeight: 0,
|
clipPath: FRAME_CLIP,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 4 corner brackets */}
|
||||||
|
{brackets.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: BRACKET_ARM,
|
||||||
|
height: BRACKET_ARM,
|
||||||
|
pointerEvents: "none",
|
||||||
|
...b.style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* CONTENT WRAP */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 14,
|
||||||
|
padding: "16px 28px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParamsPanel
|
<nav aria-label="Разделы" style={{ display: "contents" }}>
|
||||||
key={estimate?.estimate_id ?? "new"}
|
<TopNav
|
||||||
onSubmit={handleSubmit}
|
active={nav}
|
||||||
isPending={mutation.isPending}
|
onNavigate={setNav}
|
||||||
error={apiError}
|
reports={reportsCount ?? 0}
|
||||||
initialValues={initialValues}
|
user={topNavUser}
|
||||||
markers={markers}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
{middleContent}
|
</nav>
|
||||||
{rightContent}
|
<main style={{ display: "contents" }}>
|
||||||
|
<HeroBar
|
||||||
|
data={{ report, object: objectInfo }}
|
||||||
|
estimateId={mounted ? currentEstimateId : null}
|
||||||
|
onOpenInfo={() => setDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "474px 1fr 250px",
|
||||||
|
gap: 18,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParamsPanel
|
||||||
|
key={estimate?.estimate_id ?? "new"}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
error={apiError}
|
||||||
|
initialValues={initialValues}
|
||||||
|
markers={markers}
|
||||||
|
/>
|
||||||
|
{middleContent}
|
||||||
|
{rightContent}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer style={{ display: "contents" }}>
|
||||||
|
<Footer data={report} />
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer data={report} />
|
{nav !== 0 && (
|
||||||
</div>
|
<SectionOverlay
|
||||||
|
active={nav}
|
||||||
{nav !== 0 && (
|
onClose={() => setNav(0)}
|
||||||
<SectionOverlay
|
onNavigate={setNav}
|
||||||
active={nav}
|
history={historyData}
|
||||||
onClose={() => setNav(0)}
|
analytics={analyticsViewData}
|
||||||
onNavigate={setNav}
|
sources={sourcesData}
|
||||||
history={historyData}
|
cache={cacheData}
|
||||||
analytics={analyticsViewData}
|
/>
|
||||||
sources={sourcesData}
|
)}
|
||||||
cache={cacheData}
|
<LocationDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import { tokens } from "./tokens";
|
||||||
import { cacheKpi, cacheRows } from "./fixtures";
|
import { cacheKpi, cacheRows } from "./fixtures";
|
||||||
import type { CacheData } from "./mappers";
|
import type { CacheData } from "./mappers";
|
||||||
|
|
||||||
const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
|
|
||||||
|
|
||||||
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
|
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
|
||||||
|
|
||||||
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
|
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
|
||||||
|
const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr";
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
{/* 3 KPI cards */}
|
{/* 3 KPI cards */}
|
||||||
|
|
@ -87,7 +87,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: GRID_COLUMNS,
|
gridTemplateColumns: gridCols,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
padding: "9px 18px",
|
padding: "9px 18px",
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
|
@ -98,7 +98,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
>
|
>
|
||||||
<span>АДРЕС</span>
|
<span>АДРЕС</span>
|
||||||
<span>ВРЕМЯ</span>
|
<span>ВРЕМЯ</span>
|
||||||
<span>ИСТОЧНИКОВ</span>
|
{showSrc && <span>ИСТОЧНИКОВ</span>}
|
||||||
<span>СТАТУС</span>
|
<span>СТАТУС</span>
|
||||||
</div>
|
</div>
|
||||||
{data.rows.map((r, i) => (
|
{data.rows.map((r, i) => (
|
||||||
|
|
@ -106,7 +106,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
key={`${r.addr}-${i}`}
|
key={`${r.addr}-${i}`}
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: GRID_COLUMNS,
|
gridTemplateColumns: gridCols,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|
@ -120,9 +120,13 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
>
|
>
|
||||||
{r.time}
|
{r.time}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
{showSrc && (
|
||||||
{r.src}
|
<span
|
||||||
</span>
|
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||||
|
>
|
||||||
|
{r.src}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
|
||||||
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
|
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
|
||||||
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
|
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
|
||||||
if (typeof window === "undefined") return href;
|
if (typeof window === "undefined") return href;
|
||||||
return safeUrl(new URL(href, window.location.origin).toString()) ? href : null;
|
return safeUrl(new URL(href, window.location.origin).toString())
|
||||||
|
? href
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeroBarProps {
|
interface HeroBarProps {
|
||||||
|
|
@ -366,16 +368,32 @@ export default function HeroBar({
|
||||||
КОЭФ. ЛОКАЦИИ
|
КОЭФ. ЛОКАЦИИ
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span
|
{data.object.locationCoef === "—" ? (
|
||||||
style={{
|
<span
|
||||||
fontFamily: tokens.font.mono,
|
style={{
|
||||||
fontSize: 15,
|
fontSize: "9px",
|
||||||
fontWeight: 500,
|
letterSpacing: ".5px",
|
||||||
color: tokens.accent,
|
color: tokens.muted2,
|
||||||
}}
|
background: tokens.badgeTint,
|
||||||
>
|
border: `1px solid ${tokens.line2}`,
|
||||||
{data.object.locationCoef}
|
borderRadius: 10,
|
||||||
</span>
|
padding: "1px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
скоро
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: tokens.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.object.locationCoef}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import type { HistoryData } from "./mappers";
|
||||||
|
|
||||||
// Shared grid templates (kept verbatim from the design column tracks).
|
// Shared grid templates (kept verbatim from the design column tracks).
|
||||||
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
||||||
const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr";
|
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
|
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
|
||||||
|
|
@ -21,7 +20,13 @@ interface HistoryViewProps {
|
||||||
data?: HistoryData;
|
data?: HistoryData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps) {
|
export default function HistoryView({
|
||||||
|
data = HISTORY_FIXTURE,
|
||||||
|
}: HistoryViewProps) {
|
||||||
|
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
|
||||||
|
const dkpGridCols = showAsk
|
||||||
|
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
|
||||||
|
: "2.2fr 1fr 1.3fr 1fr .9fr";
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
|
||||||
{/* ── История продаж в этом доме ───────────────────────────── */}
|
{/* ── История продаж в этом доме ───────────────────────────── */}
|
||||||
|
|
@ -278,7 +283,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: dkpGrid,
|
gridTemplateColumns: dkpGridCols,
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
padding: "9px 18px",
|
padding: "9px 18px",
|
||||||
fontSize: "9px",
|
fontSize: "9px",
|
||||||
|
|
@ -292,7 +297,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
<span>ЦЕНА СДЕЛКИ</span>
|
<span>ЦЕНА СДЕЛКИ</span>
|
||||||
<span>₽/М²</span>
|
<span>₽/М²</span>
|
||||||
<span>ДАТА</span>
|
<span>ДАТА</span>
|
||||||
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
{showAsk && <span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.dkpRows.map((r, i) => (
|
{data.dkpRows.map((r, i) => (
|
||||||
|
|
@ -300,7 +305,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: dkpGrid,
|
gridTemplateColumns: dkpGridCols,
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
padding: "11px 18px",
|
padding: "11px 18px",
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
|
|
@ -323,32 +328,34 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
>
|
>
|
||||||
{r.date ?? "—"}
|
{r.date ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
{showAsk && (
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: "10px",
|
|
||||||
color: tokens.muted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "8px",
|
display: "flex",
|
||||||
letterSpacing: ".5px",
|
alignItems: "center",
|
||||||
background: tokens.badgeTint,
|
gap: "7px",
|
||||||
border: `1px solid ${tokens.line2}`,
|
fontFamily: tokens.font.mono,
|
||||||
borderRadius: "3px",
|
fontSize: "10px",
|
||||||
padding: "1px 5px",
|
color: tokens.muted,
|
||||||
color: tokens.accent,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{r.source ?? "—"}
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "8px",
|
||||||
|
letterSpacing: ".5px",
|
||||||
|
background: tokens.badgeTint,
|
||||||
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: "3px",
|
||||||
|
padding: "1px 5px",
|
||||||
|
color: tokens.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.source ?? "—"}
|
||||||
|
</span>
|
||||||
|
{r.ask}
|
||||||
|
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
||||||
</span>
|
</span>
|
||||||
{r.ask}
|
)}
|
||||||
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export function ObjectSummary({
|
||||||
background: tokens.success,
|
background: tokens.success,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
В РАСЧЁТЕ
|
АКТУАЛЬНО
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -140,7 +140,9 @@ export function ObjectSummary({
|
||||||
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
|
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.object.address}</div>
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{data.object.address}
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
|
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
|
||||||
{data.object.city}
|
{data.object.city}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface DdProps {
|
||||||
triggerBg?: string;
|
triggerBg?: string;
|
||||||
triggerColor?: string;
|
triggerColor?: string;
|
||||||
caretColor?: string;
|
caretColor?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dd({
|
function Dd({
|
||||||
|
|
@ -63,6 +64,7 @@ function Dd({
|
||||||
triggerBg = tokens.surface.w62,
|
triggerBg = tokens.surface.w62,
|
||||||
triggerColor = tokens.ink,
|
triggerColor = tokens.ink,
|
||||||
caretColor = tokens.muted2,
|
caretColor = tokens.muted2,
|
||||||
|
disabled = false,
|
||||||
}: DdProps) {
|
}: DdProps) {
|
||||||
const panelStyle: CSSProperties = {
|
const panelStyle: CSSProperties = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -88,7 +90,11 @@ function Dd({
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger"
|
disabled
|
||||||
|
? "pp-dd-trigger-disabled"
|
||||||
|
: variant === "dashed"
|
||||||
|
? "pp-dd-trigger-dashed"
|
||||||
|
: "pp-dd-trigger"
|
||||||
}
|
}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -102,19 +108,21 @@ function Dd({
|
||||||
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
|
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
|
||||||
fontSize: triggerFontSize,
|
fontSize: triggerFontSize,
|
||||||
color: triggerColor,
|
color: triggerColor,
|
||||||
cursor: "pointer",
|
cursor: disabled ? "default" : "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
<span
|
{!disabled && (
|
||||||
style={{
|
<span
|
||||||
color: caretColor,
|
style={{
|
||||||
fontSize: 9,
|
color: caretColor,
|
||||||
fontFamily: tokens.font.sans,
|
fontSize: 9,
|
||||||
}}
|
fontFamily: tokens.font.sans,
|
||||||
>
|
}}
|
||||||
▼
|
>
|
||||||
</span>
|
▼
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
|
|
@ -149,11 +157,14 @@ const styles = `
|
||||||
.pp-dd-trigger:hover{border-color:${tokens.accent}}
|
.pp-dd-trigger:hover{border-color:${tokens.accent}}
|
||||||
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
|
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
|
||||||
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
|
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
|
||||||
|
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
|
||||||
.pp-dd-opt{transition:background .15s}
|
.pp-dd-opt{transition:background .15s}
|
||||||
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
|
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
|
||||||
.pp-eval-btn{background:linear-gradient(90deg,rgba(255,255,255,.7),rgba(233,242,252,.7));box-shadow:0 0 0 0 rgba(46,139,255,0);transition:all .18s}
|
.pp-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
|
||||||
.pp-eval-btn:hover{background:linear-gradient(90deg,rgba(46,139,255,.12),rgba(46,139,255,.18));box-shadow:0 6px 22px rgba(46,139,255,.28)}
|
.pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
|
||||||
|
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
||||||
.pp-eval-btn:active{transform:translateY(1px)}
|
.pp-eval-btn:active{transform:translateY(1px)}
|
||||||
|
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const hintLabel: CSSProperties = {
|
const hintLabel: CSSProperties = {
|
||||||
|
|
@ -343,7 +354,9 @@ export default function ParamsPanel({
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||||
// Map zoom — applied as transform: scale() on the map content layer (not the
|
// Map zoom — applied as transform: scale() on the map content layer (not the
|
||||||
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp .6–2.5.
|
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp 1–2.5
|
||||||
|
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
|
||||||
|
// shrinking would only expose empty corners — zoom-in only).
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
const [area, setArea] = useState(
|
const [area, setArea] = useState(
|
||||||
|
|
@ -378,7 +391,9 @@ export default function ParamsPanel({
|
||||||
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
|
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
|
||||||
// gated at >=3 chars and fires on the debounced value. Picking a suggestion
|
// gated at >=3 chars and fires on the debounced value. Picking a suggestion
|
||||||
// fills the full address and captures lat/lon so the backend can skip geocoding.
|
// fills the full address and captures lat/lon so the backend can skip geocoding.
|
||||||
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? "");
|
const [addressQuery, setAddressQuery] = useState(
|
||||||
|
initialValues?.address ?? "",
|
||||||
|
);
|
||||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
|
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
|
||||||
initialValues?.lat != null && initialValues?.lon != null
|
initialValues?.lat != null && initialValues?.lon != null
|
||||||
|
|
@ -479,7 +494,9 @@ export default function ParamsPanel({
|
||||||
// ring never blows past the map box. The inner two rings stay fixed.
|
// ring never blows past the map box. The inner two rings stay fixed.
|
||||||
const radiusM = parseInt(radius, 10);
|
const radiusM = parseInt(radius, 10);
|
||||||
const outerRingR = Number.isFinite(radiusM)
|
const outerRingR = Number.isFinite(radiusM)
|
||||||
? Math.round(Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))))
|
? Math.round(
|
||||||
|
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
|
||||||
|
)
|
||||||
: 112;
|
: 112;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -544,16 +561,6 @@ export default function ParamsPanel({
|
||||||
ПАРАМЕТРЫ КВАРТИРЫ
|
ПАРАМЕТРЫ КВАРТИРЫ
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 10,
|
|
||||||
letterSpacing: 1,
|
|
||||||
color: tokens.muted2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ШАГ 1/1
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MAP */}
|
{/* MAP */}
|
||||||
|
|
@ -580,97 +587,97 @@ export default function ParamsPanel({
|
||||||
transition: "transform .15s ease-out",
|
transition: "transform .15s ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 440 210"
|
viewBox="0 0 440 210"
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<rect width="440" height="210" fill="#e6edf4" />
|
|
||||||
<g stroke="#dbe5ef" strokeWidth={1}>
|
|
||||||
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
|
||||||
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
|
||||||
</g>
|
|
||||||
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
|
||||||
<path d="M-10 92 H450" />
|
|
||||||
<path d="M150 -10 V220" />
|
|
||||||
<path d="M-10 30 L260 0" />
|
|
||||||
</g>
|
|
||||||
<g stroke="#eef3f9" strokeWidth={5}>
|
|
||||||
<path d="M300 -10 L470 150" />
|
|
||||||
<path d="M-10 170 H450" />
|
|
||||||
</g>
|
|
||||||
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
|
||||||
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
|
||||||
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
|
||||||
<circle cx="208" cy="100" r="80" opacity={0.4} />
|
|
||||||
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
|
||||||
</g>
|
|
||||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* center pin */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "47%",
|
|
||||||
top: "48%",
|
|
||||||
transform: "translate(-50%,-100%)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
position: "absolute",
|
||||||
alignItems: "center",
|
inset: 0,
|
||||||
gap: 6,
|
width: "100%",
|
||||||
background: tokens.surface.w85,
|
height: "100%",
|
||||||
border: `1px solid ${tokens.accent}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "4px 8px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<rect width="440" height="210" fill="#e6edf4" />
|
||||||
style={{
|
<g stroke="#dbe5ef" strokeWidth={1}>
|
||||||
width: 7,
|
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
||||||
height: 7,
|
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
||||||
borderRadius: "50%",
|
</g>
|
||||||
background: tokens.accent,
|
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
||||||
}}
|
<path d="M-10 92 H450" />
|
||||||
/>
|
<path d="M150 -10 V220" />
|
||||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
<path d="M-10 30 L260 0" />
|
||||||
{address.trim() || "Адрес квартиры"}
|
</g>
|
||||||
</span>
|
<g stroke="#eef3f9" strokeWidth={5}>
|
||||||
</div>
|
<path d="M300 -10 L470 150" />
|
||||||
|
<path d="M-10 170 H450" />
|
||||||
|
</g>
|
||||||
|
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
||||||
|
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
||||||
|
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
||||||
|
<circle cx="208" cy="100" r="80" opacity={0.4} />
|
||||||
|
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
||||||
|
</g>
|
||||||
|
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* center pin */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 0,
|
position: "absolute",
|
||||||
height: 0,
|
left: "47%",
|
||||||
borderLeft: "5px solid transparent",
|
top: "48%",
|
||||||
borderRight: "5px solid transparent",
|
transform: "translate(-50%,-100%)",
|
||||||
borderTop: `7px solid ${tokens.accent}`,
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
background: tokens.surface.w85,
|
||||||
|
border: `1px solid ${tokens.accent}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "4px 8px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: tokens.accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{address.trim() || "Адрес квартиры"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: "5px solid transparent",
|
||||||
|
borderRight: "5px solid transparent",
|
||||||
|
borderTop: `7px solid ${tokens.accent}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* analog price pins — projected from the real estimate via mapMarkers().
|
{/* analog price pins — projected from the real estimate via mapMarkers().
|
||||||
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
||||||
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
||||||
{markers.map((m, i) => (
|
{markers.map((m, i) => (
|
||||||
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
||||||
{m.label}
|
{m.label}
|
||||||
<br />
|
<br />
|
||||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* map controls */}
|
{/* map controls */}
|
||||||
|
|
@ -713,38 +720,18 @@ export default function ParamsPanel({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
cursor: "pointer",
|
cursor: zoom <= 1 ? "default" : "pointer",
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: tokens.muted,
|
color: tokens.muted,
|
||||||
|
opacity: zoom <= 1 ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
|
||||||
}}
|
}}
|
||||||
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
|
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
background: tokens.surface.w80,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all .15s",
|
|
||||||
color: tokens.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
|
||||||
<circle cx="6.5" cy="6.5" r="4" stroke="#2e8bff" />
|
|
||||||
<line x1="6.5" y1="0" x2="6.5" y2="2.5" stroke="#2e8bff" />
|
|
||||||
<line x1="6.5" y1="10.5" x2="6.5" y2="13" stroke="#2e8bff" />
|
|
||||||
<line x1="0" y1="6.5" x2="2.5" y2="6.5" stroke="#2e8bff" />
|
|
||||||
<line x1="10.5" y1="6.5" x2="13" y2="6.5" stroke="#2e8bff" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* map corner bracket */}
|
{/* map corner bracket */}
|
||||||
|
|
@ -873,6 +860,7 @@ export default function ParamsPanel({
|
||||||
HUD <Dd> panel styling. Picking an item fills the full address
|
HUD <Dd> panel styling. Picking an item fills the full address
|
||||||
and captures lat/lon for the estimate payload. */}
|
and captures lat/lon for the estimate payload. */}
|
||||||
<input
|
<input
|
||||||
|
className="pp-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={(e) => handleAddressChange(e.target.value)}
|
onChange={(e) => handleAddressChange(e.target.value)}
|
||||||
|
|
@ -941,12 +929,11 @@ export default function ParamsPanel({
|
||||||
<div>
|
<div>
|
||||||
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
|
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
|
||||||
<input
|
<input
|
||||||
|
className="pp-input"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={area}
|
value={area}
|
||||||
onChange={(e) =>
|
onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))}
|
||||||
setArea(e.target.value.replace(/[^\d.,]/g, ""))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
|
|
@ -976,6 +963,7 @@ export default function ParamsPanel({
|
||||||
<span style={optHint}>если знаешь</span>
|
<span style={optHint}>если знаешь</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
className="pp-input"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={floor}
|
value={floor}
|
||||||
|
|
@ -993,6 +981,7 @@ export default function ParamsPanel({
|
||||||
<span style={optHint}>если знаешь</span>
|
<span style={optHint}>если знаешь</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
className="pp-input"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={totalFloors}
|
value={totalFloors}
|
||||||
|
|
@ -1026,6 +1015,7 @@ export default function ParamsPanel({
|
||||||
<span style={optHint}>опц.</span>
|
<span style={optHint}>опц.</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
className="pp-input"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={year}
|
value={year}
|
||||||
|
|
@ -1094,7 +1084,7 @@ export default function ParamsPanel({
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<span style={hintLabel}>CRM — ДЛЯ МЕНЕДЖЕРА</span>
|
<span style={hintLabel}>CRM — ДЛЯ МЕНЕДЖЕРА</span>
|
||||||
<span style={optHint}>опц.</span>
|
<span style={optHint}>скоро</span>
|
||||||
</div>
|
</div>
|
||||||
{/* CRM — non-functional (no backend, #395). Markup kept; open is
|
{/* CRM — non-functional (no backend, #395). Markup kept; open is
|
||||||
forced false + no-op handlers so it never expands. TODO(wire). */}
|
forced false + no-op handlers so it never expands. TODO(wire). */}
|
||||||
|
|
@ -1113,6 +1103,7 @@ export default function ParamsPanel({
|
||||||
triggerBg={tokens.surface.w50}
|
triggerBg={tokens.surface.w50}
|
||||||
triggerColor={tokens.hint}
|
triggerColor={tokens.hint}
|
||||||
caretColor={tokens.muted4}
|
caretColor={tokens.muted4}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1136,7 +1127,7 @@ export default function ParamsPanel({
|
||||||
cursor: isPending ? "wait" : "pointer",
|
cursor: isPending ? "wait" : "pointer",
|
||||||
opacity: isPending ? 0.6 : 1,
|
opacity: isPending ? 0.6 : 1,
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
color: tokens.ink,
|
color: tokens.onAccent,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1152,9 +1143,9 @@ export default function ParamsPanel({
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
border: `1px solid ${tokens.accent}`,
|
border: "1px solid rgba(255,255,255,.7)",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
color: tokens.accent,
|
color: tokens.onAccent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,19 @@ export default function ResultPanel({
|
||||||
>
|
>
|
||||||
{card.ppm}
|
{card.ppm}
|
||||||
</div>
|
</div>
|
||||||
|
{card.note && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: muted2,
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: "82%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{card.bars ? (
|
{card.bars ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -885,7 +898,7 @@ export default function ResultPanel({
|
||||||
При недоступности источника частичный результат не блокируется
|
При недоступности источника частичный результат не блокируется
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
|
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
|
||||||
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ
|
{data.meta.builtOn ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import type { SourcesData } from "./mappers";
|
||||||
// One-off tints not present in the v2 palette (kept literal, faithful to design).
|
// One-off tints not present in the v2 palette (kept literal, faithful to design).
|
||||||
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 AVITO_DOT = "#e8743b";
|
|
||||||
|
|
||||||
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
|
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
|
||||||
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
|
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
|
||||||
|
|
@ -187,7 +186,9 @@ interface SourcesViewProps {
|
||||||
data?: SourcesData;
|
data?: SourcesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) {
|
export default function SourcesView({
|
||||||
|
data = SOURCES_FIXTURE,
|
||||||
|
}: SourcesViewProps) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -208,7 +209,8 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
</div>
|
</div>
|
||||||
<div style={headerRight}>
|
<div style={headerRight}>
|
||||||
Обновлено: —<br />
|
Обновлено: —<br />
|
||||||
Источников: <b style={{ color: tokens.muted }}>2 / 7</b>
|
Источников:{" "}
|
||||||
|
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -216,7 +218,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
||||||
value={data.marketAds.kpi.count}
|
value={data.marketAds.kpi.count}
|
||||||
unit="шт · из 2 источников"
|
unit="шт"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА"
|
label="МЕДИАНА"
|
||||||
|
|
@ -255,26 +257,6 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: "auto",
|
|
||||||
fontSize: "10px",
|
|
||||||
color: tokens.muted,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: AVITO_DOT,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Avito · 10
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,10 @@ export const REPAIR_FROM_RU: Record<string, RepairState> = {
|
||||||
|
|
||||||
// ── Format helpers (ru) ─────────────────────────────────────────────────────
|
// ── Format helpers (ru) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so
|
||||||
|
// "6,56 млн ₽" / "1,2 – 3,4" never wrap mid-token.
|
||||||
|
const NBSP = " ";
|
||||||
|
|
||||||
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
|
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
|
||||||
export function fmtMln(rub: number | null | undefined): string {
|
export function fmtMln(rub: number | null | undefined): string {
|
||||||
if (rub == null || !Number.isFinite(rub)) return "—";
|
if (rub == null || !Number.isFinite(rub)) return "—";
|
||||||
|
|
@ -155,6 +159,12 @@ export function fmtPpm(ppm: number | null | undefined): string {
|
||||||
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
|
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */
|
||||||
|
function fmtArea(area: number | null | undefined): string {
|
||||||
|
if (area == null || !Number.isFinite(area)) return "—";
|
||||||
|
return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`;
|
||||||
|
}
|
||||||
|
|
||||||
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
|
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
|
||||||
export function fmtDate(iso: string | null | undefined): string {
|
export function fmtDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
|
|
@ -228,7 +238,12 @@ function coeffVar(values: number[]): number | null {
|
||||||
|
|
||||||
function cvStr(values: number[]): string {
|
function cvStr(values: number[]): string {
|
||||||
const cv = coeffVar(values);
|
const cv = coeffVar(values);
|
||||||
return cv != null ? `${cv.toFixed(1)}%` : "—";
|
return cv != null
|
||||||
|
? `${cv.toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})}%`
|
||||||
|
: "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -339,10 +354,15 @@ function rangeLine(
|
||||||
lo: number | null | undefined,
|
lo: number | null | undefined,
|
||||||
hi: number | null | undefined,
|
hi: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
if (
|
||||||
|
lo == null ||
|
||||||
|
hi == null ||
|
||||||
|
!Number.isFinite(lo) ||
|
||||||
|
!Number.isFinite(hi)
|
||||||
|
) {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${fmtMln(lo)} — ${fmtMln(hi)} млн ₽`;
|
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}₽`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
||||||
|
|
@ -384,7 +404,8 @@ function parseAddress(full: string | null): { address: string; city: string } {
|
||||||
city = tokenMatch[1].trim();
|
city = tokenMatch[1].trim();
|
||||||
} else {
|
} else {
|
||||||
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
|
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
|
||||||
if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
|
if (anchorIdx > 0)
|
||||||
|
city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address: street token + adjacent house number (house precedes the street in
|
// Address: street token + adjacent house number (house precedes the street in
|
||||||
|
|
@ -444,17 +465,32 @@ function buildSources(e: AggregatedEstimate): SourceCard[] {
|
||||||
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
|
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
|
||||||
const active = slot.keys.some((k) => used.has(k)) || count > 0;
|
const active = slot.keys.some((k) => used.has(k)) || count > 0;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return { name: slot.name, count: "—", label: "нет данных", active: false };
|
return {
|
||||||
|
name: slot.name,
|
||||||
|
count: "—",
|
||||||
|
label: "нет данных",
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: slot.name,
|
name: slot.name,
|
||||||
count: count > 0 ? String(count) : "—",
|
count: count > 0 ? String(count) : "—",
|
||||||
label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
|
label:
|
||||||
|
count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
|
||||||
active: true,
|
active: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical "active sources" count = number of filled source TILES (one per
|
||||||
|
* design slot), so meta/quality/overlay all agree with what the user sees. NOT
|
||||||
|
* e.sources_used.length (which can double-count alias keys / anchor-only sources).
|
||||||
|
*/
|
||||||
|
function activeSourceCount(e: AggregatedEstimate): number {
|
||||||
|
return buildSources(e).filter((s) => s.active).length;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
|
// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
|
||||||
|
|
||||||
interface DealTier {
|
interface DealTier {
|
||||||
|
|
@ -559,7 +595,10 @@ function buildScatterMini(
|
||||||
p.x != null && Number.isFinite(p.y),
|
p.x != null && Number.isFinite(p.y),
|
||||||
);
|
);
|
||||||
|
|
||||||
const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom);
|
const dealsRaw = toPts(
|
||||||
|
sd && sd.deals.length > 0 ? sd.deals : e.actual_deals,
|
||||||
|
realDom,
|
||||||
|
);
|
||||||
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
|
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
|
||||||
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
||||||
const subjectDays = e.est_days_on_market;
|
const subjectDays = e.est_days_on_market;
|
||||||
|
|
@ -657,9 +696,10 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||||||
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box.
|
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box.
|
||||||
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
|
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
|
||||||
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
|
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
|
||||||
// the 4–96% margin. Closest ~6 by distance are shown (the pin chrome only fits a
|
// the 4–96% margin. Finding #12: the closest pins overlapped on the small map, so
|
||||||
// handful). null / empty / un-geocoded estimate → [] (the map shows just the
|
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
|
||||||
// subject pin, never the fake fixture prices).
|
// apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
|
||||||
|
// the subject pin, never the fake fixture prices).
|
||||||
|
|
||||||
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
|
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
|
||||||
export interface MapMarker {
|
export interface MapMarker {
|
||||||
|
|
@ -674,9 +714,13 @@ export interface MapMarker {
|
||||||
// analogs toward the centre, smaller pushes them to the clamped margins.
|
// analogs toward the centre, smaller pushes them to the clamped margins.
|
||||||
const MAP_LAT_HALF_SPAN = 0.011;
|
const MAP_LAT_HALF_SPAN = 0.011;
|
||||||
const MAP_LON_HALF_SPAN = 0.018;
|
const MAP_LON_HALF_SPAN = 0.018;
|
||||||
const MAP_MARKER_CAP = 6;
|
|
||||||
const MAP_CLAMP_LO = 4;
|
const MAP_CLAMP_LO = 4;
|
||||||
const MAP_CLAMP_HI = 96;
|
const MAP_CLAMP_HI = 96;
|
||||||
|
// Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST
|
||||||
|
// from every already-kept pin (Euclidean in the 0–100% map box) so close analogs
|
||||||
|
// don't stack into an unreadable blob on the small map.
|
||||||
|
const MIN_PIN_DIST = 22;
|
||||||
|
const KEEP_CAP = 3;
|
||||||
|
|
||||||
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
|
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
|
||||||
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
|
|
@ -705,10 +749,15 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
: fallbackM;
|
: fallbackM;
|
||||||
return { a, dist };
|
return { a, dist };
|
||||||
})
|
})
|
||||||
.sort((x, y) => x.dist - y.dist)
|
.sort((x, y) => x.dist - y.dist);
|
||||||
.slice(0, MAP_MARKER_CAP);
|
|
||||||
|
|
||||||
return ranked.map(({ a }) => {
|
// Greedy non-overlap pass over the closest-first list: project each candidate
|
||||||
|
// to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every
|
||||||
|
// pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final
|
||||||
|
// map, where they become the MapMarker "NN%" strings.
|
||||||
|
const kept: { left: number; top: number; a: AnalogLot }[] = [];
|
||||||
|
for (const { a } of ranked) {
|
||||||
|
if (kept.length >= KEEP_CAP) break;
|
||||||
const left = clamp(
|
const left = clamp(
|
||||||
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
|
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
|
||||||
MAP_CLAMP_LO,
|
MAP_CLAMP_LO,
|
||||||
|
|
@ -719,13 +768,19 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
MAP_CLAMP_LO,
|
MAP_CLAMP_LO,
|
||||||
MAP_CLAMP_HI,
|
MAP_CLAMP_HI,
|
||||||
);
|
);
|
||||||
return {
|
const overlaps = kept.some(
|
||||||
left: `${round1(left)}%`,
|
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
|
||||||
top: `${round1(top)}%`,
|
);
|
||||||
label: `${fmtMln(a.price_rub)} млн ₽`,
|
if (overlaps) continue;
|
||||||
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
|
kept.push({ left, top, a });
|
||||||
};
|
}
|
||||||
});
|
|
||||||
|
return kept.map(({ left, top, a }) => ({
|
||||||
|
left: `${round1(left)}%`,
|
||||||
|
top: `${round1(top)}%`,
|
||||||
|
label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}₽`,
|
||||||
|
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
|
|
@ -746,23 +801,35 @@ export function mapResultPanel(
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
|
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
|
||||||
value: fmtMln(e.expected_sold_price_rub),
|
value: fmtMln(e.expected_sold_price_rub),
|
||||||
unit: "млн ₽",
|
unit: "млн ₽",
|
||||||
range: rangeLine(e.expected_sold_range_low_rub, e.expected_sold_range_high_rub),
|
range: rangeLine(
|
||||||
ppm: fmtPpm(e.expected_sold_per_m2),
|
e.expected_sold_range_low_rub,
|
||||||
|
e.expected_sold_range_high_rub,
|
||||||
|
),
|
||||||
|
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
|
||||||
delta:
|
delta:
|
||||||
e.asking_to_sold_ratio != null
|
e.asking_to_sold_ratio != null
|
||||||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||||
: "—",
|
: "—",
|
||||||
deltaLabel: "К РЫНКУ",
|
deltaLabel: "К РЫНКУ",
|
||||||
|
note:
|
||||||
|
dealTier != null &&
|
||||||
|
e.expected_sold_price_rub != null &&
|
||||||
|
Number.isFinite(e.expected_sold_price_rub) &&
|
||||||
|
e.expected_sold_price_rub < dealTier.medianRub
|
||||||
|
? "Обычно ниже медианы ДКП — другая выборка и площади"
|
||||||
|
: undefined,
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
|
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
|
||||||
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
|
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
|
||||||
unit: "млн ₽",
|
unit: "млн ₽",
|
||||||
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных",
|
range: dealTier
|
||||||
|
? rangeLine(dealTier.loRub, dealTier.hiRub)
|
||||||
|
: "нет данных",
|
||||||
ppm: dealTier
|
ppm: dealTier
|
||||||
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
|
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
|
||||||
dealTier.count,
|
dealTier.count,
|
||||||
|
|
@ -774,10 +841,24 @@ export function mapResultPanel(
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ
|
||||||
|
// И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show.
|
||||||
|
const builtOnDeals = dealTier?.count ?? 0;
|
||||||
|
const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [
|
||||||
|
"АНАЛОГУ",
|
||||||
|
"АНАЛОГАМ",
|
||||||
|
"АНАЛОГАМ",
|
||||||
|
])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [
|
||||||
|
"СДЕЛКЕ",
|
||||||
|
"СДЕЛКАМ",
|
||||||
|
"СДЕЛКАМ",
|
||||||
|
])}`;
|
||||||
|
|
||||||
const meta: ResultMeta = {
|
const meta: ResultMeta = {
|
||||||
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
||||||
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||||||
|
builtOn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adsBar: RangeBar = {
|
const adsBar: RangeBar = {
|
||||||
|
|
@ -832,7 +913,8 @@ export function mapSummary(
|
||||||
|
|
||||||
const dealCount = streetDeals?.count ?? e.actual_deals.length;
|
const dealCount = streetDeals?.count ?? e.actual_deals.length;
|
||||||
const dealMedian =
|
const dealMedian =
|
||||||
streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.price_rub));
|
streetDeals?.median_price_rub ??
|
||||||
|
median(e.actual_deals.map((d) => d.price_rub));
|
||||||
const row3Value =
|
const row3Value =
|
||||||
dealCount > 0
|
dealCount > 0
|
||||||
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
|
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
|
||||||
|
|
@ -876,7 +958,7 @@ export function mapSummary(
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
quality: {
|
quality: {
|
||||||
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence],
|
confidence: CONFIDENCE_RU[e.confidence],
|
||||||
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||||||
},
|
},
|
||||||
|
|
@ -911,42 +993,47 @@ function numRu(v: number | null | undefined): string {
|
||||||
return Math.round(v).toLocaleString("ru-RU");
|
return Math.round(v).toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */
|
/** "{lo} – {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
|
||||||
function mln1(rub: number): string {
|
|
||||||
return (rub / 1e6).toLocaleString("ru-RU", {
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "{lo} – {hi}" млн range with spaces (dkpKpi / marketDeals). Missing -> "—". */
|
|
||||||
function mlnRangeSp(
|
function mlnRangeSp(
|
||||||
lo: number | null | undefined,
|
lo: number | null | undefined,
|
||||||
hi: number | null | undefined,
|
hi: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
if (
|
||||||
|
lo == null ||
|
||||||
|
hi == null ||
|
||||||
|
!Number.isFinite(lo) ||
|
||||||
|
!Number.isFinite(hi)
|
||||||
|
) {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${mln1(lo)} – ${mln1(hi)}`;
|
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "{lo}–{hi}" млн range, no spaces (marketAds P25–P75). Missing -> "—". */
|
/** Same style as mlnRangeSp (marketAds P25–P75), ru 2dp, NBSP en-dash. Missing -> "—". */
|
||||||
function mlnRangeTight(
|
function mlnRangeTight(
|
||||||
lo: number | null | undefined,
|
lo: number | null | undefined,
|
||||||
hi: number | null | undefined,
|
hi: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
if (
|
||||||
|
lo == null ||
|
||||||
|
hi == null ||
|
||||||
|
!Number.isFinite(lo) ||
|
||||||
|
!Number.isFinite(hi)
|
||||||
|
) {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${mln1(lo)}–${mln1(hi)}`;
|
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signed percent, always 1dp, "." separator + ru minus. -36 -> "−36.0%". */
|
/** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "−36,0%". */
|
||||||
function pct1(v: number | null | undefined): string {
|
function pct1(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return "—";
|
if (v == null || !Number.isFinite(v)) return "—";
|
||||||
const r = Number(v.toFixed(1));
|
const r = Number(v.toFixed(1));
|
||||||
const sign = r < 0 ? "−" : r > 0 ? "+" : "";
|
const sign = r < 0 ? "−" : r > 0 ? "+" : "";
|
||||||
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
return `${sign}${Math.abs(r).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -968,11 +1055,11 @@ function bargainSigned(pct: number | null | undefined): string {
|
||||||
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
||||||
function fmtDist(m: number | null | undefined): string {
|
function fmtDist(m: number | null | undefined): string {
|
||||||
if (m == null || !Number.isFinite(m)) return "—";
|
if (m == null || !Number.isFinite(m)) return "—";
|
||||||
if (m < 1000) return `${Math.round(m)} м`;
|
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
|
||||||
return `${(m / 1000).toLocaleString("ru-RU", {
|
return `${(m / 1000).toLocaleString("ru-RU", {
|
||||||
minimumFractionDigits: 1,
|
minimumFractionDigits: 1,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
})} км`;
|
})}${NBSP}км`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source key -> brand/RU display label (badges + link source).
|
// Source key -> brand/RU display label (badges + link source).
|
||||||
|
|
@ -1013,9 +1100,11 @@ function composeLot(
|
||||||
if (rooms == null) parts.push("Квартира");
|
if (rooms == null) parts.push("Квартира");
|
||||||
else if (rooms <= 0) parts.push("Студия");
|
else if (rooms <= 0) parts.push("Студия");
|
||||||
else parts.push(`${rooms}-к. квартира`);
|
else parts.push(`${rooms}-к. квартира`);
|
||||||
if (area != null && Number.isFinite(area)) parts.push(`${area} м²`);
|
if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
|
||||||
if (floor != null) {
|
if (floor != null) {
|
||||||
parts.push(totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`);
|
parts.push(
|
||||||
|
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
@ -1023,10 +1112,14 @@ function composeLot(
|
||||||
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
|
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
|
||||||
function adMeta(l: AnalogLot): string {
|
function adMeta(l: AnalogLot): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
|
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
if (l.floor != null) {
|
if (l.floor != null) {
|
||||||
parts.push(l.total_floors != null ? `этаж ${l.floor}/${l.total_floors}` : `этаж ${l.floor}`);
|
parts.push(
|
||||||
|
l.total_floors != null
|
||||||
|
? `этаж ${l.floor}/${l.total_floors}`
|
||||||
|
: `этаж ${l.floor}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
@ -1034,7 +1127,7 @@ function adMeta(l: AnalogLot): string {
|
||||||
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
|
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
|
||||||
function dealMeta(l: AnalogLot): string {
|
function dealMeta(l: AnalogLot): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
|
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
@ -1103,7 +1196,9 @@ export function mapHistory(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (salesVsListings?.median_discount_pct != null) {
|
if (salesVsListings?.median_discount_pct != null) {
|
||||||
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`);
|
noteParts.push(
|
||||||
|
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const note =
|
const note =
|
||||||
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
|
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
|
||||||
|
|
@ -1137,7 +1232,7 @@ export function mapHistory(
|
||||||
? tokens.success
|
? tokens.success
|
||||||
: tokens.danger;
|
: tokens.danger;
|
||||||
return {
|
return {
|
||||||
area: `${pr.deal_area_m2.toFixed(1)} м²`,
|
area: fmtArea(pr.deal_area_m2),
|
||||||
price: `${numRu(pr.deal_price_rub)} ₽`,
|
price: `${numRu(pr.deal_price_rub)} ₽`,
|
||||||
ppm: numRu(pr.deal_price_per_m2),
|
ppm: numRu(pr.deal_price_per_m2),
|
||||||
ask,
|
ask,
|
||||||
|
|
@ -1163,7 +1258,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
||||||
value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
|
value:
|
||||||
|
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
|
||||||
unit: "дн.",
|
unit: "дн.",
|
||||||
sub: "по архивным объявлениям",
|
sub: "по архивным объявлениям",
|
||||||
},
|
},
|
||||||
|
|
@ -1195,7 +1291,13 @@ const SELLTIME_META: Record<
|
||||||
|
|
||||||
// 4-column shell kept (with "—") when sell-time data is absent.
|
// 4-column shell kept (with "—") when sell-time data is absent.
|
||||||
const SELLTIME_FALLBACK: SellTimeTier[] = [
|
const SELLTIME_FALLBACK: SellTimeTier[] = [
|
||||||
{ tier: "−5% от рынка", days: "—", range: "—", count: "—", variant: "success" },
|
{
|
||||||
|
tier: "−5% от рынка",
|
||||||
|
days: "—",
|
||||||
|
range: "—",
|
||||||
|
count: "—",
|
||||||
|
variant: "success",
|
||||||
|
},
|
||||||
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
|
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
|
||||||
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
|
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
|
||||||
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
|
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
|
||||||
|
|
@ -1207,12 +1309,16 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
|
||||||
const meta =
|
const meta =
|
||||||
SELLTIME_META[b.price_premium_label] ??
|
SELLTIME_META[b.price_premium_label] ??
|
||||||
({
|
({
|
||||||
tier: b.price_premium_pct === 0 ? "По медиане" : fmtPct(b.price_premium_pct),
|
tier:
|
||||||
|
b.price_premium_pct === 0
|
||||||
|
? "По медиане"
|
||||||
|
: fmtPct(b.price_premium_pct),
|
||||||
variant: "accent",
|
variant: "accent",
|
||||||
} as { tier: string; variant: SellTimeTier["variant"] });
|
} as { tier: string; variant: SellTimeTier["variant"] });
|
||||||
return {
|
return {
|
||||||
tier: meta.tier,
|
tier: meta.tier,
|
||||||
days: b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
|
days:
|
||||||
|
b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
|
||||||
range:
|
range:
|
||||||
b.p25_days != null && b.p75_days != null
|
b.p25_days != null && b.p75_days != null
|
||||||
? `обычно ${b.p25_days}–${b.p75_days} дн.`
|
? `обычно ${b.p25_days}–${b.p75_days} дн.`
|
||||||
|
|
@ -1254,9 +1360,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||||
|
|
||||||
const series = (src: string): string =>
|
const series = (src: string): string =>
|
||||||
ph
|
ph
|
||||||
.filter(
|
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
|
||||||
(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));
|
||||||
|
|
@ -1313,8 +1417,7 @@ function detailExposurePts(
|
||||||
return lots
|
return lots
|
||||||
.map((l) => ({ x: dom(l), y: l.price_rub }))
|
.map((l) => ({ x: dom(l), y: l.price_rub }))
|
||||||
.filter(
|
.filter(
|
||||||
(p): p is { x: number; y: number } =>
|
(p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
|
||||||
p.x != null && Number.isFinite(p.y),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1474,6 +1577,8 @@ export interface SourcesData {
|
||||||
dealRows: DealRowData[];
|
dealRows: DealRowData[];
|
||||||
marketAds: MarketAds;
|
marketAds: MarketAds;
|
||||||
marketDeals: MarketDeals;
|
marketDeals: MarketDeals;
|
||||||
|
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
|
||||||
|
sourceCount?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter chips: distance corridor + rooms + analog area span (design order).
|
// Filter chips: distance corridor + rooms + analog area span (design order).
|
||||||
|
|
@ -1485,17 +1590,28 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
|
||||||
.filter((d): d is number => d != null && Number.isFinite(d));
|
.filter((d): d is number => d != null && Number.isFinite(d));
|
||||||
if (dists.length > 0) {
|
if (dists.length > 0) {
|
||||||
const maxKm = Math.max(...dists) / 1000;
|
const maxKm = Math.max(...dists) / 1000;
|
||||||
const km = maxKm < 1 ? maxKm.toFixed(1) : String(Math.ceil(maxKm));
|
const km =
|
||||||
filters.push(`Расстояние ≤ ${km} км`);
|
maxKm < 1
|
||||||
|
? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
|
||||||
|
: String(Math.ceil(maxKm));
|
||||||
|
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
|
||||||
}
|
}
|
||||||
if (e.rooms != null) {
|
if (e.rooms != null) {
|
||||||
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}-к`}`);
|
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}-к`}`);
|
||||||
}
|
}
|
||||||
const areas = e.analogs.map((a) => a.area_m2).filter((x) => Number.isFinite(x));
|
const areas = e.analogs
|
||||||
|
.map((a) => a.area_m2)
|
||||||
|
.filter((x) => Number.isFinite(x));
|
||||||
if (areas.length > 0) {
|
if (areas.length > 0) {
|
||||||
filters.push(
|
const lo = Math.min(...areas).toLocaleString("ru-RU", {
|
||||||
`Площадь ${Math.min(...areas).toFixed(1)} – ${Math.max(...areas).toFixed(1)} м²`,
|
minimumFractionDigits: 1,
|
||||||
);
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
const hi = Math.max(...areas).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
filters.push(`Площадь ${lo}${NBSP}–${NBSP}${hi}${NBSP}м²`);
|
||||||
}
|
}
|
||||||
return filters;
|
return filters;
|
||||||
}
|
}
|
||||||
|
|
@ -1570,7 +1686,15 @@ export function mapSources(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { adRows, dealRows, marketAds, marketDeals };
|
return {
|
||||||
|
adRows,
|
||||||
|
dealRows,
|
||||||
|
marketAds,
|
||||||
|
marketDeals,
|
||||||
|
sourceCount: estimate
|
||||||
|
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
|
||||||
|
: "—",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
|
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
|
||||||
|
|
@ -1626,7 +1750,8 @@ function cacheStatus(iso: string | null | undefined): {
|
||||||
} {
|
} {
|
||||||
if (!iso) return { status: "—", statusColor: tokens.muted };
|
if (!iso) return { status: "—", statusColor: tokens.muted };
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return { status: "—", statusColor: tokens.muted };
|
if (Number.isNaN(d.getTime()))
|
||||||
|
return { status: "—", statusColor: tokens.muted };
|
||||||
const ageH = (Date.now() - d.getTime()) / 3_600_000;
|
const ageH = (Date.now() - d.getTime()) / 3_600_000;
|
||||||
if (ageH <= CACHE_TTL_HOURS * 0.75) {
|
if (ageH <= CACHE_TTL_HOURS * 0.75) {
|
||||||
return { status: "свежий", statusColor: tokens.success };
|
return { status: "свежий", statusColor: tokens.success };
|
||||||
|
|
@ -1666,7 +1791,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
.map((h) => h.median_price)
|
.map((h) => h.median_price)
|
||||||
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
|
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
|
||||||
const avg =
|
const avg =
|
||||||
prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : null;
|
prices.length > 0
|
||||||
|
? prices.reduce((a, b) => a + b, 0) / prices.length
|
||||||
|
: null;
|
||||||
|
|
||||||
// KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов.
|
// KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов.
|
||||||
const seen = new Map<string, number>();
|
const seen = new Map<string, number>();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ export interface ResultCard {
|
||||||
delta?: string;
|
delta?: string;
|
||||||
/** Donut caption, e.g. "К РЫНКУ" (card 2). */
|
/** Donut caption, e.g. "К РЫНКУ" (card 2). */
|
||||||
deltaLabel?: string;
|
deltaLabel?: string;
|
||||||
|
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */
|
||||||
|
note?: string;
|
||||||
/** Nav index opened by the card's "Подробнее →" link. */
|
/** Nav index opened by the card's "Подробнее →" link. */
|
||||||
nav: number;
|
nav: number;
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +52,8 @@ export interface ResultMeta {
|
||||||
sources: string;
|
sources: string;
|
||||||
confidence: string;
|
confidence: string;
|
||||||
cv: string;
|
cv: string;
|
||||||
|
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
|
||||||
|
builtOn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RangeMarker {
|
export interface RangeMarker {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue