feat(site-finder): продуктовые решения по фидбеку analyze (#1741-1745) #1749
16 changed files with 1206 additions and 235 deletions
|
|
@ -525,6 +525,9 @@ def _demand_supply_overlay(
|
||||||
"obj_class": seg.segment.get("obj_class"),
|
"obj_class": seg.segment.get("obj_class"),
|
||||||
"deficit_index": seg.deficit_index,
|
"deficit_index": seg.deficit_index,
|
||||||
"balance_units": seg.balance_units,
|
"balance_units": seg.balance_units,
|
||||||
|
# #1745: спрос на горизонт (квартир) — для таблицы прогноза по форматам.
|
||||||
|
# demand_only его не несёт (без геометрии supply/спрос-проекция неизмеримы).
|
||||||
|
"projected_demand_units": seg.projected_demand_units,
|
||||||
"confidence": seg.confidence,
|
"confidence": seg.confidence,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ _LEVEL_RU: dict[ReportConfidenceLevel, str] = {
|
||||||
# special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]).
|
# special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]).
|
||||||
_STRONG_DEFICIT_THRESHOLD: float = 0.25
|
_STRONG_DEFICIT_THRESHOLD: float = 0.25
|
||||||
|
|
||||||
|
# Нейтральная зона |deficit_index|, внутри которой рынок считаем сбалансированным
|
||||||
|
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
||||||
|
_SIGNAL_BALANCE_EPS: float = 0.05
|
||||||
|
|
||||||
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
@ -479,12 +483,16 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
|
||||||
Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) —
|
Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) —
|
||||||
продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем
|
продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем
|
||||||
ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни
|
ranked_segments в компактные {bucket, obj_class, deficit_index, projected_demand_units,
|
||||||
другого → [].
|
signal} (#1745 — таблица прогноза по форматам). Нет ни того, ни другого → [].
|
||||||
|
|
||||||
|
#1745 ADDITIVE: проброс `projected_demand_units` (прогноз спроса, квартир) и
|
||||||
|
деривированного `signal` («строить»/«баланс»/«избегать») из deficit_index. Старые
|
||||||
|
потребители mix читают bucket/obj_class/deficit_index — новые поля их не ломают.
|
||||||
"""
|
"""
|
||||||
explicit = product_tz.get("mix")
|
explicit = product_tz.get("mix")
|
||||||
if isinstance(explicit, list):
|
if isinstance(explicit, list):
|
||||||
return [m for m in explicit if isinstance(m, dict)]
|
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
|
||||||
ranked = product_tz.get("ranked_segments")
|
ranked = product_tz.get("ranked_segments")
|
||||||
if isinstance(ranked, list):
|
if isinstance(ranked, list):
|
||||||
return [
|
return [
|
||||||
|
|
@ -492,6 +500,8 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"bucket": seg.get("bucket"),
|
"bucket": seg.get("bucket"),
|
||||||
"obj_class": seg.get("obj_class"),
|
"obj_class": seg.get("obj_class"),
|
||||||
"deficit_index": seg.get("deficit_index"),
|
"deficit_index": seg.get("deficit_index"),
|
||||||
|
"projected_demand_units": seg.get("projected_demand_units"),
|
||||||
|
"signal": _build_signal(seg.get("deficit_index")),
|
||||||
}
|
}
|
||||||
for seg in ranked
|
for seg in ranked
|
||||||
if isinstance(seg, dict)
|
if isinstance(seg, dict)
|
||||||
|
|
@ -499,6 +509,35 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
|
||||||
|
|
||||||
|
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
||||||
|
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
||||||
|
вход — assembler-чистота).
|
||||||
|
"""
|
||||||
|
enriched = dict(entry)
|
||||||
|
if enriched.get("signal") is None:
|
||||||
|
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signal(deficit_index: Any) -> str | None:
|
||||||
|
"""Сигнал «строить»/«баланс»/«избегать» из deficit_index ∈ [−1,1] (#1745). PURE.
|
||||||
|
|
||||||
|
Зеркало deficit-семантики §22 (frontend forecast-helpers.deficitWord): >+0.05
|
||||||
|
недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс».
|
||||||
|
deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал).
|
||||||
|
"""
|
||||||
|
if not isinstance(deficit_index, (int, float)) or isinstance(deficit_index, bool):
|
||||||
|
return None
|
||||||
|
if deficit_index > _SIGNAL_BALANCE_EPS:
|
||||||
|
return "строить"
|
||||||
|
if deficit_index < -_SIGNAL_BALANCE_EPS:
|
||||||
|
return "избегать"
|
||||||
|
return "баланс"
|
||||||
|
|
||||||
|
|
||||||
def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||||
"""Достать список dict'ов по ключу (мусор/None → []). PURE."""
|
"""Достать список dict'ов по ключу (мусор/None → []). PURE."""
|
||||||
value = source.get(key)
|
value = source.get(key)
|
||||||
|
|
|
||||||
|
|
@ -99,12 +99,14 @@ class RankedSegment:
|
||||||
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
|
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
|
||||||
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
||||||
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
|
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
|
||||||
|
projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"segment": dict(self.segment),
|
"segment": dict(self.segment),
|
||||||
"deficit_index": _round_or_none(self.deficit_index, 3),
|
"deficit_index": _round_or_none(self.deficit_index, 3),
|
||||||
"balance_units": _round_or_none(self.balance_units, 1),
|
"balance_units": _round_or_none(self.balance_units, 1),
|
||||||
|
"projected_demand_units": _round_or_none(self.projected_demand_units, 1),
|
||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,4 +361,5 @@ def _forecast_cell(
|
||||||
deficit_index=forecast.deficit_index,
|
deficit_index=forecast.deficit_index,
|
||||||
balance_units=forecast.balance_units,
|
balance_units=forecast.balance_units,
|
||||||
confidence=forecast.confidence,
|
confidence=forecast.confidence,
|
||||||
|
projected_demand_units=forecast.projected_demand_units,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ChatPanel } from "@/components/site-finder/ChatPanel";
|
import { ChatPanel } from "@/components/site-finder/ChatPanel";
|
||||||
|
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
||||||
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
||||||
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
||||||
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
||||||
|
|
@ -160,22 +161,48 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
|
|
||||||
{/* ── Main scroll area ──────────────────────────────────────────── */}
|
{/* ── Main scroll area ──────────────────────────────────────────── */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
||||||
{/* Section 1 — IMPLEMENTED in A5 */}
|
{/* Вердикт-баннер участка — выше всех секций (#1741). */}
|
||||||
|
<GateVerdictBanner verdict={analysis.gate_verdict} />
|
||||||
|
|
||||||
|
{/* ── Группа «Участок» ──────────────────────────────────────── */}
|
||||||
|
<GroupDivider label="Участок" />
|
||||||
|
|
||||||
|
{/* 1. Информация — IMPLEMENTED in A5 */}
|
||||||
<Section1ParcelInfo cad={cad} />
|
<Section1ParcelInfo cad={cad} />
|
||||||
|
|
||||||
{/* Section 2 — IMPLEMENTED in A6 */}
|
{/* 2. Оценка участка — IMPLEMENTED in A10 */}
|
||||||
<Section2NetworksUtilities cad={cad} />
|
|
||||||
|
|
||||||
{/* Section 3 — IMPLEMENTED in A7 */}
|
|
||||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
|
||||||
|
|
||||||
{/* Section 4 — IMPLEMENTED in A10 */}
|
|
||||||
<Section4Estimate cad={cad} />
|
<Section4Estimate cad={cad} />
|
||||||
|
|
||||||
{/* Section 5 — IMPLEMENTED in A11 */}
|
{/* 3. Сети и точки подключения — IMPLEMENTED in A6 */}
|
||||||
|
<Section2NetworksUtilities cad={cad} />
|
||||||
|
|
||||||
|
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||||
|
<GroupDivider label="Стройка и рынок" />
|
||||||
|
|
||||||
|
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
||||||
|
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||||
|
|
||||||
|
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||||
<Section5Atmosphere cad={cad} />
|
<Section5Atmosphere cad={cad} />
|
||||||
|
|
||||||
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
{/* Статус пересчёта прогноза при смене горизонта (#1744) —
|
||||||
|
нейтральная серая строка вместо молчаливого disabled. */}
|
||||||
|
{isFetching && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: "20px",
|
||||||
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
transition: "opacity 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Пересчитываем прогноз на {horizon} мес…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
||||||
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
||||||
|
|
||||||
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
|
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
|
||||||
|
|
@ -245,32 +272,85 @@ function Breadcrumb({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar nav ────────────────────────────────────────────────────────────────
|
// ── Group divider ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон
|
||||||
|
// --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar.
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
function GroupDivider({ label }: { label: string }) {
|
||||||
{ id: "section-1", label: "1. Информация" },
|
return (
|
||||||
{ id: "section-2", label: "2. Сети" },
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-headline, #0F172A)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "10px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-on-dark, #E2E8F0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar nav ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Две группы с групп-хедерами (#1741). Нумерация секций сквозная 1–6 под новый
|
||||||
|
// порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6.
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children?: { id: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
label: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
id: "section-3",
|
label: "Участок",
|
||||||
label: "3. Продажи конкурентов",
|
items: [
|
||||||
children: [
|
{ id: "section-1", label: "1. Информация" },
|
||||||
{ id: "section-3-1", label: "3.1 Настройки выборки" },
|
{ id: "section-4", label: "2. Оценка участка" },
|
||||||
{ id: "section-3-2", label: "3.2 Планировки" },
|
{ id: "section-2", label: "3. Сети и точки подключения" },
|
||||||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: "section-4", label: "4. Оценка" },
|
|
||||||
{ id: "section-5", label: "5. Атмосфера" },
|
|
||||||
{
|
{
|
||||||
id: "section-6",
|
label: "Стройка и рынок",
|
||||||
label: "6. Прогноз",
|
items: [
|
||||||
children: [
|
{
|
||||||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
id: "section-3",
|
||||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
label: "4. Рынок и конкуренты",
|
||||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
children: [
|
||||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
{ id: "section-3-1", label: "4.1 Настройки выборки" },
|
||||||
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
{ id: "section-3-2", label: "4.2 Планировки" },
|
||||||
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
{ id: "section-3-3", label: "4.3 Остатки и скорость" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "section-5", label: "5. Атмосфера" },
|
||||||
|
{
|
||||||
|
id: "section-6",
|
||||||
|
label: "6. Прогноз и рекомендация",
|
||||||
|
children: [
|
||||||
|
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||||
|
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||||
|
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||||
|
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||||
|
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
||||||
|
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -293,49 +373,36 @@ function AnalysisSidebarNav() {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_GROUPS.map((group, gi) => (
|
||||||
<div key={item.id}>
|
<div key={group.label}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
onClick={() => scrollTo(item.id)}
|
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
padding: gi === 0 ? "0 16px 6px" : "12px 16px 6px",
|
||||||
width: "100%",
|
fontSize: 11,
|
||||||
textAlign: "left",
|
fontWeight: 600,
|
||||||
padding: "8px 16px",
|
letterSpacing: "0.06em",
|
||||||
background: "none",
|
textTransform: "uppercase",
|
||||||
border: "none",
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--fg-primary, #111111)",
|
|
||||||
transition: "background 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = "var(--bg-card-alt, #FAFBFC)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = "none";
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{group.label}
|
||||||
</button>
|
</div>
|
||||||
{"children" in item &&
|
{group.items.map((item) => (
|
||||||
item.children?.map((child) => (
|
<div key={item.id}>
|
||||||
<button
|
<button
|
||||||
key={child.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scrollTo(child.id)}
|
onClick={() => scrollTo(item.id)}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
padding: "6px 16px 6px 28px",
|
padding: "8px 16px",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
color: "var(--fg-secondary, #5B6066)",
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
transition: "background 0.1s",
|
transition: "background 0.1s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
|
@ -346,9 +413,38 @@ function AnalysisSidebarNav() {
|
||||||
e.currentTarget.style.background = "none";
|
e.currentTarget.style.background = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{child.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{item.children?.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(child.id)}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "6px 16px 6px 28px",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background =
|
||||||
|
"var(--bg-card-alt, #FAFBFC)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -22,64 +22,88 @@ interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подсказка на сегментах — поясняет, что даёт ближний / дальний горизонт.
|
||||||
|
const HORIZON_TOOLTIP =
|
||||||
|
"6 мес — ближний спрос, точнее; 24 мес — стратегия, шире интервал";
|
||||||
|
|
||||||
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
|
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Горизонт прогноза
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Горизонт прогноза"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
padding: 2,
|
||||||
|
gap: 2,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HORIZONS.map((h) => {
|
||||||
|
const selected = h === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={h}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
title={HORIZON_TOOLTIP}
|
||||||
|
onClick={() => onChange(h)}
|
||||||
|
style={{
|
||||||
|
appearance: "none",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: selected ? "var(--accent)" : "transparent",
|
||||||
|
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? 600 : 500,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "background 0.12s, color 0.12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h} мес
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 500,
|
lineHeight: "16px",
|
||||||
letterSpacing: "0.04em",
|
color: "var(--fg-tertiary)",
|
||||||
textTransform: "uppercase",
|
textAlign: "right",
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Горизонт прогноза
|
Смена горизонта пересчитывает прогноз заново (~10–30 сек)
|
||||||
</span>
|
</span>
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Горизонт прогноза"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
padding: 2,
|
|
||||||
gap: 2,
|
|
||||||
opacity: disabled ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{HORIZONS.map((h) => {
|
|
||||||
const selected = h === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={h}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={selected}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(h)}
|
|
||||||
style={{
|
|
||||||
appearance: "none",
|
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
|
||||||
padding: "6px 16px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "none",
|
|
||||||
background: selected ? "var(--accent)" : "transparent",
|
|
||||||
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: selected ? 600 : 500,
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
transition: "background 0.12s, color 0.12s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{h} мес
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,33 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "#6b7280",
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
}}
|
}}
|
||||||
title="Pipeline 24 месяца — ЖК, которые сдаются в окне 24мес в радиусе 5км"
|
title="Прогноз будущего предложения: ЖК-конкуренты со сроком сдачи в ближайшие 24 месяца в радиусе 5 км. Это оценка, а не факт продаж."
|
||||||
>
|
>
|
||||||
Конкуренты на 24 мес (5 км)
|
Будущее предложение конкурентов (24 мес)
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
background: "var(--bg-card-alt, #FAFBFC)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
прогноз
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -102,13 +120,14 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.severity_label ?? data.severity} ·{" "}
|
{data.severity_label ?? data.severity} ·{" "}
|
||||||
{data.flats_total.toLocaleString("ru-RU")} квартир
|
{data.flats_total.toLocaleString("ru")} лотов
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.objects_count === 0 ? (
|
{data.objects_count === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>
|
<div style={{ fontSize: 13, color: "var(--fg-tertiary, #73767E)" }}>
|
||||||
Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км.
|
Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5
|
||||||
|
км.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -123,20 +142,10 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 11 }}>Объектов</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 14,
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{data.objects_count}
|
ЖК
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
|
||||||
Квартир суммарно
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -145,11 +154,31 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.flats_total.toLocaleString("ru-RU")}
|
{data.objects_count.toLocaleString("ru")} ЖК
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 11 }}>Горизонт</div>
|
<div
|
||||||
|
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
Лотов суммарно
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.flats_total.toLocaleString("ru")} лотов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
Горизонт
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|
@ -186,7 +215,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
{fmtClass(cls)}:{" "}
|
{fmtClass(cls)}:{" "}
|
||||||
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{flats.toLocaleString("ru-RU")}
|
{flats.toLocaleString("ru")} лотов
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -200,11 +229,11 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#6b7280",
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
По кварталам сдачи
|
По кварталам сдачи (прогноз)
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -248,7 +277,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
borderRadius: "3px 3px 0 0",
|
borderRadius: "3px 3px 0 0",
|
||||||
}}
|
}}
|
||||||
title={`${q.quarter}: ${q.objects} ЖК / ${q.flats} квартир`}
|
title={`${q.quarter}: ${q.objects.toLocaleString("ru")} ЖК / ${q.flats.toLocaleString("ru")} лотов (прогноз)`}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -278,7 +307,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
color: "#1d4ed8",
|
color: "var(--accent, #1D4ED8)",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|
@ -286,7 +315,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
{expanded
|
{expanded
|
||||||
? "Скрыть список"
|
? "Скрыть список"
|
||||||
: `Топ-${data.top_objects.length} ЖК pipeline`}
|
: `Топ-${data.top_objects.length} ЖК в прогнозе`}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -375,7 +404,9 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{obj.flat_count?.toLocaleString("ru-RU") ?? "—"}
|
{obj.flat_count != null
|
||||||
|
? `${obj.flat_count.toLocaleString("ru")} лотов`
|
||||||
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ function formatPercent(score: number): string {
|
||||||
return `${Math.round(score * 100)}%`;
|
return `${Math.round(score * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */
|
||||||
|
function paceBand(score: number): { label: string; color: string } {
|
||||||
|
if (score >= 0.66)
|
||||||
|
return { label: "быстро", color: "var(--success, #0A7A3A)" };
|
||||||
|
if (score >= 0.33) return { label: "средне", color: "var(--warn, #9A6700)" };
|
||||||
|
return { label: "медленно", color: "var(--danger, #B3261E)" };
|
||||||
|
}
|
||||||
|
|
||||||
export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
if (!velocity) {
|
if (!velocity) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -55,6 +63,16 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
||||||
const scorePct = formatPercent(velocity.velocity_score);
|
const scorePct = formatPercent(velocity.velocity_score);
|
||||||
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
||||||
|
const band = paceBand(velocity.velocity_score);
|
||||||
|
// Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
|
||||||
|
// медленнее рынка» по соотношению, отдельно от шкалы 0–100%).
|
||||||
|
const aboveMarket = ratio >= 1;
|
||||||
|
const verdict = `×${ratio.toFixed(1)} к медиане ЕКБ — ${
|
||||||
|
aboveMarket ? "выше рынка" : "ниже рынка"
|
||||||
|
}`;
|
||||||
|
const verdictColor = aboveMarket
|
||||||
|
? "var(--success, #0A7A3A)"
|
||||||
|
: "var(--danger, #B3261E)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -97,23 +115,46 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
{/* Score gauge — показываем только если данные есть */}
|
{/* Score gauge — показываем только если данные есть */}
|
||||||
{dataAvailable && (
|
{dataAvailable && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
{/* Крупный вердикт — главное число секции */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: verdictColor,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
title="Соотношение темпа продаж конкурентов к медианному темпу по ЕКБ (м² в месяц)"
|
||||||
|
>
|
||||||
|
{verdict}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#6b7280",
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Velocity-score</span>
|
<span title="Шкала 0–100%: 0–33% медленно, 33–66% средне, 66–100% быстро">
|
||||||
<span style={{ fontWeight: 600, color: "#111827" }}>
|
Темп продаж
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{scorePct}
|
{scorePct}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#e5e7eb",
|
background: "var(--border-card, #E6E8EC)",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
height: 8,
|
height: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|
@ -121,23 +162,40 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background:
|
background: band.color,
|
||||||
velocity.velocity_score >= 0.66
|
|
||||||
? "#10b981"
|
|
||||||
: velocity.velocity_score >= 0.33
|
|
||||||
? "#f59e0b"
|
|
||||||
: "#ef4444",
|
|
||||||
width: `${velocity.velocity_score * 100}%`,
|
width: `${velocity.velocity_score * 100}%`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
<div
|
||||||
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
|
style={{
|
||||||
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
|
display: "flex",
|
||||||
{ratio >= 1
|
justifyContent: "space-between",
|
||||||
? `x${ratio.toFixed(1)} выше`
|
fontSize: 11,
|
||||||
: `${formatPercent(ratio)} от среднего`}
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>выше — продаётся быстрее</span>
|
||||||
|
<span style={{ fontWeight: 600, color: band.color }}>
|
||||||
|
{band.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Сами м²/мес — мелкий caption под вердиктом */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
marginTop: 6,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(velocity.monthly_velocity_sqm).toLocaleString("ru")}{" "}
|
||||||
|
м²/мес против{" "}
|
||||||
|
{Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес
|
||||||
|
(медиана ЕКБ)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6.4 Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1).
|
||||||
|
*
|
||||||
|
* Статическая таблица под рекомендацией продукта: строки = форматы (студия / 1к /
|
||||||
|
* 2к / 3к / 4-5к) РЕКОМЕНДОВАННОГО класса, колонки = Формат · Индекс дефицита ·
|
||||||
|
* Прогноз спроса (квартир) · Сигнал. Цель — показать продуктовому директору весь
|
||||||
|
* срез дефицита по форматам одним взглядом (а не только рекомендованный формат).
|
||||||
|
*
|
||||||
|
* Источник: `product_tz.mix` (квартирография — ячейки формат×класс с per-ячейкой
|
||||||
|
* deficit_index + #1745-проброшенными projected_demand_units/signal). Фильтруем к
|
||||||
|
* форматам рекомендованного класса, упорядочиваем по канонической комнатной сетке.
|
||||||
|
*
|
||||||
|
* СЕМАНТИКА (зеркало §22 deficit): deficit_index >0 — недонасыщенность (--success,
|
||||||
|
* повод строить), <0 — затоварка (--danger), ≈0 — баланс. Рекомендованный формат
|
||||||
|
* (сильнейший дефицит класса) — строка --accent-soft. deficit_index=null → «тонкие
|
||||||
|
* данные» серым (--fg-tertiary), НЕ 0 (честность: отсутствие сигнала ≠ нулевой
|
||||||
|
* сигнал). Прогноз спроса null → тоже «тонкие данные».
|
||||||
|
*
|
||||||
|
* БЕЗ слайдеров и без рублей — это фаза 1 (фаза 2 с экономикой/сценариями придёт
|
||||||
|
* после бэктеста движка #951). Блок помечен «Советующий расчёт, не основание для
|
||||||
|
* инвест-решения».
|
||||||
|
*
|
||||||
|
* GRACEFUL: returns null когда у рекомендованного класса нет ни одной форматной
|
||||||
|
* ячейки (нечего показывать).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProductMixEntry } from "@/types/forecast";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
|
import {
|
||||||
|
DEFICIT_BALANCE_EPS,
|
||||||
|
deficitSignalWord,
|
||||||
|
deficitVariant,
|
||||||
|
fmtNum,
|
||||||
|
} from "./forecast-helpers";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Рекомендованный класс (§10.2) — фильтр строк таблицы. */
|
||||||
|
objClass: string | null;
|
||||||
|
/** Квартирография — ячейки формат×класс с deficit_index/прогнозом/сигналом. */
|
||||||
|
mix: ProductMixEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каноническая комнатная сетка для строк (порядок ОБЯЗАТЕЛЕН: от меньшего к
|
||||||
|
// большему). Ключ — live bucket-id (вокабуляр overlay), значение — короткая
|
||||||
|
// RU-метка строки. Неизвестные bucket'ы рисуем как есть в конце.
|
||||||
|
const ROOM_ROW_ORDER: { bucket: string; label: string }[] = [
|
||||||
|
{ bucket: "1-Студия", label: "Студия" },
|
||||||
|
{ bucket: "2-1-к", label: "1-к" },
|
||||||
|
{ bucket: "3-2-к", label: "2-к" },
|
||||||
|
{ bucket: "4-3-к", label: "3-к" },
|
||||||
|
{ bucket: "5-80+ м²", label: "4-5-к" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROOM_INDEX: Map<string, number> = new Map(
|
||||||
|
ROOM_ROW_ORDER.map((r, i) => [r.bucket, i]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ROOM_LABEL: Map<string, string> = new Map(
|
||||||
|
ROOM_ROW_ORDER.map((r) => [r.bucket, r.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сигнал-бейдж: вариант по знаку дефицита (строить success / баланс neutral /
|
||||||
|
// избегать danger). Зеркало deficitVariant, но neutral вместо нейтрального.
|
||||||
|
function signalVariant(deficitIndex: number): BadgeVariant {
|
||||||
|
const v = deficitVariant(deficitIndex);
|
||||||
|
if (v === "success") return "success";
|
||||||
|
if (v === "danger") return "danger";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвет ячейки индекса дефицита (только токены): >0 --success, <0 --danger,
|
||||||
|
// ≈0 нейтрально (--fg-secondary). Зеркало deficitVariant.
|
||||||
|
function deficitColor(deficitIndex: number): string {
|
||||||
|
const v = deficitVariant(deficitIndex);
|
||||||
|
if (v === "success") return "var(--success)";
|
||||||
|
if (v === "danger") return "var(--danger)";
|
||||||
|
return "var(--fg-secondary)";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssortmentRow {
|
||||||
|
bucket: string;
|
||||||
|
label: string;
|
||||||
|
deficit_index: number | null;
|
||||||
|
projected_demand_units: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(
|
||||||
|
objClass: string | null,
|
||||||
|
mix: ProductMixEntry[],
|
||||||
|
): AssortmentRow[] {
|
||||||
|
// Ячейки рекомендованного класса (или все, если класс не задан — деградация).
|
||||||
|
const cells = mix.filter(
|
||||||
|
(m) => m.bucket != null && (objClass == null || m.obj_class === objClass),
|
||||||
|
);
|
||||||
|
|
||||||
|
// По одной строке на bucket (если дубликаты — берём первую встреченную).
|
||||||
|
const byBucket = new Map<string, ProductMixEntry>();
|
||||||
|
for (const m of cells) {
|
||||||
|
if (m.bucket != null && !byBucket.has(m.bucket)) byBucket.set(m.bucket, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: AssortmentRow[] = [];
|
||||||
|
for (const [bucket, m] of byBucket) {
|
||||||
|
rows.push({
|
||||||
|
bucket,
|
||||||
|
label: ROOM_LABEL.get(bucket) ?? bucket,
|
||||||
|
deficit_index: m.deficit_index ?? null,
|
||||||
|
projected_demand_units: m.projected_demand_units ?? null,
|
||||||
|
// Готовый backend-signal в приоритете; иначе деривируем из индекса (fallback).
|
||||||
|
signal:
|
||||||
|
m.signal ??
|
||||||
|
(m.deficit_index != null ? deficitSignalWord(m.deficit_index) : null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем по канонической сетке; неизвестные bucket'ы — в конец (стабильно).
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ai = ROOM_INDEX.get(a.bucket) ?? ROOM_ROW_ORDER.length;
|
||||||
|
const bi = ROOM_INDEX.get(b.bucket) ?? ROOM_ROW_ORDER.length;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return a.bucket.localeCompare(b.bucket);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рекомендованный формат = сильнейший дефицит класса (как в ранкинге). null —
|
||||||
|
// нет ни одной ячейки с измеримым индексом → нечего подсвечивать.
|
||||||
|
function recommendedBucket(rows: AssortmentRow[]): string | null {
|
||||||
|
let best: AssortmentRow | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.deficit_index == null) continue;
|
||||||
|
if (best == null || r.deficit_index > (best.deficit_index ?? -Infinity)) {
|
||||||
|
best = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.bucket ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TH_STYLE: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
padding: "8px 12px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TD_STYLE: React.CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
padding: "9px 12px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForecastAssortmentBlock({ objClass, mix }: Props) {
|
||||||
|
const rows = buildRows(objClass, mix);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const recoBucket = recommendedBucket(rows);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{/* Headline-bar: один-предложение caveat (--bg-headline + --fg-on-dark) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-headline)",
|
||||||
|
color: "var(--fg-on-dark)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "10px 14px",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Советующий расчёт, не основание для инвест-решения.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица прогноза по всем форматам */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
minWidth: 480,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "var(--bg-card-alt)" }}>
|
||||||
|
<th style={{ ...TH_STYLE, textAlign: "left" }}>Формат</th>
|
||||||
|
<th
|
||||||
|
style={{ ...TH_STYLE, textAlign: "right" }}
|
||||||
|
title="Индекс дефицита ∈ [−1, +1]: >0 — недонасыщенность (повод строить), <0 — затоварка."
|
||||||
|
>
|
||||||
|
Индекс дефицита
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ ...TH_STYLE, textAlign: "right" }}
|
||||||
|
title="Прогноз спроса на целевом горизонте — сколько квартир рынок поглотит в этом формате."
|
||||||
|
>
|
||||||
|
Прогноз спроса, квартир
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TH_STYLE, textAlign: "left" }}>Сигнал</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const isReco = recoBucket != null && r.bucket === recoBucket;
|
||||||
|
const di = r.deficit_index;
|
||||||
|
const demand = r.projected_demand_units;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={r.bucket}
|
||||||
|
style={{
|
||||||
|
background: isReco
|
||||||
|
? "var(--accent-soft)"
|
||||||
|
: "var(--bg-card)",
|
||||||
|
borderTop:
|
||||||
|
i === 0 ? "none" : "1px solid var(--border-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Формат */}
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...TD_STYLE,
|
||||||
|
textAlign: "left",
|
||||||
|
fontWeight: isReco ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
{isReco && (
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
рекомендован
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Индекс дефицита */}
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: "right" }}>
|
||||||
|
{di != null ? (
|
||||||
|
<span
|
||||||
|
style={{ fontWeight: 600, color: deficitColor(di) }}
|
||||||
|
>
|
||||||
|
{di > 0 ? "+" : ""}
|
||||||
|
{fmtNum(di, 2)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||||
|
тонкие данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Прогноз спроса, квартир */}
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: "right" }}>
|
||||||
|
{demand != null ? (
|
||||||
|
<>
|
||||||
|
{fmtNum(demand, 0)}{" "}
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>кв.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||||
|
тонкие данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Сигнал */}
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: "left" }}>
|
||||||
|
{di != null ? (
|
||||||
|
<Badge variant={signalVariant(di)} size="sm">
|
||||||
|
{r.signal ??
|
||||||
|
(Math.abs(di) < DEFICIT_BALANCE_EPS
|
||||||
|
? "баланс"
|
||||||
|
: deficitSignalWord(di))}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--fg-tertiary)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
тонкие данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -77,12 +77,17 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
<tr>
|
<tr>
|
||||||
<th style={TH_STYLE}>Горизонт</th>
|
<th style={TH_STYLE}>Горизонт</th>
|
||||||
<th style={TH_STYLE}>Индекс дефицита</th>
|
<th style={TH_STYLE}>Индекс дефицита</th>
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
<th
|
||||||
|
style={{ ...TH_STYLE, textAlign: "right" }}
|
||||||
|
title="Во сколько месяцев распродаётся текущий объём при базовом темпе"
|
||||||
|
>
|
||||||
Мес. предложения
|
Мес. предложения
|
||||||
</th>
|
</th>
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>Прогноз спроса</th>
|
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
||||||
Прогноз предложения
|
Прогноз спроса, квартир
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
||||||
|
Прогноз предложения, квартир
|
||||||
</th>
|
</th>
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -154,6 +159,19 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Caveat — единицы и статус оценки под таблицей (advisory, не решение). */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: "16px",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Спрос/предложение — прогнозные квартиры за горизонт; оценка advisory,
|
||||||
|
уверенность ≤ средней.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Rate-sensitivity phrase — shared across horizons in current data; show
|
{/* Rate-sensitivity phrase — shared across horizons in current data; show
|
||||||
the distinct phrases once each to avoid noisy repetition. */}
|
the distinct phrases once each to avoid noisy repetition. */}
|
||||||
<RateSensitivityNotes forecasts={rows} />
|
<RateSensitivityNotes forecasts={rows} />
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
ProductCommercial,
|
ProductCommercial,
|
||||||
ProductMixEntry,
|
ProductMixEntry,
|
||||||
ProductReason,
|
ProductReason,
|
||||||
|
ProductReasonRejected,
|
||||||
ProductUsp,
|
ProductUsp,
|
||||||
ReportProductTz,
|
ReportProductTz,
|
||||||
} from "@/types/forecast";
|
} from "@/types/forecast";
|
||||||
|
|
@ -27,12 +28,14 @@ import type {
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import {
|
import {
|
||||||
|
aggregateClassDeficits,
|
||||||
DEFICIT_BALANCE_EPS,
|
DEFICIT_BALANCE_EPS,
|
||||||
deficitBarWidthPct,
|
deficitBarWidthPct,
|
||||||
deficitVariant,
|
deficitVariant,
|
||||||
deficitWord,
|
deficitWord,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
} from "./forecast-helpers";
|
} from "./forecast-helpers";
|
||||||
|
import type { ClassDeficit } from "./forecast-helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
||||||
|
|
@ -74,14 +77,21 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
// Graceful: nothing meaningful to show → render nothing.
|
// Graceful: nothing meaningful to show → render nothing.
|
||||||
if (!isNonEmpty(productTz)) return null;
|
if (!isNonEmpty(productTz)) return null;
|
||||||
|
|
||||||
|
// #1742 — объяснение выбора класса: per-класс дефицит (агрегат mix по классам).
|
||||||
|
const classDeficits = aggregateClassDeficits(productTz.mix);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{/* Recommended class — prominent pill + summary line */}
|
{/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */}
|
||||||
<ClassHeadline
|
<ClassHeadline
|
||||||
obj_class={productTz.obj_class}
|
obj_class={productTz.obj_class}
|
||||||
summary={productTz.summary}
|
summary={productTz.summary}
|
||||||
|
classDeficits={classDeficits}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */}
|
||||||
|
<RejectedAlternatives reasons={productTz.reasons} />
|
||||||
|
|
||||||
{/* Квартирография — deficit/share bars */}
|
{/* Квартирография — deficit/share bars */}
|
||||||
<MixBars mix={productTz.mix} />
|
<MixBars mix={productTz.mix} />
|
||||||
|
|
||||||
|
|
@ -91,7 +101,7 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
{/* USP-ниши */}
|
{/* USP-ниши */}
|
||||||
<UspList usp={productTz.usp} />
|
<UspList usp={productTz.usp} />
|
||||||
|
|
||||||
{/* §16 обоснование (раскрываемое) */}
|
{/* §16 обоснование (раскрываемое) — без «отвергнутых» (подняты выше) */}
|
||||||
<ReasonsDisclosure reasons={productTz.reasons} />
|
<ReasonsDisclosure reasons={productTz.reasons} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -102,21 +112,68 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
function ClassHeadline({
|
function ClassHeadline({
|
||||||
obj_class,
|
obj_class,
|
||||||
summary,
|
summary,
|
||||||
|
classDeficits,
|
||||||
}: {
|
}: {
|
||||||
obj_class: string | null;
|
obj_class: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
classDeficits: ClassDeficit[];
|
||||||
}) {
|
}) {
|
||||||
if (obj_class == null && !summary) return null;
|
if (obj_class == null && !summary) return null;
|
||||||
|
|
||||||
|
// #1742 — дефицит рекомендованного класса (для автоген-фразы). Ищем его агрегат
|
||||||
|
// в per-класс таблице; null → у класса нет измеримого индекса (тонкие данные).
|
||||||
|
const recoDeficit =
|
||||||
|
obj_class != null
|
||||||
|
? (classDeficits.find((c) => c.obj_class === obj_class)
|
||||||
|
?.meanDeficitIndex ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{obj_class != null && (
|
{obj_class != null && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
||||||
<Badge variant="info" size="md">
|
<Badge variant="info" size="md">
|
||||||
{obj_class}
|
{capitalize(obj_class)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Автоген-фраза: почему именно этот класс (#1742) */}
|
||||||
|
{obj_class != null && recoDeficit != null && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontWeight: 600 }}>{capitalize(obj_class)}</strong> —
|
||||||
|
здесь сильнее всего недозакрыт спрос: индекс дефицита{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: `var(--${deficitVariant(recoDeficit) === "danger" ? "danger" : deficitVariant(recoDeficit) === "success" ? "success" : "fg-secondary"})`,
|
||||||
|
}}
|
||||||
|
title="Индекс дефицита ∈ [−1, +1]: +1 — острый дефицит (недонасыщенность), −1 — затоварка."
|
||||||
|
>
|
||||||
|
{recoDeficit > 0 ? "+" : ""}
|
||||||
|
{fmtNum(recoDeficit, 2)}
|
||||||
|
</span>{" "}
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||||
|
(где +1 — острый дефицит, −1 — затоварка).
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Мини-таблица 3 классов с их индексом дефицита (контраст) (#1742) */}
|
||||||
|
<ClassDeficitTable
|
||||||
|
classDeficits={classDeficits}
|
||||||
|
recommended={obj_class}
|
||||||
|
/>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -133,6 +190,159 @@ function ClassHeadline({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Мини-таблица per-класс дефицита (#1742 — контраст 3 классов) ───────────────
|
||||||
|
|
||||||
|
function ClassDeficitTable({
|
||||||
|
classDeficits,
|
||||||
|
recommended,
|
||||||
|
}: {
|
||||||
|
classDeficits: ClassDeficit[];
|
||||||
|
recommended: string | null;
|
||||||
|
}) {
|
||||||
|
if (classDeficits.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
gap: 12,
|
||||||
|
padding: "6px 12px",
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
borderBottom: "1px solid var(--border-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={SUBHEAD_STYLE}>Класс</span>
|
||||||
|
<span style={{ ...SUBHEAD_STYLE, textAlign: "right" }}>
|
||||||
|
Индекс дефицита
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{classDeficits.map((c, i) => {
|
||||||
|
const isReco = recommended != null && c.obj_class === recommended;
|
||||||
|
const di = c.meanDeficitIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.obj_class}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "7px 12px",
|
||||||
|
background: isReco ? "var(--accent-soft)" : "var(--bg-card)",
|
||||||
|
borderTop: i === 0 ? "none" : "1px solid var(--border-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
fontWeight: isReco ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capitalize(c.obj_class)}
|
||||||
|
{isReco && (
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
рекомендован
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{di != null ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "right",
|
||||||
|
color: `var(--${deficitVariant(di) === "danger" ? "danger" : deficitVariant(di) === "success" ? "success" : "fg-secondary"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{di > 0 ? "+" : ""}
|
||||||
|
{fmtNum(di, 2)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "right",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
тонкие данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Отвергнутые альтернативы (#1742 — поднято из-под ката) ─────────────────────
|
||||||
|
|
||||||
|
function RejectedAlternatives({ reasons }: { reasons: ProductReason[] }) {
|
||||||
|
// Собираем уникальные отвергнутые альтернативы из всех §16-причин (де-дуп по
|
||||||
|
// alternative — одна и та же альтернатива может встречаться в нескольких reason).
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rejected: ProductReasonRejected[] = [];
|
||||||
|
for (const r of reasons) {
|
||||||
|
for (const rej of r.rejected ?? []) {
|
||||||
|
if (!rej.alternative || seen.has(rej.alternative)) continue;
|
||||||
|
seen.add(rej.alternative);
|
||||||
|
rejected.push(rej);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rejected.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
|
{rejected.map((rej, i) => (
|
||||||
|
<span
|
||||||
|
key={`${rej.alternative}-${i}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-soft)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "3px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--fg-primary)" }}>
|
||||||
|
{capitalize(rej.alternative)}
|
||||||
|
</span>
|
||||||
|
{rej.deficit_index != null && (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{rej.deficit_index > 0 ? "+" : ""}
|
||||||
|
{fmtNum(rej.deficit_index, 2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>· {rej.reason}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
|
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function mixLabel(m: ProductMixEntry): string {
|
function mixLabel(m: ProductMixEntry): string {
|
||||||
|
|
@ -446,42 +656,8 @@ function ReasonCard({ reason }: { reason: ProductReason }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reason.rejected && reason.rejected.length > 0 && (
|
{/* «Отвергнутые альтернативы» подняты на уровень блока (#1742) —
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
здесь не дублируем. */}
|
||||||
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|
||||||
{reason.rejected.map((rej, i) => (
|
|
||||||
<span
|
|
||||||
key={`${rej.alternative}-${i}`}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
background: "var(--bg-card-alt)",
|
|
||||||
border: "1px solid var(--border-soft)",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "3px 8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--fg-primary)" }}>
|
|
||||||
{rej.alternative}
|
|
||||||
</span>
|
|
||||||
{rej.deficit_index != null && (
|
|
||||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
||||||
{rej.deficit_index > 0 ? "+" : ""}
|
|
||||||
{fmtNum(rej.deficit_index, 2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ color: "var(--fg-tertiary)" }}>
|
|
||||||
· {rej.reason}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section2NetworksUtilities — "2. Сети и точки подключения"
|
* Section2NetworksUtilities — "3. Сети и точки подключения"
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* HeadlineBar (title «Сети» + subtitle с расстояниями)
|
* HeadlineBar (title «Сети» + subtitle с расстояниями)
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ function Section31Settings({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
3.1 Настройки выборки
|
4.1 Настройки выборки
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -281,7 +281,7 @@ function Section32Layouts({ cad }: { cad: string }) {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
3.2 Планировки
|
4.2 Планировки
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -299,14 +299,95 @@ function Section32Layouts({ cad }: { cad: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 3.3 «Остатки и скорость» ──────────────────────────────────────────
|
// ── Section 3.3 «Как продаётся рынок рядом» ───────────────────────────────────
|
||||||
|
|
||||||
function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
|
/** Small uppercase label-заголовок над каждым подблоком 3.3. */
|
||||||
|
function SubBlockLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает одно предложение-вердикт для headline-bar из уже доступных данных
|
||||||
|
* velocity / pipeline / market_trend. Возвращает null если данных совсем нет.
|
||||||
|
*
|
||||||
|
* - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес).
|
||||||
|
* - months_of_inventory — за сколько месяцев при текущем темпе разойдётся
|
||||||
|
* будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из
|
||||||
|
* velocity.by_room_bucket (units за months_observed); без него клаузу
|
||||||
|
* пропускаем, чтобы не выдумывать цифру.
|
||||||
|
* - price change — market_trend.delta_6m_pct в процентных пунктах.
|
||||||
|
*/
|
||||||
|
function buildVerdict(data: ParcelAnalysis): string | null {
|
||||||
|
const v = data.velocity;
|
||||||
|
const pipeline = data.pipeline_24mo;
|
||||||
|
const trend = data.market_trend;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) {
|
||||||
|
const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm;
|
||||||
|
const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка";
|
||||||
|
parts.push(
|
||||||
|
`Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Темп в лотах/мес из наблюдённых сделок по комнатности.
|
||||||
|
if (
|
||||||
|
pipeline &&
|
||||||
|
pipeline.flats_total > 0 &&
|
||||||
|
v.by_room_bucket &&
|
||||||
|
v.months_observed > 0
|
||||||
|
) {
|
||||||
|
const unitsSold = Object.values(v.by_room_bucket).reduce(
|
||||||
|
(sum, b) => sum + b.units,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const lotsPerMonth = unitsSold / v.months_observed;
|
||||||
|
if (lotsPerMonth > 0) {
|
||||||
|
const moi = pipeline.flats_total / lotsPerMonth;
|
||||||
|
parts.push(
|
||||||
|
`при текущем темпе остаток разойдётся за ~${Math.round(
|
||||||
|
moi,
|
||||||
|
).toLocaleString("ru")} мес`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trend) {
|
||||||
|
const d = trend.delta_6m_pct;
|
||||||
|
const sign = d > 0 ? "+" : d < 0 ? "−" : "";
|
||||||
|
const abs = Math.abs(d).toLocaleString("ru", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
parts.push(`цены за период ${sign}${abs} п.п.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return `${parts.join("; ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
|
||||||
const hasPipeline = data.pipeline_24mo != null;
|
const hasPipeline = data.pipeline_24mo != null;
|
||||||
const hasVelocity = data.velocity != null;
|
const hasVelocity = data.velocity != null;
|
||||||
const hasTrend = data.market_trend != null;
|
const hasTrend = data.market_trend != null;
|
||||||
|
|
||||||
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
|
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
|
||||||
|
const verdict = buildVerdict(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
||||||
|
|
@ -319,7 +400,7 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
3.3 Остатки и скорость
|
4.3 Как продаётся рынок рядом
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -328,7 +409,7 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
|
||||||
margin: "4px 0 0",
|
margin: "4px 0 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pipeline конкурентов на 24 мес · темп продаж · тренд цен
|
Остатки конкурентов · темп продаж · динамика цен
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -343,31 +424,65 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Данные по остаткам и скорости продаж отсутствуют для этого участка
|
Данные по продажам рынка рядом отсутствуют для этого участка
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
style={{
|
{/* Headline-bar — один вердикт-предложение */}
|
||||||
display: "grid",
|
{verdict && (
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasPipeline && <Pipeline24moBlock data={data.pipeline_24mo!} />}
|
|
||||||
{hasVelocity && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--bg-card, #FFFFFF)",
|
background: "var(--bg-headline, #0F172A)",
|
||||||
border: "1px solid var(--border-card, #E6E8EC)",
|
borderRadius: 12,
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 18px",
|
padding: "14px 18px",
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--fg-on-dark, #E2E8F0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VelocityBlock velocity={data.velocity} />
|
{verdict}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasTrend && <MarketTrendBlock trend={data.market_trend} />}
|
|
||||||
</div>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasVelocity && (
|
||||||
|
<div>
|
||||||
|
<SubBlockLabel>Темп продаж</SubBlockLabel>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VelocityBlock velocity={data.velocity} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPipeline && (
|
||||||
|
<div>
|
||||||
|
<SubBlockLabel>
|
||||||
|
Будущее предложение конкурентов (24 мес)
|
||||||
|
</SubBlockLabel>
|
||||||
|
<Pipeline24moBlock data={data.pipeline_24mo!} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasTrend && (
|
||||||
|
<div>
|
||||||
|
<SubBlockLabel>Динамика цен</SubBlockLabel>
|
||||||
|
<MarketTrendBlock trend={data.market_trend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -684,7 +799,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Section32Layouts cad={cad} />
|
<Section32Layouts cad={cad} />
|
||||||
<Section33RemainVelocity data={data} />
|
<Section33MarketSales data={data} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtered competitors count hint */}
|
{/* Filtered competitors count hint */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section4Estimate — "4. Оценка участка"
|
* Section4Estimate — "2. Оценка участка"
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* HeadlineBar (score + verdict label as title)
|
* HeadlineBar (score + verdict label as title)
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
|
||||||
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
|
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
|
||||||
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
|
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
|
||||||
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
|
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
|
||||||
|
|
@ -166,12 +165,7 @@ export function Section4Estimate({ cad }: Props) {
|
||||||
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
|
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Gate verdict banner ──────────────────────────────────────────── */}
|
{/* Gate verdict banner перенесён НАД группой «Участок» (AnalysisPageContent, #1741) */}
|
||||||
{analysis.gate_verdict && (
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<GateVerdictBanner verdict={analysis.gate_verdict} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
|
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
|
||||||
{hasBreakdown && (
|
{hasBreakdown && (
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||||||
import { ScenariosBlock } from "./ScenariosBlock";
|
import { ScenariosBlock } from "./ScenariosBlock";
|
||||||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
|
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
|
||||||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||||||
import { ForecastMetaLine } from "./ForecastMetaLine";
|
import { ForecastMetaLine } from "./ForecastMetaLine";
|
||||||
|
|
@ -217,6 +218,17 @@ function ForecastReady({
|
||||||
(pt.commercial != null &&
|
(pt.commercial != null &&
|
||||||
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
||||||
|
|
||||||
|
// 6.4a — прогноз по всем форматам (#1745). Показываем, когда квартирография
|
||||||
|
// несёт хотя бы одну форматную ячейку (с bucket) рекомендованного класса —
|
||||||
|
// иначе таблица пуста (объект класс не задан → деградируем к любым ячейкам).
|
||||||
|
const hasAssortment =
|
||||||
|
pt != null &&
|
||||||
|
pt.mix.some(
|
||||||
|
(m) =>
|
||||||
|
m.bucket != null &&
|
||||||
|
(pt.obj_class == null || m.obj_class === pt.obj_class),
|
||||||
|
);
|
||||||
|
|
||||||
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
|
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
|
||||||
// ForecastReport type; populated when product_scores / special_indices exist.
|
// ForecastReport type; populated when product_scores / special_indices exist.
|
||||||
const sc = report.scoring;
|
const sc = report.scoring;
|
||||||
|
|
@ -240,6 +252,7 @@ function ForecastReady({
|
||||||
hasScenarios ||
|
hasScenarios ||
|
||||||
hasConfidence ||
|
hasConfidence ||
|
||||||
hasProductTz ||
|
hasProductTz ||
|
||||||
|
hasAssortment ||
|
||||||
hasScoring ||
|
hasScoring ||
|
||||||
hasFutureSupplyDetail;
|
hasFutureSupplyDetail;
|
||||||
|
|
||||||
|
|
@ -379,6 +392,15 @@ function ForecastReady({
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 6.4a Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1).
|
||||||
|
Под рекомендацией: весь срез дефицита по форматам рекомендованного
|
||||||
|
класса. Показываем только когда есть форматные ячейки квартирографии. */}
|
||||||
|
{hasAssortment && pt && (
|
||||||
|
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
|
||||||
|
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
|
||||||
|
</SubBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
|
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
|
||||||
{hasScoring && sc && (
|
{hasScoring && sc && (
|
||||||
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import type { ConfidenceLevel, FutureSupply } from "@/types/forecast";
|
import type {
|
||||||
|
ConfidenceLevel,
|
||||||
|
FutureSupply,
|
||||||
|
ProductMixEntry,
|
||||||
|
} from "@/types/forecast";
|
||||||
|
|
||||||
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
||||||
export const DEFICIT_BALANCE_EPS = 0.05;
|
export const DEFICIT_BALANCE_EPS = 0.05;
|
||||||
|
|
@ -26,6 +30,70 @@ export function deficitWord(deficitIndex: number): string {
|
||||||
return "баланс";
|
return "баланс";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить»
|
||||||
|
* (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend
|
||||||
|
* `report_assembler._build_signal` — фронт-fallback, когда mix-ячейка не несёт
|
||||||
|
* готового `signal` (старый прогон). Семантика та же, что у `deficitWord`.
|
||||||
|
*/
|
||||||
|
export function deficitSignalWord(deficitIndex: number): string {
|
||||||
|
if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить";
|
||||||
|
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать";
|
||||||
|
return "баланс";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ──────────
|
||||||
|
|
||||||
|
/** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */
|
||||||
|
export interface ClassDeficit {
|
||||||
|
obj_class: string;
|
||||||
|
/** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */
|
||||||
|
meanDeficitIndex: number | null;
|
||||||
|
/** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */
|
||||||
|
nWithIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Агрегировать `mix` (квартирография — ячейки формат×класс) в per-класс дефицит:
|
||||||
|
* среднее deficit_index по форматам каждого класса. Зеркало backend
|
||||||
|
* `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт
|
||||||
|
* мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения —
|
||||||
|
* `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index.
|
||||||
|
*
|
||||||
|
* Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге);
|
||||||
|
* классы без obj_class игнорируются (нечего агрегировать — НЕ фабрикуем класс).
|
||||||
|
* Класс, у которого ни у одной ячейки нет индекса → meanDeficitIndex=null (фронт
|
||||||
|
* рисует «тонкие данные», НЕ 0).
|
||||||
|
*/
|
||||||
|
export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] {
|
||||||
|
const byClass = new Map<string, number[]>();
|
||||||
|
for (const m of mix) {
|
||||||
|
if (m.obj_class == null) continue;
|
||||||
|
const list = byClass.get(m.obj_class) ?? [];
|
||||||
|
if (m.deficit_index != null) list.push(m.deficit_index);
|
||||||
|
byClass.set(m.obj_class, list);
|
||||||
|
}
|
||||||
|
const out: ClassDeficit[] = [];
|
||||||
|
for (const [obj_class, values] of byClass) {
|
||||||
|
const meanDeficitIndex =
|
||||||
|
values.length > 0
|
||||||
|
? values.reduce((a, b) => a + b, 0) / values.length
|
||||||
|
: null;
|
||||||
|
out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length });
|
||||||
|
}
|
||||||
|
// DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC.
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const av = a.meanDeficitIndex;
|
||||||
|
const bv = b.meanDeficitIndex;
|
||||||
|
if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class);
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
if (bv !== av) return bv - av;
|
||||||
|
return a.obj_class.localeCompare(b.obj_class);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the
|
* Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the
|
||||||
* magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar
|
* magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,19 @@ export interface ProductMixEntry {
|
||||||
deficit_index: number | null;
|
deficit_index: number | null;
|
||||||
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
||||||
pct?: number | null;
|
pct?: number | null;
|
||||||
|
/**
|
||||||
|
* #1745 — прогноз спроса по формату (квартир) на целевом горизонте. Проброшен из
|
||||||
|
* ranked_segments overlay (`projected_demand_units`). null при тонких данных или в
|
||||||
|
* demand_only-режиме (без геометрии участка спрос-проекция неизмерима) — рендерится
|
||||||
|
* «тонкие данные», НЕ 0.
|
||||||
|
*/
|
||||||
|
projected_demand_units?: number | null;
|
||||||
|
/**
|
||||||
|
* #1745 — деривированный сигнал из deficit_index: «строить» (недонасыщенность) /
|
||||||
|
* «баланс» / «избегать» (затоварка). null при deficit_index=null (тонкие данные).
|
||||||
|
* Дублирует frontend-семантику deficitWord — фронт может пересчитать сам как fallback.
|
||||||
|
*/
|
||||||
|
signal?: string | null;
|
||||||
confidence?: ConfidenceLevel | null;
|
confidence?: ConfidenceLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue