feat(ptica): Сценарии tab (§22 forecast) + real hero invest-score [PR#2] #1832
13 changed files with 1567 additions and 16 deletions
|
|
@ -17,12 +17,16 @@ import { useState } from "react";
|
|||
import Link from "next/link";
|
||||
|
||||
import styles from "./ptica.module.css";
|
||||
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import {
|
||||
useParcelAnalyzeQuery,
|
||||
useParcelForecastQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { PticaShell } from "@/components/site-finder/ptica/PticaShell";
|
||||
import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
|
||||
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
|
||||
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
|
||||
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
||||
import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -30,9 +34,15 @@ interface Props {
|
|||
}
|
||||
|
||||
export function PticaPageContent({ cad }: Props) {
|
||||
const [horizon] = useState<number>(12);
|
||||
const [horizon, setHorizon] = useState<number>(12);
|
||||
const [tab, setTab] = useState<PticaTab>("analysis");
|
||||
const { data, isLoading, error } = useParcelAnalyzeQuery(cad, horizon);
|
||||
// §22 forecast is keyed/cached by cad — call unconditionally; it polls the
|
||||
// async report (enqueued by /analyze) and feeds both the hero invest-score and
|
||||
// the Scenarios tab. Pending state is handled inside each consumer.
|
||||
const { data: forecastData } = useParcelForecastQuery(cad);
|
||||
const forecastReport =
|
||||
forecastData?.status === "ready" ? forecastData.report : undefined;
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
|
|
@ -70,12 +80,20 @@ export function PticaPageContent({ cad }: Props) {
|
|||
return (
|
||||
<div className={styles.pticaRoot} data-theme="dark">
|
||||
<PticaShell activeTab={tab} onTabChange={setTab}>
|
||||
{tab === "analysis" ? (
|
||||
{tab === "analysis" && (
|
||||
<>
|
||||
<PticaHero analysis={analysis} />
|
||||
<PticaHero analysis={analysis} forecastReport={forecastReport} />
|
||||
<DevelopmentScan analysis={analysis} />
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{tab === "scenarios" && (
|
||||
<PticaScenarios
|
||||
cad={cad}
|
||||
horizon={horizon}
|
||||
onHorizonChange={setHorizon}
|
||||
/>
|
||||
)}
|
||||
{(tab === "reports" || tab === "compare") && (
|
||||
<PticaPlaceholderPanel
|
||||
label="В разработке"
|
||||
hint="Раздел появится в следующем релизе платформы ПТИЦА."
|
||||
|
|
|
|||
|
|
@ -688,6 +688,382 @@
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===================== SCENARIOS (§22 Прогноз) ===================== */
|
||||
.scenariosRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* view header (title + horizon selector) */
|
||||
.viewHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.viewHead h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.viewSub {
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
letter-spacing: 0.08em;
|
||||
margin-left: 8px;
|
||||
text-transform: none;
|
||||
}
|
||||
.horizonSel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.horizonLab {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg button {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
border-right: var(--line) solid var(--border);
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.seg button:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.seg button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.segActive,
|
||||
.seg button.segActive {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 3 scenario cards */
|
||||
.scenarioGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.scenarioCard {
|
||||
border-top: 2px solid var(--accent-cyan);
|
||||
}
|
||||
.scAccentBase {
|
||||
border-top-color: var(--accent-blue);
|
||||
}
|
||||
.scAccentAggr {
|
||||
border-top-color: var(--accent-green);
|
||||
}
|
||||
.scAccentCons {
|
||||
border-top-color: var(--accent-yellow);
|
||||
}
|
||||
.scTag {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.scDeficit {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
margin: 4px 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.deficitPos {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.deficitNeg {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.deficitFlat {
|
||||
color: var(--text);
|
||||
}
|
||||
.scKvRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.scK {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scV {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
}
|
||||
.scNote {
|
||||
margin-top: 6px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* forecast grid (chart + table; confidence + product) */
|
||||
.forecastGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* inline legend */
|
||||
.legendInline {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.legendInline span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.legendInline i {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.legendDemand {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
.legendSupply {
|
||||
background: var(--accent-yellow);
|
||||
}
|
||||
|
||||
/* inline SVG line chart */
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
.chart svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.chartGrid {
|
||||
stroke: var(--border-faint);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.chartDemand {
|
||||
fill: none;
|
||||
stroke: var(--accent-cyan);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.chartSupply {
|
||||
fill: none;
|
||||
stroke: var(--accent-yellow);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
.chartLbl {
|
||||
fill: var(--text-soft);
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* data table */
|
||||
.dtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dtable th,
|
||||
.dtable td {
|
||||
text-align: left;
|
||||
padding: 8px 9px;
|
||||
border-bottom: var(--line) solid var(--border-faint);
|
||||
}
|
||||
.dtable th {
|
||||
color: var(--text-soft);
|
||||
text-transform: uppercase;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.thNum {
|
||||
text-align: right;
|
||||
}
|
||||
.tdNum {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.dtable tbody tr:hover td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.numPos {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.numNeg {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.rowTarget td {
|
||||
background: rgba(47, 111, 159, 0.16);
|
||||
}
|
||||
.targetTag {
|
||||
margin-left: 8px;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* confidence rows + pills */
|
||||
.confRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 6px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.confName {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.confPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border: var(--line) solid currentColor;
|
||||
}
|
||||
.confPillHigh {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.confPillMedium {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.confPillLow {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* tag (recommended class) */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 9px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* horizontal bars (квартирография) */
|
||||
.hbars {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.hbar {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 56px;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
.hbarName {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hbarTrack {
|
||||
height: 5px;
|
||||
background: var(--surface-muted);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hbarFill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.hbarFillGood {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
.hbarFillBad {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
.hbarFillNeutral {
|
||||
background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
|
||||
}
|
||||
.hbarValue {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* hints + empty panels */
|
||||
.panelHint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.emptyPanel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 96px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* pending / polling skeleton (calm, NOT an error) */
|
||||
.forecastPending {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 260px;
|
||||
text-align: center;
|
||||
}
|
||||
.pendingPulse {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--accent-cyan);
|
||||
animation: pticaSpin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes pticaSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.pendingTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pendingHint {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===================== RESPONSIVE ===================== */
|
||||
@media (max-width: 1500px) {
|
||||
.hero {
|
||||
|
|
@ -704,6 +1080,9 @@
|
|||
.scanGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.forecastGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.shell {
|
||||
|
|
@ -722,4 +1101,8 @@
|
|||
.tabs {
|
||||
display: none;
|
||||
}
|
||||
.scenarioGrid,
|
||||
.forecastGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,17 @@
|
|||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
|
||||
forecastReport?: ForecastReport;
|
||||
}
|
||||
|
||||
export function InvestScoreBlock({ analysis }: Props) {
|
||||
const inv = adaptInvestScore(analysis);
|
||||
export function InvestScoreBlock({ analysis, forecastReport }: Props) {
|
||||
const inv = adaptInvestScore(analysis, forecastReport);
|
||||
|
||||
return (
|
||||
<div className={styles.scoreStack}>
|
||||
|
|
@ -36,7 +39,7 @@ export function InvestScoreBlock({ analysis }: Props) {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!inv.score.isReal && inv.score.caption && (
|
||||
{inv.score.caption && (
|
||||
<span className={styles.fieldCaption}>{inv.score.caption}</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import { PticaMap } from "@/components/site-finder/ptica/PticaMap";
|
||||
import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPassportCard";
|
||||
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
||||
|
|
@ -15,9 +16,11 @@ import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-ada
|
|||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
/** §22 forecast report — threaded into the invest-score block once ready. */
|
||||
forecastReport?: ForecastReport;
|
||||
}
|
||||
|
||||
export function PticaHero({ analysis }: Props) {
|
||||
export function PticaHero({ analysis, forecastReport }: Props) {
|
||||
const buildGauge = adaptBuildabilityGauge(analysis);
|
||||
|
||||
return (
|
||||
|
|
@ -28,7 +31,7 @@ export function PticaHero({ analysis }: Props) {
|
|||
|
||||
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}>
|
||||
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
|
||||
<InvestScoreBlock analysis={analysis} />
|
||||
<InvestScoreBlock analysis={analysis} forecastReport={forecastReport} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
|
||||
// ── View-model ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -65,6 +66,14 @@ function formatInt(n: number): string {
|
|||
return Math.round(n).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
/** Fixed-decimal RU number (used for the invest-score on a /10 scale). */
|
||||
function fmtNum(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
/** м² from area_ha (×10000). */
|
||||
function areaM2FromHa(areaHa: number): string {
|
||||
return `${formatInt(areaHa * 10000)} м²`;
|
||||
|
|
@ -205,16 +214,67 @@ export interface PticaInvestScore {
|
|||
buySignal: PticaField;
|
||||
}
|
||||
|
||||
export function adaptInvestScore(a: ParcelAnalysis): PticaInvestScore {
|
||||
/**
|
||||
* Buy-signal from the §22 forecast exec-summary key numbers. Derived from the
|
||||
* (signed) deficit_index — the long `verdict` sentence is a paragraph, not a
|
||||
* badge, so we map the structured KPI instead. >0.05 недонасыщенность →
|
||||
* «приобретать», <−0.05 затоварка → «пропустить», иначе «наблюдать». Flagged
|
||||
* advisory (the forecast itself is advisory). Independent of the overall score,
|
||||
* so it surfaces as soon as the report is ready even if scoring is thin.
|
||||
*/
|
||||
function buySignalFromForecast(report: ForecastReport): PticaField {
|
||||
const kn = report.exec_summary.key_numbers;
|
||||
const di = kn.deficit_index;
|
||||
if (di == null) {
|
||||
return { value: "наблюдать", isReal: true, caption: "advisory · §22" };
|
||||
}
|
||||
const word =
|
||||
di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
|
||||
return { value: word, isReal: true, caption: "advisory · §22" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invest-score view-model. INCREMENT 1: PLACEHOLDER until the §22 forecast is
|
||||
* ready; PR#2: when `report` is present, the score is overall_score×10 (∈ 0..10)
|
||||
* and the buy-signal is derived from the exec-summary KPIs. While the forecast is
|
||||
* still pending (`report` undefined) the «после прогноза» placeholders stand.
|
||||
*/
|
||||
export function adaptInvestScore(
|
||||
a: ParcelAnalysis,
|
||||
report?: ForecastReport,
|
||||
): PticaInvestScore {
|
||||
const risk = adaptRiskGauge(a);
|
||||
const riskField: PticaField =
|
||||
risk.value != null
|
||||
? { value: risk.label, isReal: false, caption: "предв." }
|
||||
: placeholder("предв.");
|
||||
|
||||
const overall =
|
||||
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
|
||||
|
||||
// Buy-signal is derived from the deficit_index alone, so it surfaces whenever
|
||||
// the report is ready — independent of whether the overall score is present.
|
||||
const buySignal = report
|
||||
? buySignalFromForecast(report)
|
||||
: placeholder("после прогноза (Scenarios)");
|
||||
|
||||
if (report && overall != null) {
|
||||
// overall_score ∈ 0..1 → /10 scale (one decimal), advisory.
|
||||
const scoreOn10 = real(fmtNum(overall * 10, 1));
|
||||
scoreOn10.caption = "advisory · §22";
|
||||
return {
|
||||
score: scoreOn10,
|
||||
potential: placeholder("после финмодели"),
|
||||
risk: riskField,
|
||||
buySignal,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
score: placeholder("после прогноза (Scenarios)"),
|
||||
potential: placeholder("после прогноза (Scenarios)"),
|
||||
risk:
|
||||
risk.value != null
|
||||
? { value: risk.label, isReal: false, caption: "предв." }
|
||||
: placeholder("предв."),
|
||||
buySignal: placeholder("после прогноза (Scenarios)"),
|
||||
risk: riskField,
|
||||
buySignal,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ConfidencePanel — 6.3 «Уверенность прогноза». An overall conf-pill (from
|
||||
* confidence.level) + a row per confidence.factors entry, each with a high/mid/
|
||||
* low pill. The factors map carries a stray non-factor boolean (advisory_capped)
|
||||
* in prod envelopes — narrowed out via a runtime guard (mirrors the light block).
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ConfidenceFactor, ReportConfidence } from "@/types/forecast";
|
||||
import { CONFIDENCE_RU, confidencePill } from "./scenario-helpers";
|
||||
|
||||
interface Props {
|
||||
confidence: ReportConfidence;
|
||||
}
|
||||
|
||||
const FACTOR_RU: Record<string, string> = {
|
||||
deal_count: "Объём данных по сделкам",
|
||||
analog_count: "Аналоги (ЖК)",
|
||||
domrf_coverage: "Покрытие ДОМ.РФ",
|
||||
history_months: "Глубина истории",
|
||||
component: "Компонент",
|
||||
};
|
||||
|
||||
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
const PILL_CLASS = {
|
||||
high: styles.confPillHigh,
|
||||
medium: styles.confPillMedium,
|
||||
low: styles.confPillLow,
|
||||
} as const;
|
||||
|
||||
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.note === "string" &&
|
||||
(o.level === "high" || o.level === "medium" || o.level === "low")
|
||||
);
|
||||
}
|
||||
|
||||
function factorLabel(key: string, factor: ConfidenceFactor): string {
|
||||
if (factor.label) return factor.label;
|
||||
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
||||
const m = key.match(/^component(?:_(\d+))?$/);
|
||||
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
|
||||
return key;
|
||||
}
|
||||
|
||||
export function ConfidencePanel({ confidence }: Props) {
|
||||
const level = confidence.level;
|
||||
const drivers = Object.entries(confidence.factors)
|
||||
.filter((entry): entry is [string, ConfidenceFactor] =>
|
||||
isConfidenceFactor(entry[1]),
|
||||
)
|
||||
.sort((a, b) => LEVEL_RANK[a[1].level] - LEVEL_RANK[b[1].level]);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Уверенность прогноза</h3>
|
||||
{level && (
|
||||
<span
|
||||
className={`${styles.confPill} ${PILL_CLASS[confidencePill(level)]}`}
|
||||
>
|
||||
{CONFIDENCE_RU[level]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{drivers.length > 0 ? (
|
||||
drivers.map(([key, factor]) => (
|
||||
<div key={key} className={styles.confRow}>
|
||||
<span className={styles.confName}>{factorLabel(key, factor)}</span>
|
||||
<span
|
||||
className={`${styles.confPill} ${PILL_CLASS[confidencePill(factor.level)]}`}
|
||||
>
|
||||
{CONFIDENCE_RU[factor.level]}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.panelHint}>Факторы достоверности недоступны.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* HorizonForecast — 6.1 «Прогноз по горизонтам».
|
||||
*
|
||||
* Left: a demand-vs-supply line chart (inline SVG, two polylines) over
|
||||
* future_market.forecasts_by_horizon — mirrors the prototype's renderLineCharts
|
||||
* (no chart lib; the dark .chart tokens drive the look). Right: a compact
|
||||
* horizons table (горизонт · спрос · предложение · дефицит).
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { DemandSupplyForecast } from "@/types/forecast";
|
||||
import { deficitTone, fmtInt, fmtSignedDeficit } from "./scenario-helpers";
|
||||
|
||||
interface Props {
|
||||
forecasts: DemandSupplyForecast[];
|
||||
targetHorizon: number;
|
||||
/** Segment caption (e.g. "Комфорт · 1–2К") — shown under the table when known. */
|
||||
segmentLabel: string | null;
|
||||
}
|
||||
|
||||
const CHART_W = 600;
|
||||
const CHART_H = 180;
|
||||
const CHART_PAD = 28;
|
||||
|
||||
const DEFICIT_TONE_CLASS = {
|
||||
good: styles.numPos,
|
||||
bad: styles.numNeg,
|
||||
warn: styles.numNeg,
|
||||
neutral: "",
|
||||
} as const;
|
||||
|
||||
interface ChartGeometry {
|
||||
gridLines: number[];
|
||||
demandPath: string;
|
||||
supplyPath: string;
|
||||
xLabels: { x: number; text: string }[];
|
||||
}
|
||||
|
||||
function buildChart(rows: DemandSupplyForecast[]): ChartGeometry | null {
|
||||
if (rows.length < 2) return null;
|
||||
const demand = rows.map((r) => r.projected_demand_units);
|
||||
const supply = rows.map((r) => r.projected_supply_units);
|
||||
const all = [...demand, ...supply, 0];
|
||||
const min = Math.min(...all);
|
||||
const max = Math.max(...all);
|
||||
const span = max - min || 1;
|
||||
const x = (i: number, n: number) =>
|
||||
CHART_PAD + (i * (CHART_W - 2 * CHART_PAD)) / (n - 1);
|
||||
const y = (v: number) =>
|
||||
CHART_H - CHART_PAD - ((v - min) / span) * (CHART_H - 2 * CHART_PAD);
|
||||
const toPath = (arr: number[]) =>
|
||||
arr
|
||||
.map(
|
||||
(v, i) =>
|
||||
`${i ? "L" : "M"}${x(i, arr.length).toFixed(1)},${y(v).toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
const gridLines: number[] = [];
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
gridLines.push(CHART_PAD + (g * (CHART_H - 2 * CHART_PAD)) / 4);
|
||||
}
|
||||
|
||||
const xLabels = rows.map((r, i) => ({
|
||||
x: x(i, rows.length),
|
||||
text: `${r.horizon_months}м`,
|
||||
}));
|
||||
|
||||
return {
|
||||
gridLines,
|
||||
demandPath: toPath(demand),
|
||||
supplyPath: toPath(supply),
|
||||
xLabels,
|
||||
};
|
||||
}
|
||||
|
||||
export function HorizonForecast({
|
||||
forecasts,
|
||||
targetHorizon,
|
||||
segmentLabel,
|
||||
}: Props) {
|
||||
const rows = useMemo(
|
||||
() => [...forecasts].sort((a, b) => a.horizon_months - b.horizon_months),
|
||||
[forecasts],
|
||||
);
|
||||
const chart = useMemo(() => buildChart(rows), [rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||||
Прогноз по горизонтам недоступен.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>
|
||||
6.1 · <b>прогноз по горизонтам</b>
|
||||
</div>
|
||||
<section className={styles.forecastGrid}>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Спрос vs предложение</h3>
|
||||
<div className={styles.legendInline}>
|
||||
<span>
|
||||
<i className={styles.legendDemand} />
|
||||
Спрос
|
||||
</span>
|
||||
<span>
|
||||
<i className={styles.legendSupply} />
|
||||
Предложение
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{chart ? (
|
||||
<div className={styles.chart}>
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Прогноз спроса и предложения по горизонтам, квартир"
|
||||
>
|
||||
{chart.gridLines.map((gy, i) => (
|
||||
<line
|
||||
key={i}
|
||||
className={styles.chartGrid}
|
||||
x1={CHART_PAD}
|
||||
y1={gy}
|
||||
x2={CHART_W - CHART_PAD}
|
||||
y2={gy}
|
||||
/>
|
||||
))}
|
||||
<path className={styles.chartDemand} d={chart.demandPath} />
|
||||
<path className={styles.chartSupply} d={chart.supplyPath} />
|
||||
{chart.xLabels.map((l, i) => (
|
||||
<text
|
||||
key={i}
|
||||
className={styles.chartLbl}
|
||||
x={l.x.toFixed(1)}
|
||||
y={CHART_H - 8}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
Недостаточно точек для графика
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>По горизонтам</h3>
|
||||
</div>
|
||||
<table className={styles.dtable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Горизонт</th>
|
||||
<th className={styles.thNum}>Спрос</th>
|
||||
<th className={styles.thNum}>Предлож.</th>
|
||||
<th className={styles.thNum}>Дефицит</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((f) => {
|
||||
const isTarget = f.horizon_months === targetHorizon;
|
||||
const tone = deficitTone(f.deficit_index);
|
||||
return (
|
||||
<tr
|
||||
key={f.horizon_months}
|
||||
className={isTarget ? styles.rowTarget : undefined}
|
||||
>
|
||||
<td>
|
||||
{f.horizon_months} мес
|
||||
{isTarget && (
|
||||
<span className={styles.targetTag}>целевой</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{fmtInt(f.projected_demand_units)}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{fmtInt(f.projected_supply_units)}
|
||||
</td>
|
||||
<td
|
||||
className={`${styles.tdNum} ${DEFICIT_TONE_CLASS[tone]}`}
|
||||
>
|
||||
{fmtSignedDeficit(f.deficit_index)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{segmentLabel && (
|
||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||
<span className={styles.scK}>Сегмент</span>
|
||||
<span className={styles.scV}>{segmentLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaScenarios — root of the ПТИЦА dark «Сценарии» tab (§22 Прогноз).
|
||||
*
|
||||
* Reads the REAL async forecast via useParcelForecastQuery(cad). The forecast is
|
||||
* enqueued fire-and-forget by /analyze on page mount — this hook only polls. While
|
||||
* the envelope is `pending` (or the first request is loading) we render a CALM
|
||||
* skeleton («Прогноз считается…»), NOT an error: the poll resolves once the report
|
||||
* is assembled. Once `report` is present we compose the sub-blocks:
|
||||
* 3 scenario cards · горизонты (chart + table) · сравнение · уверенность +
|
||||
* рекомендованный продукт · прозрачность скоринга.
|
||||
*
|
||||
* The horizon (6/12/18 мес) selector lives in this tab's header; its state is
|
||||
* lifted to PticaPageContent so the hero/other tabs can share it.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||||
import type { ForecastReport, ForecastSegment } from "@/types/forecast";
|
||||
import { ScenarioCards } from "./ScenarioCards";
|
||||
import { HorizonForecast } from "./HorizonForecast";
|
||||
import { ScenarioCompareTable } from "./ScenarioCompareTable";
|
||||
import { ConfidencePanel } from "./ConfidencePanel";
|
||||
import { RecommendedProduct } from "./RecommendedProduct";
|
||||
import { ScoringTransparency } from "./ScoringTransparency";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
horizon: number;
|
||||
onHorizonChange: (h: number) => void;
|
||||
}
|
||||
|
||||
const HORIZONS = [6, 12, 18];
|
||||
|
||||
function segmentLabel(segment: ForecastSegment | undefined): string | null {
|
||||
if (!segment) return null;
|
||||
const parts = [segment.obj_class, segment.room_bucket].filter(
|
||||
(p): p is string => !!p,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function PticaScenarios({ cad, horizon, onHorizonChange }: Props) {
|
||||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||||
|
||||
const header = (
|
||||
<div className={styles.viewHead}>
|
||||
<h2>
|
||||
Сценарии{" "}
|
||||
<span className={styles.viewSub}>
|
||||
прогноз спроса и предложения · §22
|
||||
</span>
|
||||
</h2>
|
||||
<div className={styles.horizonSel}>
|
||||
<span className={styles.horizonLab}>Горизонт</span>
|
||||
<div className={styles.seg} role="group" aria-label="Горизонт прогноза">
|
||||
{HORIZONS.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
className={h === horizon ? styles.segActive : undefined}
|
||||
aria-pressed={h === horizon}
|
||||
onClick={() => onHorizonChange(h)}
|
||||
>
|
||||
{h} мес
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Pending / loading → calm skeleton (NOT an error) ─────────────────────────
|
||||
const report: ForecastReport | undefined =
|
||||
data?.status === "ready" ? data.report : undefined;
|
||||
const pending = isLoading || (!report && !error);
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className={styles.scenariosRoot}>
|
||||
{header}
|
||||
<div className={`${styles.panel} ${styles.forecastPending}`}>
|
||||
<div className={styles.pendingPulse} aria-hidden="true" />
|
||||
<div className={styles.pendingTitle}>Прогноз считается…</div>
|
||||
<p className={styles.pendingHint}>
|
||||
Сценарный прогноз §22 формируется в фоне после запуска анализа.
|
||||
Обычно занимает 1–2 минуты — обновится автоматически.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error / no report → honest empty panel ───────────────────────────────────
|
||||
if (error || !report) {
|
||||
return (
|
||||
<div className={styles.scenariosRoot}>
|
||||
{header}
|
||||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||||
Прогноз §22 недоступен для этого участка.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fm = report.future_market;
|
||||
const targetHorizon = horizon;
|
||||
|
||||
return (
|
||||
<div className={styles.scenariosRoot}>
|
||||
{header}
|
||||
|
||||
<ScenarioCards
|
||||
scenarios={report.scenarios}
|
||||
scenariosSummary={fm.scenarios_summary}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
|
||||
<HorizonForecast
|
||||
forecasts={fm.forecasts_by_horizon}
|
||||
targetHorizon={targetHorizon}
|
||||
segmentLabel={segmentLabel(report.meta.segment)}
|
||||
/>
|
||||
|
||||
<ScenarioCompareTable
|
||||
scenarios={report.scenarios}
|
||||
scenariosSummary={fm.scenarios_summary}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
|
||||
<div className={styles.sectionLabel}>
|
||||
6.3 · <b>уверенность</b> · 6.4 · <b>рекомендация по продукту</b>
|
||||
</div>
|
||||
<section className={styles.forecastGrid}>
|
||||
<ConfidencePanel confidence={report.confidence} />
|
||||
{report.product_tz ? (
|
||||
<RecommendedProduct
|
||||
productTz={report.product_tz}
|
||||
successScore={report.scoring?.overall ?? null}
|
||||
targetPrice={null}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||||
Рекомендация по продукту недоступна.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{report.scoring && <ScoringTransparency scoring={report.scoring} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
"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 (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Рекомендованный продукт</h3>
|
||||
{productTz.obj_class && (
|
||||
<span className={styles.tag}>{capitalize(productTz.obj_class)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bars.length > 0 && (
|
||||
<div className={styles.hbars}>
|
||||
{bars.map((b) => (
|
||||
<div key={b.key} className={styles.hbar}>
|
||||
<span className={styles.hbarName}>{b.label}</span>
|
||||
<div className={styles.hbarTrack}>
|
||||
<div
|
||||
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
|
||||
style={{ width: `${b.widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.hbarValue}>{b.valueStr}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!byShare && bars.length > 0 && (
|
||||
<p className={styles.panelHint}>
|
||||
Длина полосы — сила сигнала по индексу дефицита формата: зелёный —
|
||||
недонасыщенность, красный — затоварка.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{targetPrice && (
|
||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||
<span className={styles.scK}>Целевая цена</span>
|
||||
<span className={styles.scV}>{targetPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Success-score формата</span>
|
||||
<span className={styles.scV}>
|
||||
{successScore != null ? fmtNum(successScore, 2) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ScenarioCards — 3 dark cockpit cards (base / aggressive / conservative) from
|
||||
* report.scenarios.by_scenario. Each card shows the deficit-index (big, signed,
|
||||
* colour by sign) at the target horizon, the demand/supply projection, and the
|
||||
* months-of-inventory (MOI). Mirrors the §22 light ScenariosBlock field mapping.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ReportScenarios, ScenariosSummary } from "@/types/forecast";
|
||||
import {
|
||||
DEFICIT_BALANCE_EPS,
|
||||
SCENARIO_ACCENT,
|
||||
SCENARIO_HINT,
|
||||
SCENARIO_ORDER,
|
||||
SCENARIO_RU,
|
||||
deficitForScenario,
|
||||
deficitTone,
|
||||
deficitWord,
|
||||
fmtInt,
|
||||
fmtNum,
|
||||
fmtSignedDeficit,
|
||||
pickHorizonForecast,
|
||||
} from "./scenario-helpers";
|
||||
|
||||
interface Props {
|
||||
scenarios: ReportScenarios;
|
||||
scenariosSummary: ScenariosSummary | null;
|
||||
targetHorizon: number;
|
||||
}
|
||||
|
||||
const DEFICIT_TONE_CLASS = {
|
||||
good: styles.deficitPos,
|
||||
bad: styles.deficitNeg,
|
||||
warn: styles.deficitNeg,
|
||||
neutral: styles.deficitFlat,
|
||||
} as const;
|
||||
|
||||
const ACCENT_CLASS = {
|
||||
base: styles.scAccentBase,
|
||||
aggr: styles.scAccentAggr,
|
||||
cons: styles.scAccentCons,
|
||||
} as const;
|
||||
|
||||
export function ScenarioCards({
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
}: Props) {
|
||||
const present = SCENARIO_ORDER.filter(
|
||||
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
|
||||
);
|
||||
|
||||
if (present.length === 0) {
|
||||
return (
|
||||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||||
Сценарный прогноз недоступен.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.scenarioGrid}>
|
||||
{present.map((key) => {
|
||||
const sc = scenarios.by_scenario[key];
|
||||
const di = deficitForScenario(
|
||||
key,
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
);
|
||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||
const labelHorizon = di.horizon ?? targetHorizon;
|
||||
const value = di.value;
|
||||
const tone = value != null ? deficitTone(value) : "neutral";
|
||||
const word =
|
||||
value != null
|
||||
? Math.abs(value) < DEFICIT_BALANCE_EPS
|
||||
? "баланс"
|
||||
: deficitWord(value)
|
||||
: "нет данных";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[key]]}`}
|
||||
>
|
||||
<div className={styles.scTag}>{SCENARIO_RU[key]}</div>
|
||||
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
|
||||
{value != null ? fmtSignedDeficit(value) : "—"}
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>
|
||||
Дефицит-индекс ({labelHorizon} мес)
|
||||
</span>
|
||||
<span className={styles.scV}>{word}</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз спроса</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз предложения</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Месяцев запаса (MOI)</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scNote}>{SCENARIO_HINT[key]}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ScenarioCompareTable — 6.2 «Сравнение сценариев». One row per scenario
|
||||
* (base / aggressive / conservative): deficit-index · demand · supply · вывод.
|
||||
* Reads the same by_scenario forecasts as ScenarioCards at the target horizon.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type {
|
||||
ScenarioKey,
|
||||
ReportScenarios,
|
||||
ScenariosSummary,
|
||||
} from "@/types/forecast";
|
||||
import {
|
||||
SCENARIO_ORDER,
|
||||
deficitForScenario,
|
||||
deficitTone,
|
||||
deficitVerdict,
|
||||
fmtInt,
|
||||
fmtSignedDeficit,
|
||||
pickHorizonForecast,
|
||||
} from "./scenario-helpers";
|
||||
|
||||
interface Props {
|
||||
scenarios: ReportScenarios;
|
||||
scenariosSummary: ScenariosSummary | null;
|
||||
targetHorizon: number;
|
||||
}
|
||||
|
||||
const SCENARIO_SHORT: Record<ScenarioKey, string> = {
|
||||
base: "Базовый",
|
||||
aggressive: "Агрессивный",
|
||||
conservative: "Консервативный",
|
||||
};
|
||||
|
||||
const DEFICIT_TONE_CLASS = {
|
||||
good: styles.numPos,
|
||||
bad: styles.numNeg,
|
||||
warn: styles.numNeg,
|
||||
neutral: "",
|
||||
} as const;
|
||||
|
||||
export function ScenarioCompareTable({
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
}: Props) {
|
||||
const present = SCENARIO_ORDER.filter(
|
||||
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>
|
||||
6.2 · <b>сравнение сценариев</b>
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<table className={styles.dtable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Сценарий</th>
|
||||
<th className={styles.thNum}>Дефицит-индекс</th>
|
||||
<th className={styles.thNum}>Спрос</th>
|
||||
<th className={styles.thNum}>Предлож.</th>
|
||||
<th>Вывод</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{present.map((key) => {
|
||||
const sc = scenarios.by_scenario[key];
|
||||
const di = deficitForScenario(
|
||||
key,
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
);
|
||||
const fc = sc
|
||||
? pickHorizonForecast(sc.forecasts, targetHorizon)
|
||||
: null;
|
||||
const value = di.value;
|
||||
const tone = value != null ? deficitTone(value) : "neutral";
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{SCENARIO_SHORT[key]}</td>
|
||||
<td className={`${styles.tdNum} ${DEFICIT_TONE_CLASS[tone]}`}>
|
||||
{value != null ? fmtSignedDeficit(value) : "—"}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{fc ? fmtInt(fc.projected_demand_units) : "—"}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{fc ? fmtInt(fc.projected_supply_units) : "—"}
|
||||
</td>
|
||||
<td>{value != null ? deficitVerdict(value) : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ScoringTransparency — 6.5 «Прозрачность скоринга». §13.6 scoring.product_scores:
|
||||
* one row per §14.2 product score — factor (RU label) · вес (value ∈ 0..1) ·
|
||||
* направление. Score semantics (HARD): value ∈ [0,1], выше = лучше для девелопера
|
||||
* (risk scores pre-inverted at the source), anchored on 0.5 = balance. So the
|
||||
* direction reads off the 0.5 midpoint: ≥0.55 «сильный» / 0.45–0.55 «нейтральный»
|
||||
* / <0.45 «слабый». Rows with value=null (thin data) render «нет данных».
|
||||
*
|
||||
* GRACEFUL: returns null when scoring carries no product scores.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type {
|
||||
ProductScore,
|
||||
ProductScoreCard,
|
||||
ReportScoring,
|
||||
} from "@/types/forecast";
|
||||
import { fmtNum } from "./scenario-helpers";
|
||||
|
||||
interface Props {
|
||||
scoring: ReportScoring;
|
||||
}
|
||||
|
||||
const SCORE_GOOD_THRESHOLD = 0.55;
|
||||
const SCORE_WEAK_THRESHOLD = 0.45;
|
||||
|
||||
const PRODUCT_SCORE_RU: Record<string, string> = {
|
||||
market_fit: "Соответствие рынку",
|
||||
demand: "Спрос",
|
||||
supply_risk: "Риск избытка предложения",
|
||||
future_competition: "Будущая конкуренция",
|
||||
price_feasibility: "Доступность цены",
|
||||
infra_fit: "Инфраструктура",
|
||||
mortgage_sensitivity: "Чувствительность к ставке",
|
||||
differentiation: "Дифференциация",
|
||||
commercial: "Коммерция",
|
||||
confidence: "Надёжность данных",
|
||||
};
|
||||
|
||||
interface Direction {
|
||||
text: string;
|
||||
toneClass: string;
|
||||
}
|
||||
|
||||
function scoreDirection(value: number | null): Direction {
|
||||
if (value == null) return { text: "—", toneClass: "" };
|
||||
if (value >= SCORE_GOOD_THRESHOLD)
|
||||
return { text: "сигнал ↑", toneClass: styles.numPos };
|
||||
if (value >= SCORE_WEAK_THRESHOLD)
|
||||
return { text: "нейтрально →", toneClass: "" };
|
||||
return { text: "сигнал ↓", toneClass: styles.numNeg };
|
||||
}
|
||||
|
||||
function scoreRows(card: ProductScoreCard | null): ProductScore[] {
|
||||
if (card == null) return [];
|
||||
return Object.values(card.scores).filter(
|
||||
(s): s is ProductScore => s != null && typeof s.key === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoringTransparency({ scoring }: Props) {
|
||||
const scores = scoreRows(scoring.product_scores);
|
||||
if (scores.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>
|
||||
6.5 · <b>прозрачность скоринга</b>
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<table className={styles.dtable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Фактор прогноза</th>
|
||||
<th className={styles.thNum}>Вес</th>
|
||||
<th>Направление</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scores.map((s) => {
|
||||
const dir = scoreDirection(s.value);
|
||||
return (
|
||||
<tr key={s.key}>
|
||||
<td title={s.reason || undefined}>
|
||||
{PRODUCT_SCORE_RU[s.key] ?? s.key}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{s.value != null ? fmtNum(s.value, 2) : "нет данных"}
|
||||
</td>
|
||||
<td className={dir.toneClass}>{dir.text}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className={styles.panelHint}>
|
||||
Вес — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры
|
||||
уже инвертированы). Направление — относительно баланса 0.5.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* scenario-helpers — framework-agnostic mapping helpers for the ПТИЦА dark
|
||||
* «Сценарии» tab. Shares the deficit semantics + RU formatting used by the light
|
||||
* Section 6 blocks (single source of truth = forecast-helpers), re-expressed here
|
||||
* with dark-cockpit "tone" classes (good/warn/bad) instead of light BadgeVariant.
|
||||
*
|
||||
* Deficit semantics (HARD): >0 недонасыщенность (повод строить),
|
||||
* <0 затоварка, ≈0 баланс. Mirrors forecast-helpers.deficitWord.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DemandSupplyForecast,
|
||||
ReportScenarios,
|
||||
ScenarioKey,
|
||||
ScenariosSummary,
|
||||
} from "@/types/forecast";
|
||||
|
||||
/** Below this |deficit_index| the market reads as balanced (neutral tone). */
|
||||
export const DEFICIT_BALANCE_EPS = 0.05;
|
||||
|
||||
/** Dark cockpit severity tone — drives the accent colour via CSS classes. */
|
||||
export type Tone = "good" | "warn" | "bad" | "neutral";
|
||||
|
||||
/** deficit_index → tone (>0 good / <0 bad / ≈0 neutral). */
|
||||
export function deficitTone(deficitIndex: number): Tone {
|
||||
if (deficitIndex > DEFICIT_BALANCE_EPS) return "good";
|
||||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/** deficit_index → RU word (недонасыщенность / затоварка / баланс). */
|
||||
export function deficitWord(deficitIndex: number): string {
|
||||
if (deficitIndex > DEFICIT_BALANCE_EPS) return "недонасыщенность";
|
||||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "затоварка";
|
||||
return "баланс";
|
||||
}
|
||||
|
||||
/** Short verdict for the compare table «Вывод» column. */
|
||||
export function deficitVerdict(deficitIndex: number): string {
|
||||
if (deficitIndex > 0.25) return "Сильный дефицит — премия к цене";
|
||||
if (deficitIndex > DEFICIT_BALANCE_EPS)
|
||||
return "Недонасыщенность — окно для выхода";
|
||||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "Затоварка — сдерживать темп";
|
||||
return "Баланс — контроль темпа продаж";
|
||||
}
|
||||
|
||||
// ── Confidence ──────────────────────────────────────────────────────────────
|
||||
|
||||
import type { ConfidenceLevel } from "@/types/forecast";
|
||||
|
||||
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
||||
high: "высокая",
|
||||
medium: "средняя",
|
||||
low: "низкая",
|
||||
};
|
||||
|
||||
/** ConfidenceLevel → conf-pill modifier class key (high/medium/low). */
|
||||
export function confidencePill(
|
||||
level: ConfidenceLevel,
|
||||
): "high" | "medium" | "low" {
|
||||
return level;
|
||||
}
|
||||
|
||||
// ── Number formatting (ru typography) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fixed-decimal RU number. Normalises the leading ASCII minus to the Unicode
|
||||
* «−» (U+2212) per ui-microcopy. `.replace` hits only the leading sign (RU
|
||||
* grouping uses NBSP, not "-"), so the numeric part is untouched.
|
||||
*/
|
||||
export function fmtNum(value: number, digits = 1): string {
|
||||
return value
|
||||
.toLocaleString("ru", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
.replace("-", "−");
|
||||
}
|
||||
|
||||
/** Signed deficit_index, e.g. «+0.18» / «−1.00» (Unicode minus). */
|
||||
export function fmtSignedDeficit(deficitIndex: number, digits = 2): string {
|
||||
const body = fmtNum(deficitIndex, digits);
|
||||
return deficitIndex > 0 ? `+${body}` : body;
|
||||
}
|
||||
|
||||
/** Round to integer with RU thousands grouping (квартиры). */
|
||||
export function fmtInt(value: number): string {
|
||||
return Math.round(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
// ── Scenario selection ────────────────────────────────────────────────────────
|
||||
|
||||
/** Display order: base → aggressive → conservative (own → best → worst). */
|
||||
export const SCENARIO_ORDER: ScenarioKey[] = [
|
||||
"base",
|
||||
"aggressive",
|
||||
"conservative",
|
||||
];
|
||||
|
||||
export const SCENARIO_RU: Record<ScenarioKey, string> = {
|
||||
base: "Базовый сценарий",
|
||||
aggressive: "Агрессивный",
|
||||
conservative: "Консервативный",
|
||||
};
|
||||
|
||||
export const SCENARIO_HINT: Record<ScenarioKey, string> = {
|
||||
base: "ставка без изменений",
|
||||
aggressive: "ставка ниже",
|
||||
conservative: "ставка выше",
|
||||
};
|
||||
|
||||
/** Border-top accent class key per scenario (base blue / aggr green / cons yellow). */
|
||||
export const SCENARIO_ACCENT: Record<ScenarioKey, "base" | "aggr" | "cons"> = {
|
||||
base: "base",
|
||||
aggressive: "aggr",
|
||||
conservative: "cons",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the forecast row at the target horizon, falling back to the longest
|
||||
* available horizon (mirrors ScenariosBlock.pickHorizonForecast).
|
||||
*/
|
||||
export function pickHorizonForecast(
|
||||
forecasts: DemandSupplyForecast[],
|
||||
targetHorizon: number,
|
||||
): DemandSupplyForecast | null {
|
||||
if (forecasts.length === 0) return null;
|
||||
const exact = forecasts.find((f) => f.horizon_months === targetHorizon);
|
||||
if (exact) return exact;
|
||||
return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0];
|
||||
}
|
||||
|
||||
/** The deficit_index for a scenario at the target horizon (+ the horizon used). */
|
||||
export interface ScenarioDeficit {
|
||||
value: number | null;
|
||||
horizon: number | null;
|
||||
}
|
||||
|
||||
export function deficitForScenario(
|
||||
key: ScenarioKey,
|
||||
scenarios: ReportScenarios,
|
||||
scenariosSummary: ScenariosSummary | null,
|
||||
targetHorizon: number,
|
||||
): ScenarioDeficit {
|
||||
const sc = scenarios.by_scenario[key];
|
||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||
if (fc) return { value: fc.deficit_index, horizon: fc.horizon_months };
|
||||
if (scenariosSummary)
|
||||
return { value: scenariosSummary[key], horizon: targetHorizon };
|
||||
return { value: null, horizon: null };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue