"use client";
import { useMemo } from "react";
import type { RecommendBucket, RecommendMixOutput } from "@/types/analytics";
interface DerivedBucket extends RecommendBucket {
effective_share_pct: number;
effective_units: number | null;
effective_revenue_rub: number | null;
}
interface Props {
scope: RecommendMixOutput["scope"];
derivedRows: DerivedBucket[];
priceFactor: number;
onPriceFactorChange: (next: number) => void;
targetMonths: number | null;
/** Set when user wants the system to suggest required price_factor. */
onTargetMonthsApply?: () => void;
hasAllocation: boolean;
}
const fmtMln = (rub: number | null) =>
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)}`;
/**
* Live "цена↔темп↔срок↔ликвидность" calculator.
*
* Все расчёты — клиентские по базовым коэффициентам из API:
* velocity_at_pf = velocity_base × price_factor^elasticity
* months_to_sellout = units / velocity_at_pf
* liquidity = avg(min(1, 24/months) × units) / total_units × 100
*
* Никаких round-trip к backend на каждое движение слайдера.
*/
export function RecommendVelocityPanel({
scope,
derivedRows,
priceFactor,
onPriceFactorChange,
targetMonths,
hasAllocation,
}: Props) {
const elasticity = scope.elasticity;
const pfPow = useMemo(
() => (priceFactor > 0 ? priceFactor ** elasticity : 1),
[priceFactor, elasticity],
);
// Aggregate live recompute. Темп и сроки считаем per-bucket с СВОЕЙ
// эластичностью (Tier 3) — потому что Студии и 80+ м² реагируют на цену
// по-разному. pfPow выше — fallback для bucket'ов без своей эластичности.
const totals = useMemo(() => {
let units = 0;
let revenue = 0;
let baseVelocity = 0; // sum of bucket velocity at price_factor=1
let adjustedVelocity = 0; // sum of bucket velocity × bucket-specific pf^e
let weightedSold24 = 0;
for (const r of derivedRows) {
const u = r.effective_units ?? 0;
units += u;
const baseRev = r.effective_revenue_rub ?? 0;
revenue += baseRev * priceFactor;
const v = r.velocity_per_month ?? 0;
const shareRatio = r.effective_share_pct / Math.max(r.share_pct, 0.01);
baseVelocity += v * shareRatio;
const be = r.elasticity ?? elasticity;
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
const adjustedV = v * bucketPfPow;
adjustedVelocity += adjustedV * shareRatio;
// Liquidity-24 KPI должен совпадать с точкой кривой
// RecommendLiquidityChart на 24 мес (подпись секции обещает равенство).
// График считает sellout по ЕДИНОЙ scope-эластичности (v × pfPow),
// поэтому здесь тоже используем pfPow, а не per-bucket bucketPfPow.
const liquidityV = v * pfPow;
if (u > 0 && liquidityV > 0) {
const months = u / liquidityV;
const fracIn24 = Math.min(1, 24 / months);
weightedSold24 += fracIn24 * u;
}
}
const tempo =
adjustedVelocity > 0 ? adjustedVelocity : baseVelocity * pfPow;
const monthsToSellout = tempo > 0 && units > 0 ? units / tempo : null;
const liquidity = units > 0 ? (weightedSold24 / units) * 100 : null;
const avgTicket = units > 0 && revenue > 0 ? revenue / units : null;
return {
units,
revenue,
tempo,
monthsToSellout,
liquidity,
avgTicket,
};
}, [derivedRows, priceFactor, pfPow, elasticity]);
const liquidityColor =
totals.liquidity == null
? "#9ca3af"
: totals.liquidity >= 70
? "#0a7a3a"
: totals.liquidity >= 40
? "#9a6700"
: "#b3261e";
const monthsLabel =
totals.monthsToSellout == null
? "—"
: totals.monthsToSellout > 60
? "60+"
: totals.monthsToSellout.toFixed(1);
// Preview required price_factor if target_months active
const required = scope.required_price_factor;
const requiredPct =
required != null ? Math.round((required - 1) * 100) : null;
return (
{/* Big KPI strip */}
0 ? totals.tempo.toFixed(1) : "—"}
unit="кв/мес"
/>
{/* #22 Noise penalty badge */}
{scope.noise_penalty != null && scope.noise_penalty < 0.98 ? (
{
const nb = scope.noise_breakdown;
if (!nb || !("magistral_n" in nb)) return "нет данных";
return (
`Магистрали: ${nb.magistral_n} · ` +
`ЖД: ${nb.railway_n} · ` +
`Промзоны: ${nb.industrial_n} · ` +
`Итого источников: ${nb.total_sources}`
);
})()}
>
{"🔊"} −
{scope.noise_breakdown && "penalty_pct" in scope.noise_breakdown
? scope.noise_breakdown.penalty_pct
: Math.round((1 - scope.noise_penalty) * 100)}
% (магистрали/жд/промзоны в районе)
) : null}
{/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */}
{scope.velocity_source === "rosreestr_fallback" ? (
⚠ Темп грубый — нет sale_graph для ({scope.district}
{scope.target_class ? ` · ${scope.target_class}` : ""}). Показано:
city-wide rosreestr ({scope.total_deals} сделок /{" "}
{scope.effective_window_months} мес) ÷ {scope.competitors_count}{" "}
активных строящихся ЖК. Запусти 442-sweep по {scope.district} для
точных данных.
) : null}
{/* Price factor slider — 0.01..3.0 (от -99% до +200%).
min=0.01 (а не 0) чтобы избежать pf^elasticity = ∞ при свободной
цене. */}
{/* Inverse mode hint */}
{targetMonths && required != null ? (
Целевой срок {targetMonths} мес требует price-factor{" "}
×{required.toFixed(2)}
{" "}
({requiredPct !== null && requiredPct >= 0 ? "+" : ""}
{requiredPct}% к рынку).{" "}
) : null}
{/* Cadastre vs market cross-check (NSPD ↔ rosreestr) */}
{scope.cadastre_median_per_m2 != null &&
scope.cadastre_vs_market_pct != null
? (() => {
const pct = scope.cadastre_vs_market_pct;
const isAnomaly = pct > 50 || pct < -30;
const bg = isAnomaly ? "#fef2f2" : "#f0fdf4";
const border = isAnomaly ? "#fecaca" : "#bbf7d0";
const fg = isAnomaly ? "#b3261e" : "#0a7a3a";
return (
+50% (переоценка рынка) или <-30% (рынок дешевле кадастра, странно).`}
>
Кадастр vs Рынок:
кадастровая медиана{" "}
{(scope.cadastre_median_per_m2 / 1000).toFixed(0)} тыс ₽/м²
{" "}
(по {scope.cadastre_buildings_n} зданиям NSPD), рынок{" "}
{scope.district_median_price_per_m2 != null
? `${(scope.district_median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
: "—"}
{" → "}
{pct > 0 ? "+" : ""}
{pct.toFixed(0)}%
{isAnomaly ? (
⚠ Аномалия
) : null}
);
})()
: null}
{/* Per-bucket elasticity breakdown — Tier 3 */}
{scope.elasticity_per_bucket &&
Object.keys(scope.elasticity_per_bucket).length > 0 ? (
Эластичность по сегментам
{Object.entries(scope.elasticity_per_bucket).map(([b, info]) => {
const isRegr = info.source === "regression";
return (
{b}:
{info.elasticity.toFixed(2)}
{isRegr ? (
(n={info.n})
) : (
(fb)
)}
);
})}
) : null}
{/* #25 Success ranking banner */}
{scope.success_ranking != null &&
scope.success_ranking.length > 0 &&
scope.success_ranking[0].success_score > 0 ? (
💎 Рекомендация смещена в пользу{" "}
{scope.success_ranking[0].bucket} — лучшая динамика в
районе ({scope.success_ranking[0].n_deals} сделок, успех{" "}
{scope.success_ranking[0].success_score.toFixed(2)})
) : null}
{/* Methodology note */}
Эластичность цена↔темп {elasticity} (
{scope.elasticity_source === "regression"
? `регрессия sale_graph за ${scope.elasticity_window_months} мес: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
)
{scope.elasticity_weighted != null &&
Math.abs(scope.elasticity_weighted - elasticity) > 0.05
? ` · взвешенная по бакетам: ${scope.elasticity_weighted.toFixed(2)}`
: ""}
. Базовый темп{" "}
{scope.market_velocity_per_month?.toFixed(1) ?? "—"}{" "}
кв/мес (
{scope.velocity_source === "objective" ||
scope.velocity_source === "sale_graph"
? `${scope.velocity_source}: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
: "fallback на rosreestr-сделки"}
),{" "}
{scope.competitors_radius_n != null &&
scope.competitors_district_only_n != null ? (
нормирован: в радиусе 3км{" "}
{scope.competitors_radius_n} ЖК · дальше по району{" "}
{scope.competitors_district_only_n} ЖК (вес 0.6)
) : (
<>
нормирован на {scope.competitors_count} активных
конкурентов в районе
>
)}
. Применены macro-факторы:{" "}
sat ×{scope.sat_factor.toFixed(2)}
{scope.saturation_median != null
? ` (sold% ${scope.saturation_median.toFixed(0)})`
: ""}{" "}
· trend ×{scope.trend_factor.toFixed(2)}
{scope.velocity_trend_ratio != null
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
: ""}{" "}
· POI ×{scope.poi_factor.toFixed(2)} (на цену) ). При
price ×{priceFactor.toFixed(2)} ценовой множитель темпа по общей
эластичности = {priceFactor.toFixed(2)}^{elasticity} = ×
{pfPow.toFixed(3)}. Показанный «Темп продаж» суммирует бакеты с их
собственной эластичностью (Tier 3) и текущим распределением долей,
поэтому может отличаться от базовый ×{pfPow.toFixed(3)}.
);
}
function BigKpi({
label,
value,
unit,
hint,
color,
}: {
label: string;
value: string;
unit?: string;
hint?: string;
color?: string;
}) {
return (