481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import type {
|
||
MarketTrend,
|
||
MarketTrendStatus,
|
||
OfferTrend,
|
||
OfferTrendPoint,
|
||
} from "@/types/site-finder";
|
||
|
||
interface Props {
|
||
trend?: MarketTrend | null;
|
||
}
|
||
|
||
const LABEL_COLORS: Record<string, { bg: string; color: string }> = {
|
||
"Сильный рост": { bg: "#dcfce7", color: "#15803d" },
|
||
"Умеренный рост": { bg: "#dbeafe", color: "#1d4ed8" },
|
||
Стагнация: { bg: "#fef3c7", color: "#b45309" },
|
||
Падение: { bg: "#fecaca", color: "#b91c1c" },
|
||
};
|
||
|
||
const N_WEAK = 30;
|
||
const N_STRONG = 100;
|
||
|
||
function trendArrow(delta: number): string {
|
||
if (delta > 1) return "↑";
|
||
if (delta < -1) return "↓";
|
||
return "→";
|
||
}
|
||
|
||
function deltaColor(delta: number): string {
|
||
if (delta > 1) return "#15803d";
|
||
if (delta < -1) return "#b91c1c";
|
||
return "#6b7280";
|
||
}
|
||
|
||
function fmtPrice(v: number): string {
|
||
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 });
|
||
}
|
||
|
||
/**
|
||
* Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01
|
||
* означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата
|
||
* YYYY-MM-DD → «I квартал 2026» (римские I/II/III/IV по месяцу). null при
|
||
* отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() — TZ).
|
||
*/
|
||
const ROMAN_QUARTERS = ["I", "II", "III", "IV"];
|
||
|
||
function formatDealsQuarter(asOf: string | undefined | null): string | null {
|
||
if (!asOf) return null;
|
||
const m = /^(\d{4})-(\d{2})/.exec(asOf);
|
||
if (!m) return null;
|
||
const year = Number(m[1]);
|
||
const month = Number(m[2]);
|
||
if (month < 1 || month > 12) return null;
|
||
const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)];
|
||
return `${quarter} квартал ${year}`;
|
||
}
|
||
|
||
/**
|
||
* #2178: дата YYYY-MM-DD → «DD.MM» или «DD.MM.YYYY» (при withYear). Парсим
|
||
* компоненты напрямую — НЕ через new Date() (TZ-сдвиг на полночь UTC).
|
||
*/
|
||
function formatShortDate(iso: string, withYear: boolean): string | null {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||
if (!m) return null;
|
||
const [, year, month, day] = m;
|
||
return withYear ? `${day}.${month}.${year}` : `${day}.${month}`;
|
||
}
|
||
|
||
/** #2178: «DD.MM–DD.MM.YYYY» для диапазона точек тренда (минус — Unicode en-dash). */
|
||
function formatPeriod(points: OfferTrendPoint[]): string | null {
|
||
if (points.length === 0) return null;
|
||
const first = formatShortDate(points[0].date, false);
|
||
const last = formatShortDate(points[points.length - 1].date, true);
|
||
if (first == null || last == null) return null;
|
||
return `${first}–${last}`;
|
||
}
|
||
|
||
/**
|
||
* #2178: знаковый delta-процент с Unicode-минусом (−, не дефис) и одним знаком.
|
||
* `+8.1%` / `−8.1%` / `0.0%`.
|
||
*/
|
||
function formatSignedPct(delta: number): string {
|
||
const rounded = delta.toFixed(1);
|
||
if (delta > 0) return `+${rounded}%`;
|
||
if (delta < 0) return `−${rounded.replace("-", "")}%`;
|
||
return `${rounded}%`;
|
||
}
|
||
|
||
/** #2178: цвет delta-бейджа предложения — danger при падении, success при росте. */
|
||
function offerDeltaStyle(delta: number): { bg: string; color: string } {
|
||
if (delta > 1) return { bg: "#dcfce7", color: "#15803d" };
|
||
if (delta < -1) return { bg: "#fecaca", color: "#b91c1c" };
|
||
return { bg: "#f3f4f6", color: "#374151" };
|
||
}
|
||
|
||
/**
|
||
* #2178: SVG-спарклайн по точкам медианы предложения. Одиночная линия — цвет
|
||
* #374151 (правило ui-tokens: цвет серии только для сравнения). Без зависимостей.
|
||
* Каждая точка — <title> с датой и ценой «₽/м²» (native hover-tooltip с единицей).
|
||
*/
|
||
function OfferSparkline({ points }: { points: OfferTrendPoint[] }) {
|
||
const W = 260;
|
||
const H = 48;
|
||
const PAD = 4;
|
||
if (points.length < 2) return null;
|
||
|
||
const values = points.map((p) => p.median_price_m2);
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const span = max - min || 1;
|
||
const stepX = (W - PAD * 2) / (points.length - 1);
|
||
|
||
const coords = points.map((p, i) => {
|
||
const x = PAD + i * stepX;
|
||
// Инвертируем Y: выше цена — выше на графике (меньше y).
|
||
const y = PAD + (1 - (p.median_price_m2 - min) / span) * (H - PAD * 2);
|
||
return { x, y };
|
||
});
|
||
|
||
const line = coords
|
||
.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`)
|
||
.join(" ");
|
||
|
||
return (
|
||
<svg
|
||
width="100%"
|
||
height={H}
|
||
viewBox={`0 0 ${W} ${H}`}
|
||
preserveAspectRatio="none"
|
||
role="img"
|
||
aria-label="Динамика медианной цены предложения, ₽/м²"
|
||
style={{ display: "block", overflow: "visible" }}
|
||
>
|
||
<polyline
|
||
points={line}
|
||
fill="none"
|
||
stroke="#374151"
|
||
strokeWidth={1.5}
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
{coords.map((c, i) => (
|
||
<circle key={i} cx={c.x} cy={c.y} r={2} fill="#374151">
|
||
<title>
|
||
{formatShortDate(points[i].date, true) ?? points[i].date} ·{" "}
|
||
{fmtPrice(points[i].median_price_m2)} ₽/м²
|
||
</title>
|
||
</circle>
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #2178: полноценная карточка тренда по предложению (Объектив) для status
|
||
* "offer_only". Нейтральные токены (--fg-primary / --bg-card), НЕ выглядит
|
||
* ошибкой. deals_stale управляет второй строкой-caveat.
|
||
*/
|
||
function OfferOnlyCard({
|
||
title,
|
||
offer,
|
||
dealsStale,
|
||
dealsAsOfDate,
|
||
}: {
|
||
title: string;
|
||
offer: OfferTrend;
|
||
dealsStale: boolean;
|
||
dealsAsOfDate: string | null | undefined;
|
||
}) {
|
||
const latest = offer.points[offer.points.length - 1];
|
||
const delta = offer.delta_pct;
|
||
const period = formatPeriod(offer.points);
|
||
const deltaStyle = delta != null ? offerDeltaStyle(delta) : null;
|
||
|
||
// Источник — полным предложением: «По ценам предложения N ЖК рядом (Объектив,
|
||
// M лотов) · DD.MM–DD.MM.YYYY».
|
||
const lots = offer.lots_latest;
|
||
const sourceParts = [
|
||
`По ценам предложения ${offer.complexes_covered} ЖК рядом`,
|
||
lots != null ? `(Объектив, ${fmtPrice(lots)} лотов)` : "(Объектив)",
|
||
];
|
||
const sourceLine =
|
||
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
|
||
|
||
// Caveat — почему показываем предложение вместо сделок. Сделки Росреестра
|
||
// приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ
|
||
// «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия.
|
||
const dealsQuarter = formatDealsQuarter(dealsAsOfDate);
|
||
const caveat = dealsStale
|
||
? dealsQuarter != null
|
||
? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).`
|
||
: "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)."
|
||
: "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив).";
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "#ffffff",
|
||
border: "1px solid #e5e7eb",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||
{title}
|
||
</div>
|
||
|
||
{/* Main value: текущая медиана предложения + delta-бейдж за период */}
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 24,
|
||
fontWeight: 800,
|
||
color: "#111827",
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{fmtPrice(latest.median_price_m2)} ₽/м²
|
||
</span>
|
||
{delta != null && deltaStyle != null && (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
borderRadius: 6,
|
||
padding: "2px 8px",
|
||
background: deltaStyle.bg,
|
||
color: deltaStyle.color,
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
}}
|
||
>
|
||
{formatSignedPct(delta)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
|
||
медиана предложения · за период
|
||
</div>
|
||
|
||
{/* Мини-график динамики предложения */}
|
||
<div style={{ marginTop: 2 }}>
|
||
<OfferSparkline points={offer.points} />
|
||
</div>
|
||
|
||
{/* Источник полным предложением + caveat */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<div style={{ fontSize: 11, color: "#6b7280" }}>{sourceLine}</div>
|
||
<div style={{ fontSize: 11, color: "#9ca3af" }}>{caveat}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */
|
||
function TrendCaveat({ title, message }: { title: string; message: string }) {
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "#f3f4f6",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||
{title}
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#9ca3af" }}>{message}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MarketTrendBlock({ trend }: Props) {
|
||
if (!trend) {
|
||
return (
|
||
<TrendCaveat
|
||
title="Тренд рынка"
|
||
message="Недостаточно сделок ДДУ в радиусе для тренда"
|
||
/>
|
||
);
|
||
}
|
||
|
||
// P2 (#1871): отсутствие status трактуем как "ok" (старый/кэшированный ответ).
|
||
const status: MarketTrendStatus = trend.status ?? "ok";
|
||
const radiusSuffix =
|
||
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
|
||
const title = `Тренд рынка${radiusSuffix}`;
|
||
|
||
// #2178: сделок в recent-окне нет (поквартальная поставка / нет рядом), но живёт
|
||
// динамика предложения — полноценная карточка тренда по предложению (Объектив).
|
||
if (status === "offer_only") {
|
||
const offer = trend.offer_trend;
|
||
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
|
||
if (offer && offer.points.length > 0) {
|
||
return (
|
||
<OfferOnlyCard
|
||
title={title}
|
||
offer={offer}
|
||
dealsStale={trend.deals_stale ?? false}
|
||
dealsAsOfDate={trend.deals_as_of_date}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<TrendCaveat
|
||
title={title}
|
||
message="Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."
|
||
/>
|
||
);
|
||
}
|
||
|
||
// #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало
|
||
// квартала поставки), НЕ «устарели». Показываем честную квартальную подпись
|
||
// вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете.
|
||
if (status === "source_stale") {
|
||
const quarter = formatDealsQuarter(trend.as_of_date);
|
||
const message =
|
||
trend.label ??
|
||
(quarter != null
|
||
? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.`
|
||
: "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем.");
|
||
return <TrendCaveat title={title} message={message} />;
|
||
}
|
||
|
||
// P2 (#1871): нет сделок в радиусе за период.
|
||
if (status === "no_deals") {
|
||
return (
|
||
<TrendCaveat title={title} message="Нет сделок в радиусе за период." />
|
||
);
|
||
}
|
||
|
||
// status === "ok" — старый контракт с полными числовыми полями.
|
||
const n = trend.recent_deals_count;
|
||
|
||
// Без числовых полей (defensive) — показываем no-data, не падаем.
|
||
if (
|
||
trend.delta_6m_pct == null ||
|
||
trend.recent_avg_price_per_m2 == null ||
|
||
trend.prior_avg_price_per_m2 == null ||
|
||
trend.prior_deals_count == null
|
||
) {
|
||
return (
|
||
<TrendCaveat
|
||
title={title}
|
||
message="Недостаточно сделок ДДУ в радиусе для тренда"
|
||
/>
|
||
);
|
||
}
|
||
|
||
// n < 30 — hide trend entirely, show data-insufficient state
|
||
if (n < N_WEAK) {
|
||
return (
|
||
<TrendCaveat
|
||
title={title}
|
||
message={`Недостаточно данных (n=${n}) — расширьте радиус для анализа тренда`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const delta = trend.delta_6m_pct;
|
||
const arrow = trendArrow(delta);
|
||
const arrowColor = deltaColor(delta);
|
||
// Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее
|
||
// инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу.
|
||
const label = trend.label ?? "";
|
||
const labelKey = label.split(" — ")[0];
|
||
const labelStyle = LABEL_COLORS[labelKey] ?? {
|
||
bg: "#f3f4f6",
|
||
color: "#374151",
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "#fafafa",
|
||
border: "1px solid #e5e7eb",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||
{title}
|
||
</div>
|
||
|
||
{/* Main price */}
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 24,
|
||
fontWeight: 800,
|
||
color: "#111827",
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{fmtPrice(trend.recent_avg_price_per_m2)} ₽/м²
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
|
||
за последние 6 мес · {trend.recent_deals_count} сделок
|
||
</div>
|
||
|
||
{/* Delta */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
color: arrowColor,
|
||
}}
|
||
>
|
||
<span>{arrow}</span>
|
||
<span>
|
||
{delta > 0 ? "+" : ""}
|
||
{delta.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Prior comparison */}
|
||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||
vs {fmtPrice(trend.prior_avg_price_per_m2)} ₽/м² за предыдущие 6 мес (
|
||
{trend.prior_deals_count}→{trend.recent_deals_count} сделок)
|
||
</div>
|
||
|
||
{/* Label badge */}
|
||
{label && (
|
||
<div
|
||
style={{
|
||
display: "inline-block",
|
||
borderRadius: 6,
|
||
padding: "3px 10px",
|
||
background: labelStyle.bg,
|
||
color: labelStyle.color,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
alignSelf: "flex-start",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{label}
|
||
</div>
|
||
)}
|
||
|
||
{/* #2178: компактная вторая строка динамики предложения под сделко-трендом. */}
|
||
{trend.offer_trend &&
|
||
trend.offer_trend.points.length > 0 &&
|
||
(() => {
|
||
const offer = trend.offer_trend;
|
||
const latest = offer.points[offer.points.length - 1];
|
||
const deltaTxt =
|
||
offer.delta_pct != null
|
||
? ` · Δ ${formatSignedPct(offer.delta_pct)}`
|
||
: "";
|
||
return (
|
||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||
Предложение: медиана {fmtPrice(latest.median_price_m2)} ₽/м²
|
||
{deltaTxt} (Объектив)
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Weak data warning: 30 ≤ n < 100 */}
|
||
{n < N_STRONG && (
|
||
<div className="bg-amber-50 border border-amber-200 text-amber-800 text-xs px-2 py-1 rounded">
|
||
Слабые данные (n={n}) — расширьте радиус для точности
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|