diff --git a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx index 2152b6ea..6ff06e03 100644 --- a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx @@ -19,15 +19,25 @@ import { Badge } from "@/components/ui/Badge"; import type { DemandSupplyForecast } from "@/types/forecast"; import { + deficitPegPlaque, deficitVariant, deficitWord, fmtNum, + isDeficitPegged, + type DeficitPegState, } from "./forecast-helpers"; interface Props { forecasts: DemandSupplyForecast[]; /** Horizon (мес) to emphasise as the current target — highlights its row. */ targetHorizon?: number; + /** + * B1 (#1958/#1959) — pegged-signal state (deficit_index уперся в край шкалы на + * всех горизонтах). Считается в Section6Forecast по общему ряду и прокидывается + * сюда + в 6.2, чтобы плашка «на пределе» не разъезжалась. Когда не задан — + * по умолчанию «не pegged» (плашка скрыта). + */ + peg?: DeficitPegState; } const TH_STYLE: React.CSSProperties = { @@ -53,7 +63,11 @@ const TD_STYLE: React.CSSProperties = { const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" }; -export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { +export function ForecastHorizonsBlock({ + forecasts, + targetHorizon, + peg, +}: Props) { if (forecasts.length === 0) { return (

@@ -68,6 +82,10 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { return (

+ {/* B1 — честная плашка, когда индекс упёрся в край шкалы на всех горизонтах: + точное «−1.00 ×4» не различает горизонты, оценка грубая. */} + {peg?.pegged && peg.sign != null && } +
@@ -137,6 +159,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { > {deficitWord(di)} + {/* B1 — значение на краю шкалы: помечаем «на пределе», чтобы + одинаковые −1.00 ×4 не читались как ложно-точные. */} + {peg?.pegged && isDeficitPegged(di) && ( + + на пределе + + )}
{Math.round(f.projected_demand_units).toLocaleString("ru")} @@ -165,6 +203,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { уверенность ≤ средней.

+ {/* B3 — почему «Прогноз предложения» здесь может быть 0, хотя в §4.3 виден + крупный pipeline: баланс §6 посегментный (класс/район), а §4.3 — класс- + агностичный будущий ввод. Снимает кажущееся противоречие 4.3 ↔ 6. */} +

+ Баланс считается по сегменту (класс/район); класс-агностичный будущий + pipeline из раздела 4.3 в посегментный баланс не входит — поэтому + будущее предложение здесь может быть 0. +

+ {/* Rate-sensitivity phrase — shared across horizons in current data; show the distinct phrases once each to avoid noisy repetition. */} @@ -172,6 +226,32 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { ); } +/** + * B1 (#1958/#1959) — honest plaque shown above 6.1 / inside 6.2 when the deficit + * signal is saturated at the scale edge on every horizon. Exported so 6.2 + * (ScenariosBlock) renders the identical plaque (one visual source of truth). The + * RU text comes from `deficitPegPlaque` (helper) — never hardcoded per block. + * Warn pattern (--warn-soft / role="note") как у остальных caveat-плашек §6. + */ +export function DeficitPegPlaque({ sign }: { sign: -1 | 1 }) { + return ( +
+ {deficitPegPlaque(sign)} +
+ ); +} + function RateSensitivityNotes({ forecasts, }: { diff --git a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx index 20a2980c..4c62fdc6 100644 --- a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx @@ -21,12 +21,21 @@ import { deficitVariant, deficitWord, fmtNum, + type DeficitPegState, } from "./forecast-helpers"; +import { DeficitPegPlaque } from "./ForecastHorizonsBlock"; interface Props { scenarios: ReportScenarios; scenariosSummary: ScenariosSummary | null; targetHorizon: number; + /** + * B1 (#1958/#1959) — pegged-signal state (deficit_index уперся в край шкалы на + * всех горизонтах). Тот же объект, что у 6.1 — считается один раз в + * Section6Forecast. Когда pegged, над карточками показываем честную плашку + * «на пределе», т.к. −1.00 одинаков во всех сценариях. + */ + peg?: DeficitPegState; } // Display order: conservative → base → aggressive (worst-to-best rate path). @@ -80,7 +89,12 @@ export function ScenariosBlock({ scenarios, scenariosSummary, targetHorizon, + peg, }: Props) { + const pegPlaque = + peg?.pegged && peg.sign != null ? ( + + ) : null; const present = SCENARIO_ORDER.filter( (k) => scenarios.by_scenario[k] != null || scenariosSummary != null, ); @@ -109,6 +123,7 @@ export function ScenariosBlock({ ); return (
+ {pegPlaque}
+ {pegPlaque}
+ {/* B2 — участок не под жильё: прогноз спроса/предложения справочный. + Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */} + {gateCaveat && } + {/* Export / share forecast report (EPIC #959) */} @@ -330,9 +346,7 @@ function ForecastReady({ (exec.overall_confidence ?? kn.confidence) != null ? CONFIDENCE_RU[ (exec.overall_confidence ?? kn.confidence) as - | "high" - | "medium" - | "low" + "high" | "medium" | "low" ] : "—" } @@ -370,6 +384,7 @@ function ForecastReady({ )} @@ -381,6 +396,7 @@ function ForecastReady({ scenarios={report.scenarios} scenariosSummary={fm.scenarios_summary} targetHorizon={targetHorizon} + peg={peg} /> )} @@ -457,6 +473,60 @@ function ForecastReady({ ); } +// ── NonResidentialCaveat — участок не под жильё (B2) ────────────────────────── +// +// Заметная warn-плашка В ВЕРХУ §6: бэкенд проставил product_tz.gate_caveat, когда +// gate=«Нельзя строить МКД». Весь прогноз спроса/предложения тогда — справочный +// (считается по сегменту рынка, но строить жильё на участке нельзя). Паттерн +// --warn-soft / role="note" как у СЗЗ-баннера / VelocityBlock-caveat; иконка +// Lucide (НЕ emoji), токены с fallback. `caveat` — готовый RU-текст от бэкенда; +// под ним — наша микрокопия-следствие «прогноз носит справочный характер». + +function NonResidentialCaveat({ caveat }: { caveat: string }) { + return ( +
+ +
+ + Участок не предназначен под жильё (см. блокеры) — прогноз спроса и + предложения носит справочный характер. + + + {caveat} + +
+
+ ); +} + // ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ───────────── // // Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без @@ -533,7 +603,9 @@ function DeficitLegend() {
))}
- + «Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее затоварка и ниже индекс. diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts new file mode 100644 index 00000000..bb3f31f1 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for `deficitPegState` / `isDeficitPegged` — B1 honesty-guard (#1958/#1959). + * + * The §22 deficit_index is clamped to [−1, 1] and saturates at balance_ratio=0.5; + * when supply massively dwarfs demand (ratio ≪ 0.5 на всех горизонтах) every + * horizon clamps to exactly −1.00 (зеркально +1.00 для острого дефицита). Showing + * «−1.00 ×4» reads as broken / falsely precise, so Section6Forecast detects the + * saturated state once and renders a honest «на пределе» plaque over 6.1 / 6.2. + * + * The state is pegged only when EVERY present row is saturated AND same-signed — + * a single non-pegged / opposite-sign row means there IS differentiation. + */ +import { describe, expect, it } from "vitest"; + +import { deficitPegState, isDeficitPegged } from "../forecast-helpers"; + +function rows(...values: (number | null)[]) { + return values.map( + (deficit_index) => + ({ deficit_index }) as { + deficit_index: number; + }, + ); +} + +describe("isDeficitPegged — B1 saturation threshold (#1958/#1959)", () => { + it("is pegged at ±1.00 and within eps of the edge", () => { + expect(isDeficitPegged(-1)).toBe(true); + expect(isDeficitPegged(1)).toBe(true); + expect(isDeficitPegged(-0.99)).toBe(true); + expect(isDeficitPegged(0.995)).toBe(true); + }); + + it("is NOT pegged below the threshold (real differentiation)", () => { + expect(isDeficitPegged(-0.98)).toBe(false); + expect(isDeficitPegged(0.5)).toBe(false); + expect(isDeficitPegged(0)).toBe(false); + }); +}); + +describe("deficitPegState — across horizons (#1958/#1959)", () => { + it("pegs at −1.00 on all horizons (затоварка) → sign −1", () => { + expect(deficitPegState(rows(-1, -1, -1, -1))).toEqual({ + pegged: true, + sign: -1, + }); + }); + + it("pegs at +1.00 on all horizons (острый дефицит) → sign +1", () => { + expect(deficitPegState(rows(1, 1, 1, 1))).toEqual({ + pegged: true, + sign: 1, + }); + }); + + it("does NOT peg when a row is mid-range (signal differentiates)", () => { + expect(deficitPegState(rows(-1, -1, -0.6, -1))).toEqual({ + pegged: false, + sign: null, + }); + }); + + it("does NOT peg when saturated rows have opposite signs", () => { + expect(deficitPegState(rows(-1, 1))).toEqual({ + pegged: false, + sign: null, + }); + }); + + it("ignores null deficit_index and pegs on present saturated rows only", () => { + expect(deficitPegState(rows(null, -1, null, -1))).toEqual({ + pegged: true, + sign: -1, + }); + }); + + it("is not pegged when no rows carry a deficit_index", () => { + expect(deficitPegState(rows(null, null))).toEqual({ + pegged: false, + sign: null, + }); + expect(deficitPegState([])).toEqual({ pegged: false, sign: null }); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts index 1eefc03f..00ab8091 100644 --- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts +++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts @@ -7,6 +7,7 @@ import type { BadgeVariant } from "@/components/ui/Badge"; import type { ConfidenceLevel, + DemandSupplyForecast, FutureSupply, ProductMixEntry, } from "@/types/forecast"; @@ -14,6 +15,77 @@ import type { /** Below this |deficit_index| we treat the market as balanced (neutral). */ export const DEFICIT_BALANCE_EPS = 0.05; +/** + * #1958/#1959 honesty-guard — at/above this |deficit_index| the signal is + * SATURATED («упёрся» в край шкалы). The backend clamps deficit_index to [−1, 1] + * and saturates the index at balance_ratio=0.5; when supply massively dwarfs + * demand (ratio ≪ 0.5 на всех горизонтах) every horizon clamps to exactly −1.00 + * (зеркально +1.00 для острого дефицита). Showing the SAME «−1.00» ×4 reads as + * broken / falsely precise — so we mark such values «на пределе» and раскрываем + * честную плашку. Mirrors `ForecastChart.DEGENERATE_CLAMP_EPS` (0.02 → + * |value| ≥ 0.98) but tightened to 0.99 per the §6 brief (а у графика свой порог). + */ +export const DEFICIT_PEG_THRESHOLD = 0.99; + +/** Saturated (clamp-floor) signal? `|deficit_index| ≥ DEFICIT_PEG_THRESHOLD`. */ +export function isDeficitPegged(deficitIndex: number): boolean { + return Math.abs(deficitIndex) >= DEFICIT_PEG_THRESHOLD; +} + +/** + * Pegged-signal state across a set of horizon rows (HONESTY-GUARD, #1958/#1959). + * The signal is «на пределе шкалы» only when EVERY present deficit_index is + * saturated (|value| ≥ 0.99) AND all саме знака — then «−1.00 ×4» не различает + * горизонты и плашка честнее точного числа. A single non-pegged / opposite-sign + * row means there IS differentiation → not pegged (plain table tells the story). + * + * Returns `{ pegged, sign }`: + * pegged — render the honesty plaque + mark values «на пределе»; + * sign — −1 затоварка (supply ≫ demand) / +1 острый дефицит, или null when + * не pegged. Drives which RU plaque text to show (zeroкально). + * PURE (no JSX) — single source of truth shared by 6.1 (table) and 6.2 (scenarios). + */ +export interface DeficitPegState { + pegged: boolean; + sign: -1 | 1 | null; +} + +export function deficitPegState( + forecasts: Pick[], +): DeficitPegState { + const present = forecasts + .map((f) => f.deficit_index) + .filter((v): v is number => v != null); + if (present.length === 0) return { pegged: false, sign: null }; + const allPegged = present.every(isDeficitPegged); + if (!allPegged) return { pegged: false, sign: null }; + const allSameSign = + present.every((v) => v > 0) || present.every((v) => v < 0); + if (!allSameSign) return { pegged: false, sign: null }; + return { pegged: true, sign: present[0] > 0 ? 1 : -1 }; +} + +/** + * Human RU plaque text for a pegged deficit signal (#1958/#1959). Финдиректору + * объясняем простым языком, что точное «−1.00 ×4» — артефакт упора в край шкалы + * (рынок перенасыщен), а не различие горизонтов. Зеркально для +1.0 — острый + * дефицит. Источник правды для 6.1 и 6.2 (не дублировать строку в TSX). + */ +export function deficitPegPlaque(sign: -1 | 1): string { + if (sign < 0) { + return ( + "Сигнал на пределе шкалы: предложение во много раз превышает спрос на всех " + + "горизонтах — сильная затоварка. Точное −1.00 не различает горизонты (рынок " + + "перенасыщен), оценка грубая." + ); + } + return ( + "Сигнал на пределе шкалы: спрос во много раз превышает предложение на всех " + + "горизонтах — острый дефицит. Точное +1.00 не различает горизонты (рынок " + + "сильно недонасыщен), оценка грубая." + ); +} + /** * Deficit semantics (HARD): >0 недонасыщенность (повод строить, success); * <0 затоварка (warn/danger); ≈0 баланс (neutral).