Compare commits
No commits in common. "b8fd7771b81f714aeaf7c16ba4323e6604c817aa" and "57ae2dfe0046fd3fcb627312dd942dd174fffab8" have entirely different histories.
b8fd7771b8
...
57ae2dfe00
10 changed files with 373 additions and 548 deletions
|
|
@ -24,11 +24,7 @@ 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 {
|
import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
|
||||||
Analytics,
|
|
||||||
ObjectInfo,
|
|
||||||
Report,
|
|
||||||
} from "@/components/trade-in/v2/types";
|
|
||||||
import type {
|
import type {
|
||||||
CacheData,
|
CacheData,
|
||||||
HistoryData,
|
HistoryData,
|
||||||
|
|
@ -160,7 +156,13 @@ 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({ height, flex }: { height?: number; flex?: number }) {
|
function SkeletonBlock({
|
||||||
|
height,
|
||||||
|
flex,
|
||||||
|
}: {
|
||||||
|
height?: number;
|
||||||
|
flex?: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="v2-skel"
|
className="v2-skel"
|
||||||
|
|
@ -397,25 +399,11 @@ 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 =
|
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? 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
|
||||||
|
|
@ -466,8 +454,7 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
const loading = mutation.isPending || restoreLoading;
|
const loading = mutation.isPending || restoreLoading;
|
||||||
const insufficient =
|
const insufficient =
|
||||||
estimate != null &&
|
estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0);
|
||||||
(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
|
||||||
|
|
@ -485,8 +472,7 @@ 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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -618,116 +604,103 @@ export default function TradeInV2Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
|
<div
|
||||||
|
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: "relative",
|
position: "absolute",
|
||||||
width: 1536,
|
inset: FRAME_INSET,
|
||||||
height: 1024,
|
border: `1px solid ${tokens.line}`,
|
||||||
overflow: "hidden",
|
borderRadius: 4,
|
||||||
background: tokens.gradientBg,
|
clipPath: FRAME_CLIP,
|
||||||
fontFamily: tokens.font.sans,
|
pointerEvents: "none",
|
||||||
color: tokens.ink,
|
}}
|
||||||
transform: `scale(${artboardScale})`,
|
/>
|
||||||
transformOrigin: "top left",
|
{/* 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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<style>{HELPER_STYLES}</style>
|
<TopNav
|
||||||
|
active={nav}
|
||||||
{/* OUTER HUD FRAME */}
|
onNavigate={setNav}
|
||||||
<div
|
reports={reportsCount ?? 0}
|
||||||
style={{
|
user={topNavUser}
|
||||||
position: "absolute",
|
onLogout={logout}
|
||||||
inset: FRAME_INSET,
|
/>
|
||||||
border: `1px solid ${tokens.line}`,
|
<HeroBar
|
||||||
borderRadius: 4,
|
data={{ report, object: objectInfo }}
|
||||||
clipPath: FRAME_CLIP,
|
estimateId={mounted ? currentEstimateId : null}
|
||||||
pointerEvents: "none",
|
onOpenInfo={() => setDrawerOpen(true)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/* 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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
display: "grid",
|
||||||
inset: 14,
|
gridTemplateColumns: "474px 1fr 250px",
|
||||||
padding: "16px 28px",
|
gap: 18,
|
||||||
display: "flex",
|
flex: 1,
|
||||||
flexDirection: "column",
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<nav aria-label="Разделы" style={{ display: "contents" }}>
|
<ParamsPanel
|
||||||
<TopNav
|
key={estimate?.estimate_id ?? "new"}
|
||||||
active={nav}
|
onSubmit={handleSubmit}
|
||||||
onNavigate={setNav}
|
isPending={mutation.isPending}
|
||||||
reports={reportsCount ?? 0}
|
error={apiError}
|
||||||
user={topNavUser}
|
initialValues={initialValues}
|
||||||
onLogout={logout}
|
markers={markers}
|
||||||
/>
|
/>
|
||||||
</nav>
|
{middleContent}
|
||||||
<main style={{ display: "contents" }}>
|
{rightContent}
|
||||||
<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>
|
||||||
|
|
||||||
{nav !== 0 && (
|
<Footer data={report} />
|
||||||
<SectionOverlay
|
|
||||||
active={nav}
|
|
||||||
onClose={() => setNav(0)}
|
|
||||||
onNavigate={setNav}
|
|
||||||
history={historyData}
|
|
||||||
analytics={analyticsViewData}
|
|
||||||
sources={sourcesData}
|
|
||||||
cache={cacheData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LocationDrawer
|
|
||||||
open={drawerOpen}
|
|
||||||
onClose={() => setDrawerOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nav !== 0 && (
|
||||||
|
<SectionOverlay
|
||||||
|
active={nav}
|
||||||
|
onClose={() => setNav(0)}
|
||||||
|
onNavigate={setNav}
|
||||||
|
history={historyData}
|
||||||
|
analytics={analyticsViewData}
|
||||||
|
sources={sourcesData}
|
||||||
|
cache={cacheData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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: gridCols,
|
gridTemplateColumns: GRID_COLUMNS,
|
||||||
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>
|
||||||
{showSrc && <span>ИСТОЧНИКОВ</span>}
|
<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: gridCols,
|
gridTemplateColumns: GRID_COLUMNS,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|
@ -120,13 +120,9 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||||
>
|
>
|
||||||
{r.time}
|
{r.time}
|
||||||
</span>
|
</span>
|
||||||
{showSrc && (
|
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||||
<span
|
{r.src}
|
||||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
</span>
|
||||||
>
|
|
||||||
{r.src}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ 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())
|
return safeUrl(new URL(href, window.location.origin).toString()) ? href : null;
|
||||||
? href
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeroBarProps {
|
interface HeroBarProps {
|
||||||
|
|
@ -368,32 +366,16 @@ export default function HeroBar({
|
||||||
КОЭФ. ЛОКАЦИИ
|
КОЭФ. ЛОКАЦИИ
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
{data.object.locationCoef === "—" ? (
|
<span
|
||||||
<span
|
style={{
|
||||||
style={{
|
fontFamily: tokens.font.mono,
|
||||||
fontSize: "9px",
|
fontSize: 15,
|
||||||
letterSpacing: ".5px",
|
fontWeight: 500,
|
||||||
color: tokens.muted2,
|
color: tokens.accent,
|
||||||
background: tokens.badgeTint,
|
}}
|
||||||
border: `1px solid ${tokens.line2}`,
|
>
|
||||||
borderRadius: 10,
|
{data.object.locationCoef}
|
||||||
padding: "1px 8px",
|
</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
скоро
|
|
||||||
</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,6 +12,7 @@ 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 };
|
||||||
|
|
@ -20,13 +21,7 @@ interface HistoryViewProps {
|
||||||
data?: HistoryData;
|
data?: HistoryData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryView({
|
export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps) {
|
||||||
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" }}>
|
||||||
{/* ── История продаж в этом доме ───────────────────────────── */}
|
{/* ── История продаж в этом доме ───────────────────────────── */}
|
||||||
|
|
@ -283,7 +278,7 @@ export default function HistoryView({
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: dkpGridCols,
|
gridTemplateColumns: dkpGrid,
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
padding: "9px 18px",
|
padding: "9px 18px",
|
||||||
fontSize: "9px",
|
fontSize: "9px",
|
||||||
|
|
@ -297,7 +292,7 @@ export default function HistoryView({
|
||||||
<span>ЦЕНА СДЕЛКИ</span>
|
<span>ЦЕНА СДЕЛКИ</span>
|
||||||
<span>₽/М²</span>
|
<span>₽/М²</span>
|
||||||
<span>ДАТА</span>
|
<span>ДАТА</span>
|
||||||
{showAsk && <span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
|
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.dkpRows.map((r, i) => (
|
{data.dkpRows.map((r, i) => (
|
||||||
|
|
@ -305,7 +300,7 @@ export default function HistoryView({
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: dkpGridCols,
|
gridTemplateColumns: dkpGrid,
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
padding: "11px 18px",
|
padding: "11px 18px",
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
|
|
@ -328,34 +323,32 @@ export default function HistoryView({
|
||||||
>
|
>
|
||||||
{r.date ?? "—"}
|
{r.date ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{showAsk && (
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: "10px",
|
||||||
|
color: tokens.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
fontSize: "8px",
|
||||||
alignItems: "center",
|
letterSpacing: ".5px",
|
||||||
gap: "7px",
|
background: tokens.badgeTint,
|
||||||
fontFamily: tokens.font.mono,
|
border: `1px solid ${tokens.line2}`,
|
||||||
fontSize: "10px",
|
borderRadius: "3px",
|
||||||
color: tokens.muted,
|
padding: "1px 5px",
|
||||||
|
color: tokens.accent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{r.source ?? "—"}
|
||||||
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,9 +140,7 @@ 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 }}>
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.object.address}</div>
|
||||||
{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,7 +46,6 @@ interface DdProps {
|
||||||
triggerBg?: string;
|
triggerBg?: string;
|
||||||
triggerColor?: string;
|
triggerColor?: string;
|
||||||
caretColor?: string;
|
caretColor?: string;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dd({
|
function Dd({
|
||||||
|
|
@ -64,7 +63,6 @@ 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",
|
||||||
|
|
@ -90,11 +88,7 @@ function Dd({
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
disabled
|
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger"
|
||||||
? "pp-dd-trigger-disabled"
|
|
||||||
: variant === "dashed"
|
|
||||||
? "pp-dd-trigger-dashed"
|
|
||||||
: "pp-dd-trigger"
|
|
||||||
}
|
}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -108,21 +102,19 @@ 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: disabled ? "default" : "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
{!disabled && (
|
<span
|
||||||
<span
|
style={{
|
||||||
style={{
|
color: caretColor,
|
||||||
color: caretColor,
|
fontSize: 9,
|
||||||
fontSize: 9,
|
fontFamily: tokens.font.sans,
|
||||||
fontFamily: tokens.font.sans,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
▼
|
||||||
▼
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
|
|
@ -157,14 +149,11 @@ 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-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
|
.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-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: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: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 = {
|
||||||
|
|
@ -354,9 +343,7 @@ 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 1–2.5
|
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp .6–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(
|
||||||
|
|
@ -391,9 +378,7 @@ 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(
|
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? "");
|
||||||
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
|
||||||
|
|
@ -494,9 +479,7 @@ 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.round(Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))))
|
||||||
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
|
|
||||||
)
|
|
||||||
: 112;
|
: 112;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -561,6 +544,16 @@ 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 */}
|
||||||
|
|
@ -587,97 +580,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={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<rect width="440" height="210" fill="#e6edf4" />
|
<rect width="440" height="210" fill="#e6edf4" />
|
||||||
<g stroke="#dbe5ef" strokeWidth={1}>
|
<g stroke="#dbe5ef" strokeWidth={1}>
|
||||||
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
||||||
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
||||||
</g>
|
</g>
|
||||||
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
||||||
<path d="M-10 92 H450" />
|
<path d="M-10 92 H450" />
|
||||||
<path d="M150 -10 V220" />
|
<path d="M150 -10 V220" />
|
||||||
<path d="M-10 30 L260 0" />
|
<path d="M-10 30 L260 0" />
|
||||||
</g>
|
</g>
|
||||||
<g stroke="#eef3f9" strokeWidth={5}>
|
<g stroke="#eef3f9" strokeWidth={5}>
|
||||||
<path d="M300 -10 L470 150" />
|
<path d="M300 -10 L470 150" />
|
||||||
<path d="M-10 170 H450" />
|
<path d="M-10 170 H450" />
|
||||||
</g>
|
</g>
|
||||||
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
||||||
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
||||||
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
<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="80" opacity={0.4} />
|
||||||
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
||||||
</g>
|
</g>
|
||||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* center pin */}
|
{/* center pin */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "47%",
|
||||||
|
top: "48%",
|
||||||
|
transform: "translate(-50%,-100%)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
|
||||||
left: "47%",
|
|
||||||
top: "48%",
|
|
||||||
transform: "translate(-50%,-100%)",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
width: 7,
|
||||||
alignItems: "center",
|
height: 7,
|
||||||
gap: 6,
|
borderRadius: "50%",
|
||||||
background: tokens.surface.w85,
|
background: tokens.accent,
|
||||||
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}`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{address.trim() || "Адрес квартиры"}
|
||||||
|
</span>
|
||||||
</div>
|
</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 */}
|
||||||
|
|
@ -720,18 +713,38 @@ export default function ParamsPanel({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
cursor: zoom <= 1 ? "default" : "pointer",
|
cursor: "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 */}
|
||||||
|
|
@ -860,7 +873,6 @@ 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)}
|
||||||
|
|
@ -929,11 +941,12 @@ 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) => setArea(e.target.value.replace(/[^\d.,]/g, ""))}
|
onChange={(e) =>
|
||||||
|
setArea(e.target.value.replace(/[^\d.,]/g, ""))
|
||||||
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
|
|
@ -963,7 +976,6 @@ 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}
|
||||||
|
|
@ -981,7 +993,6 @@ 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}
|
||||||
|
|
@ -1015,7 +1026,6 @@ 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}
|
||||||
|
|
@ -1084,7 +1094,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). */}
|
||||||
|
|
@ -1103,7 +1113,6 @@ 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>
|
||||||
|
|
@ -1127,7 +1136,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.onAccent,
|
color: tokens.ink,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1143,9 +1152,9 @@ export default function ParamsPanel({
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
border: "1px solid rgba(255,255,255,.7)",
|
border: `1px solid ${tokens.accent}`,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
color: tokens.onAccent,
|
color: tokens.accent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
|
|
|
||||||
|
|
@ -231,19 +231,6 @@ 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
|
||||||
|
|
@ -898,7 +885,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 }}>
|
||||||
{data.meta.builtOn ?? "—"}
|
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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";
|
||||||
|
|
@ -186,9 +187,7 @@ interface SourcesViewProps {
|
||||||
data?: SourcesData;
|
data?: SourcesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourcesView({
|
export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) {
|
||||||
data = SOURCES_FIXTURE,
|
|
||||||
}: SourcesViewProps) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -209,8 +208,7 @@ export default function SourcesView({
|
||||||
</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>
|
||||||
|
|
||||||
|
|
@ -218,7 +216,7 @@ export default function SourcesView({
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
||||||
value={data.marketAds.kpi.count}
|
value={data.marketAds.kpi.count}
|
||||||
unit="шт"
|
unit="шт · из 2 источников"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА"
|
label="МЕДИАНА"
|
||||||
|
|
@ -257,6 +255,26 @@ export default function SourcesView({
|
||||||
{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,10 +140,6 @@ 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 "—";
|
||||||
|
|
@ -159,12 +155,6 @@ 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 "—";
|
||||||
|
|
@ -238,12 +228,7 @@ 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
|
return cv != null ? `${cv.toFixed(1)}%` : "—";
|
||||||
? `${cv.toLocaleString("ru-RU", {
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})}%`
|
|
||||||
: "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -354,15 +339,10 @@ function rangeLine(
|
||||||
lo: number | null | undefined,
|
lo: number | null | undefined,
|
||||||
hi: number | null | undefined,
|
hi: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (
|
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||||
lo == null ||
|
|
||||||
hi == null ||
|
|
||||||
!Number.isFinite(lo) ||
|
|
||||||
!Number.isFinite(hi)
|
|
||||||
) {
|
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}₽`;
|
return `${fmtMln(lo)} — ${fmtMln(hi)} млн ₽`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
||||||
|
|
@ -404,8 +384,7 @@ 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)
|
if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
|
||||||
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
|
||||||
|
|
@ -465,32 +444,17 @@ 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 {
|
return { name: slot.name, count: "—", label: "нет данных", active: false };
|
||||||
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:
|
label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
|
||||||
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 {
|
||||||
|
|
@ -595,10 +559,7 @@ function buildScatterMini(
|
||||||
p.x != null && Number.isFinite(p.y),
|
p.x != null && Number.isFinite(p.y),
|
||||||
);
|
);
|
||||||
|
|
||||||
const dealsRaw = toPts(
|
const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom);
|
||||||
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;
|
||||||
|
|
@ -696,10 +657,9 @@ 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. Finding #12: the closest pins overlapped on the small map, so
|
// the 4–96% margin. Closest ~6 by distance are shown (the pin chrome only fits a
|
||||||
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
|
// handful). null / empty / un-geocoded estimate → [] (the map shows just the
|
||||||
// apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
|
// subject pin, never the fake fixture prices).
|
||||||
// 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 {
|
||||||
|
|
@ -714,13 +674,9 @@ 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[] {
|
||||||
|
|
@ -749,15 +705,10 @@ 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);
|
||||||
|
|
||||||
// Greedy non-overlap pass over the closest-first list: project each candidate
|
return ranked.map(({ a }) => {
|
||||||
// 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,
|
||||||
|
|
@ -768,19 +719,13 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
MAP_CLAMP_LO,
|
MAP_CLAMP_LO,
|
||||||
MAP_CLAMP_HI,
|
MAP_CLAMP_HI,
|
||||||
);
|
);
|
||||||
const overlaps = kept.some(
|
return {
|
||||||
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
|
left: `${round1(left)}%`,
|
||||||
);
|
top: `${round1(top)}%`,
|
||||||
if (overlaps) continue;
|
label: `${fmtMln(a.price_rub)} млн ₽`,
|
||||||
kept.push({ left, top, a });
|
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
|
||||||
}
|
};
|
||||||
|
});
|
||||||
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. */
|
||||||
|
|
@ -801,35 +746,23 @@ 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(
|
range: rangeLine(e.expected_sold_range_low_rub, e.expected_sold_range_high_rub),
|
||||||
e.expected_sold_range_low_rub,
|
ppm: fmtPpm(e.expected_sold_per_m2),
|
||||||
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
|
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных",
|
||||||
? 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,
|
||||||
|
|
@ -841,24 +774,10 @@ 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: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${e.sources_used.length} / ${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 = {
|
||||||
|
|
@ -913,8 +832,7 @@ 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 ??
|
streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.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)} млн` : ""}`
|
||||||
|
|
@ -958,7 +876,7 @@ export function mapSummary(
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
quality: {
|
quality: {
|
||||||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${e.sources_used.length} / ${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)),
|
||||||
},
|
},
|
||||||
|
|
@ -993,47 +911,42 @@ function numRu(v: number | null | undefined): string {
|
||||||
return Math.round(v).toLocaleString("ru-RU");
|
return Math.round(v).toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "{lo} – {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
|
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */
|
||||||
|
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 (
|
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||||
lo == null ||
|
|
||||||
hi == null ||
|
|
||||||
!Number.isFinite(lo) ||
|
|
||||||
!Number.isFinite(hi)
|
|
||||||
) {
|
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
|
return `${mln1(lo)} – ${mln1(hi)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same style as mlnRangeSp (marketAds P25–P75), ru 2dp, NBSP en-dash. Missing -> "—". */
|
/** "{lo}–{hi}" млн range, no spaces (marketAds P25–P75). Missing -> "—". */
|
||||||
function mlnRangeTight(
|
function mlnRangeTight(
|
||||||
lo: number | null | undefined,
|
lo: number | null | undefined,
|
||||||
hi: number | null | undefined,
|
hi: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (
|
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||||
lo == null ||
|
|
||||||
hi == null ||
|
|
||||||
!Number.isFinite(lo) ||
|
|
||||||
!Number.isFinite(hi)
|
|
||||||
) {
|
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
|
return `${mln1(lo)}–${mln1(hi)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "−36,0%". */
|
/** Signed percent, always 1dp, "." 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).toLocaleString("ru-RU", {
|
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})}%`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1055,11 +968,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)}${NBSP}м`;
|
if (m < 1000) return `${Math.round(m)} м`;
|
||||||
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).
|
||||||
|
|
@ -1100,11 +1013,9 @@ 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(fmtArea(area));
|
if (area != null && Number.isFinite(area)) parts.push(`${area} м²`);
|
||||||
if (floor != null) {
|
if (floor != null) {
|
||||||
parts.push(
|
parts.push(totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`);
|
||||||
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
@ -1112,14 +1023,10 @@ 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(fmtArea(l.area_m2));
|
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
if (l.floor != null) {
|
if (l.floor != null) {
|
||||||
parts.push(
|
parts.push(l.total_floors != null ? `этаж ${l.floor}/${l.total_floors}` : `этаж ${l.floor}`);
|
||||||
l.total_floors != null
|
|
||||||
? `этаж ${l.floor}/${l.total_floors}`
|
|
||||||
: `этаж ${l.floor}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
@ -1127,7 +1034,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(fmtArea(l.area_m2));
|
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
@ -1196,9 +1103,7 @@ export function mapHistory(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (salesVsListings?.median_discount_pct != null) {
|
if (salesVsListings?.median_discount_pct != null) {
|
||||||
noteParts.push(
|
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`);
|
||||||
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const note =
|
const note =
|
||||||
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
|
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
|
||||||
|
|
@ -1232,7 +1137,7 @@ export function mapHistory(
|
||||||
? tokens.success
|
? tokens.success
|
||||||
: tokens.danger;
|
: tokens.danger;
|
||||||
return {
|
return {
|
||||||
area: fmtArea(pr.deal_area_m2),
|
area: `${pr.deal_area_m2.toFixed(1)} м²`,
|
||||||
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,
|
||||||
|
|
@ -1258,8 +1163,7 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
||||||
value:
|
value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
|
||||||
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
|
|
||||||
unit: "дн.",
|
unit: "дн.",
|
||||||
sub: "по архивным объявлениям",
|
sub: "по архивным объявлениям",
|
||||||
},
|
},
|
||||||
|
|
@ -1291,13 +1195,7 @@ 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" },
|
||||||
|
|
@ -1309,16 +1207,12 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
|
||||||
const meta =
|
const meta =
|
||||||
SELLTIME_META[b.price_premium_label] ??
|
SELLTIME_META[b.price_premium_label] ??
|
||||||
({
|
({
|
||||||
tier:
|
tier: b.price_premium_pct === 0 ? "По медиане" : fmtPct(b.price_premium_pct),
|
||||||
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:
|
days: b.median_exposure_days != null ? `~${b.median_exposure_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} дн.`
|
||||||
|
|
@ -1360,7 +1254,9 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||||
|
|
||||||
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));
|
||||||
|
|
@ -1417,7 +1313,8 @@ 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.x != null && Number.isFinite(p.y),
|
(p): p is { x: number; y: number } =>
|
||||||
|
p.x != null && Number.isFinite(p.y),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1577,8 +1474,6 @@ 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).
|
||||||
|
|
@ -1590,28 +1485,17 @@ 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 =
|
const km = maxKm < 1 ? maxKm.toFixed(1) : String(Math.ceil(maxKm));
|
||||||
maxKm < 1
|
filters.push(`Расстояние ≤ ${km} км`);
|
||||||
? 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
|
const areas = e.analogs.map((a) => a.area_m2).filter((x) => Number.isFinite(x));
|
||||||
.map((a) => a.area_m2)
|
|
||||||
.filter((x) => Number.isFinite(x));
|
|
||||||
if (areas.length > 0) {
|
if (areas.length > 0) {
|
||||||
const lo = Math.min(...areas).toLocaleString("ru-RU", {
|
filters.push(
|
||||||
minimumFractionDigits: 1,
|
`Площадь ${Math.min(...areas).toFixed(1)} – ${Math.max(...areas).toFixed(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;
|
||||||
}
|
}
|
||||||
|
|
@ -1686,15 +1570,7 @@ export function mapSources(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return { adRows, dealRows, marketAds, marketDeals };
|
||||||
adRows,
|
|
||||||
dealRows,
|
|
||||||
marketAds,
|
|
||||||
marketDeals,
|
|
||||||
sourceCount: estimate
|
|
||||||
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
|
|
||||||
: "—",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
|
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
|
||||||
|
|
@ -1750,8 +1626,7 @@ 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()))
|
if (Number.isNaN(d.getTime())) return { status: "—", statusColor: tokens.muted };
|
||||||
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 };
|
||||||
|
|
@ -1791,9 +1666,7 @@ 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.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : null;
|
||||||
? 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,8 +42,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -52,8 +50,6 @@ 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