"use client"; /** * RecommendedProduct — 6.4 «Рекомендованный продукт». §13.4 product_tz: the * recommended obj_class (tag) + квартирография as horizontal bars (доля when an * explicit `pct` mix exists, else |deficit_index| as signal strength — the * current overlay shape), plus the overall product success-score (scoring.overall) * and the optional target price when the caller supplies it. * * GRACEFUL: returns null when product_tz carries nothing meaningful. */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import type { ProductMixEntry, ReportProductTz } from "@/types/forecast"; import { DEFICIT_BALANCE_EPS, deficitTone, fmtNum } from "./scenario-helpers"; interface Props { productTz: ReportProductTz; /** Overall product success-score ∈ 0..1 (scoring.overall) — null when absent. */ successScore: number | null; /** Target price (₽/м²) when the caller can supply it (district median) — optional. */ targetPrice: string | null; } const TONE_CLASS = { good: styles.hbarFillGood, bad: styles.hbarFillBad, warn: styles.hbarFillBad, neutral: styles.hbarFillNeutral, } as const; function isNonEmpty(productTz: ReportProductTz): boolean { return ( productTz.obj_class != null || productTz.mix.length > 0 || !!productTz.summary ); } function mixLabel(m: ProductMixEntry): string { return m.bucket ?? "формат"; } interface MixBar { key: string; label: string; widthPct: number; valueStr: string; tone: "good" | "warn" | "bad" | "neutral"; } function buildBars(mix: ProductMixEntry[]): { bars: MixBar[]; byShare: boolean; } { const rows = mix.filter((m) => m.pct != null || m.deficit_index != null); const byShare = rows.some((m) => m.pct != null); const bars = rows.map((m, i): MixBar => { if (m.pct != null) { const pct = Math.max(0, Math.min(100, m.pct)); return { key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`, label: mixLabel(m), widthPct: pct, valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`, tone: "neutral", }; } const di = m.deficit_index ?? 0; const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS; return { key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`, label: mixLabel(m), widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)), valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`, tone: balanced ? "neutral" : deficitTone(di), }; }); return { bars, byShare }; } export function RecommendedProduct({ productTz, successScore, targetPrice, }: Props) { if (!isNonEmpty(productTz)) return null; const { bars, byShare } = buildBars(productTz.mix); return (
Длина полосы — сила сигнала по индексу дефицита формата: зелёный — недонасыщенность, красный — затоварка.
)} {targetPrice && (