"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 = ∞ при свободной цене. */}
Цена ± к рынку onPriceFactorChange(Number(e.target.value))} />
{priceFactor === 1 ? "0%" : `${(priceFactor - 1) * 100 > 0 ? "+" : ""}${((priceFactor - 1) * 100).toFixed(0)}%`}
×{priceFactor.toFixed(2)}
{/* 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 (
{label}
{value} {unit ? ( {unit} ) : null}
{hint ? (
{hint}
) : null}
); }