Merge pull request 'feat(ptica): detail drawer system + 17 drawers [PR#4]' (#1834) from feat/ptica-cockpit-pr4-drawers into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been cancelled

Reviewed-on: #1834
This commit is contained in:
lekss361 2026-06-20 12:33:37 +00:00
commit f6922e5527
13 changed files with 2837 additions and 29 deletions

View file

@ -1,20 +1,27 @@
"use client";
/**
* PticaPageContent client root of the ПТИЦА cockpit (INCREMENT 1).
* PticaPageContent client root of the ПТИЦА cockpit.
*
* Wires the SAME /analyze data as the existing light analysis page via
* useParcelAnalyzeQuery, narrows the response to ParcelAnalysis (the sanctioned
* `as unknown as` pattern see AnalysisPageContent.tsx:111), and composes the
* dark operator-terminal shell. Only the 'analysis' tab renders content in PR#1;
* Scenarios / Reports / Compare show an honest "В разработке" panel.
* dark operator-terminal shell.
*
* PR#4 DETAIL DRAWER system: this root owns the open-drawer state
* (`openDrawer: DrawerKey | null`), threads an `onOpenDrawer(key)` callback down
* the tree (hero cards + scan cards), and renders the matching drawer content
* inside a single controlled <PticaDrawer>. Deep-link: `?drawer=<key>` opens a
* drawer on mount; opening/closing updates the URL query via `router.replace`
* (shallow, no full navigation) so drawers are linkable / back-button friendly.
*
* The root carries BOTH `.pticaRoot` and `data-theme="dark"` so the scoped dark
* tokens apply here and never leak to the light pages.
*/
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import styles from "./ptica.module.css";
import {
@ -29,6 +36,12 @@ import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan"
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
import { PticaDrawer } from "@/components/site-finder/ptica/drawers/PticaDrawer";
import { getDrawerEntry } from "@/components/site-finder/ptica/drawers/drawer-registry";
import {
asDrawerKey,
type DrawerKey,
} from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
cad: string;
@ -45,6 +58,43 @@ export function PticaPageContent({ cad }: Props) {
const forecastReport =
forecastData?.status === "ready" ? forecastData.report : undefined;
// ── Drawer open-state + deep-link (?drawer=<key>) ──────────────────────────
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [openDrawer, setOpenDrawer] = useState<DrawerKey | null>(null);
// On mount / URL change, sync the open drawer from the `?drawer=` query.
const drawerParam = searchParams.get("drawer");
useEffect(() => {
setOpenDrawer(asDrawerKey(drawerParam));
}, [drawerParam]);
// Update the URL query shallowly (no full navigation) when opening/closing.
const syncUrl = useCallback(
(key: DrawerKey | null) => {
const params = new URLSearchParams(searchParams.toString());
if (key) params.set("drawer", key);
else params.delete("drawer");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[router, pathname, searchParams],
);
const handleOpenDrawer = useCallback(
(key: DrawerKey) => {
setOpenDrawer(key);
syncUrl(key);
},
[syncUrl],
);
const handleCloseDrawer = useCallback(() => {
setOpenDrawer(null);
syncUrl(null);
}, [syncUrl]);
// ── Loading ────────────────────────────────────────────────────────────────
if (isLoading) {
return (
@ -78,13 +128,24 @@ export function PticaPageContent({ cad }: Props) {
// the same /analyze endpoint. Sanctioned pattern (AnalysisPageContent.tsx:111).
const analysis = data as unknown as ParcelAnalysis;
const drawerEntry = openDrawer
? getDrawerEntry(openDrawer, analysis, forecastReport)
: null;
return (
<div className={styles.pticaRoot} data-theme="dark">
<PticaShell activeTab={tab} onTabChange={setTab}>
{tab === "analysis" && (
<>
<PticaHero analysis={analysis} forecastReport={forecastReport} />
<DevelopmentScan analysis={analysis} />
<PticaHero
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
<DevelopmentScan
analysis={analysis}
onOpenDrawer={handleOpenDrawer}
/>
</>
)}
{tab === "scenarios" && (
@ -97,6 +158,15 @@ export function PticaPageContent({ cad }: Props) {
{tab === "reports" && <PticaReports cad={cad} analysis={analysis} />}
{tab === "compare" && <PticaCompare cad={cad} />}
</PticaShell>
<PticaDrawer
open={Boolean(drawerEntry)}
title={drawerEntry?.title ?? ""}
sub={drawerEntry?.sub}
onClose={handleCloseDrawer}
>
{drawerEntry?.content}
</PticaDrawer>
</div>
);
}

View file

@ -1394,6 +1394,254 @@
font-size: 10px;
}
/* ===================== DETAIL DRAWER (PR#4) ===================== */
.drawerScrim {
position: fixed;
inset: 0;
background: rgba(2, 8, 12, 0.62);
backdrop-filter: blur(2px);
opacity: 0;
visibility: hidden;
transition:
opacity 0.18s ease,
visibility 0.18s ease;
z-index: 60;
}
.drawerScrimOpen {
opacity: 1;
visibility: visible;
}
.drawer {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 480px;
max-width: 100vw;
background: var(--surface-strong);
border-left: var(--line) solid var(--border-strong);
box-shadow: var(--shadow);
transform: translateX(100%);
transition: transform 0.18s ease;
z-index: 61;
display: flex;
flex-direction: column;
}
.drawerOpen {
transform: translateX(0);
}
.drawerHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: var(--line) solid var(--border);
flex-shrink: 0;
}
.drawerTitle {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-strong);
}
.drawerSub {
margin-top: 3px;
font-size: 10px;
color: var(--text-soft);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.drawerClose {
flex-shrink: 0;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: var(--line) solid var(--border);
border-radius: var(--radius-xs);
background: transparent;
color: var(--text-muted);
}
.drawerClose:hover {
color: var(--text-strong);
border-color: var(--border-strong);
}
.drawerClose:focus-visible {
outline: var(--line-strong) solid var(--accent-cyan);
outline-offset: 1px;
}
.drawerBody {
flex: 1;
overflow-y: auto;
padding: 16px 18px 32px;
}
.drawerSection {
margin-bottom: 18px;
}
.drawerSection:last-child {
margin-bottom: 0;
}
.drawerH4 {
margin: 0 0 9px;
font-size: 9.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-soft);
}
.summaryBox {
padding: 11px 12px;
background: var(--surface-2);
border: var(--line) solid var(--border-faint);
border-radius: var(--radius-sm);
font-size: 11.5px;
line-height: 1.55;
color: var(--text);
}
.statusRow {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 0;
font-size: 11px;
}
.drDot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
}
.drDotOk {
background: var(--accent-green);
}
.drDotWarn {
background: var(--accent-yellow);
}
.drDotBad {
background: var(--accent-red);
}
.drDotMuted {
background: var(--text-soft);
}
.drLabel {
flex: 1;
color: var(--text-muted);
}
.drState {
text-align: right;
font-weight: 600;
}
.drStateOk {
color: var(--accent-green);
}
.drStateWarn {
color: var(--accent-yellow);
}
.drStateBad {
color: var(--accent-red);
}
.drStateMuted {
color: var(--text-soft);
font-weight: 500;
}
.drawerActions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.drawerGaugeRow {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.drawerNote {
margin: 0 0 8px;
font-size: 11px;
line-height: 1.5;
color: var(--text-muted);
}
.drawerLinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.drawerLink {
font-size: 10px;
color: var(--accent-cyan);
text-decoration: none;
}
.drawerLink:hover {
text-decoration: underline;
}
.soonCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
text-align: center;
padding: 22px 16px;
border-style: dashed;
}
.soonCard p {
margin: 0;
font-size: 11px;
line-height: 1.5;
color: var(--text-soft);
max-width: 320px;
}
.roadmap {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-soft);
}
/* drawer sub-tabs (.dtabs / .dtab-panel) */
.dtabs {
display: flex;
gap: 4px;
margin-bottom: 11px;
border-bottom: var(--line) solid var(--border-faint);
}
.dtabs button {
padding: 6px 10px;
background: transparent;
border: none;
border-bottom: var(--line-strong) solid transparent;
color: var(--text-soft);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dtabs button:hover {
color: var(--text-muted);
}
.dtabActive {
color: var(--text-strong) !important;
border-bottom-color: var(--accent-cyan) !important;
}
.dtabPanel {
display: grid;
gap: 2px;
}
/* gauge-as-button (hero Buildability) */
.gaugeButton {
display: block;
width: 100%;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.gaugeButton:focus-visible {
outline: var(--line-strong) solid var(--accent-cyan);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ===================== RESPONSIVE ===================== */
@media (max-width: 1500px) {
.hero {
@ -1438,3 +1686,8 @@
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.drawer {
width: 100vw;
}
}

View file

@ -4,6 +4,9 @@
* DevelopmentScan "Development Scan" section: a responsive grid of ScanCards
* (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски /
* Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter.
*
* Each card maps to its drawer key and opens the matching detail drawer via the
* `onOpenDrawer` callback threaded down from PticaPageContent.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
@ -20,12 +23,14 @@ import {
adaptOksCard,
adaptRiskGauge,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: DrawerKey) => void;
}
export function DevelopmentScan({ analysis }: Props) {
export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
const riskGauge = adaptRiskGauge(analysis);
return (
@ -34,12 +39,32 @@ export function DevelopmentScan({ analysis }: Props) {
Development Scan · <b>градостроительный анализ</b>
</div>
<section className={styles.scanGrid}>
<ScanCard card={adaptUrbanCard(analysis)} />
<ScanCard card={adaptRestrictionsCard(analysis)} />
<ScanCard card={adaptEngineeringCard(analysis)} />
<ScanCard card={adaptMarketCard(analysis)} />
<ScanCard
card={adaptUrbanCard(analysis)}
drawerKey="urban"
onOpen={onOpenDrawer}
/>
<ScanCard
card={adaptRestrictionsCard(analysis)}
drawerKey="restrictions"
onOpen={onOpenDrawer}
/>
<ScanCard
card={adaptEngineeringCard(analysis)}
drawerKey="engineering"
onOpen={onOpenDrawer}
/>
<ScanCard
card={adaptMarketCard(analysis)}
drawerKey="market"
onOpen={onOpenDrawer}
/>
<div id="economy">
<ScanCard card={adaptEconomyCard()} />
<ScanCard
card={adaptEconomyCard()}
drawerKey="economy"
onOpen={onOpenDrawer}
/>
</div>
<div
@ -51,10 +76,23 @@ export function DevelopmentScan({ analysis }: Props) {
Риски
</h3>
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
<button
type="button"
className={styles.detailBtn}
style={{ alignSelf: "flex-start", marginTop: "auto" }}
onClick={() => onOpenDrawer("risks")}
title="Открыть детализацию"
>
Подробнее
</button>
</div>
<ScanCard card={adaptEnvironmentCard()} />
<ScanCard card={adaptOksCard()} />
<ScanCard
card={adaptEnvironmentCard()}
drawerKey="environment"
onOpen={onOpenDrawer}
/>
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
</section>
</>
);

View file

@ -11,14 +11,20 @@ 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";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
forecastReport?: ForecastReport;
onOpenDrawer: (key: DrawerKey) => void;
}
export function InvestScoreBlock({ analysis, forecastReport }: Props) {
export function InvestScoreBlock({
analysis,
forecastReport,
onOpenDrawer,
}: Props) {
const inv = adaptInvestScore(analysis, forecastReport);
return (
@ -63,6 +69,15 @@ export function InvestScoreBlock({ analysis, forecastReport }: Props) {
{inv.buySignal.isReal ? inv.buySignal.value : "после прогноза"}
</span>
</div>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("potential")}
title="Открыть расчёт ёмкости"
>
Подробнее
</button>
</div>
);
}

View file

@ -13,9 +13,11 @@ import type {
PticaField,
PticaPassport,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: DrawerKey) => void;
}
function KvCell({
@ -42,13 +44,21 @@ function KvCell({
);
}
export function ParcelPassportCard({ analysis }: Props) {
export function ParcelPassportCard({ analysis, onOpenDrawer }: Props) {
const p: PticaPassport = adaptPassport(analysis);
return (
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Паспорт участка</h3>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("passport")}
title="Открыть детализацию"
>
Подробнее
</button>
</div>
<div className={styles.kvGrid}>
<div className={`${styles.kv} ${styles.kvWide}`}>

View file

@ -3,6 +3,10 @@
/**
* PticaHero top of the cockpit: map + passport KV-grid + Buildability gauge +
* invest-score block, in the prototype's hero grid.
*
* The passport card, the Buildability gauge and the invest/potential block each
* open their detail drawer via the `onOpenDrawer` callback threaded down from
* PticaPageContent (passport / buildability / potential keys).
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
@ -13,25 +17,38 @@ import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPasspor
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
import { InvestScoreBlock } from "@/components/site-finder/ptica/InvestScoreBlock";
import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
/** §22 forecast report — threaded into the invest-score block once ready. */
forecastReport?: ForecastReport;
onOpenDrawer: (key: DrawerKey) => void;
}
export function PticaHero({ analysis, forecastReport }: Props) {
export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) {
const buildGauge = adaptBuildabilityGauge(analysis);
return (
<section id="passport" className={styles.hero}>
<PticaMap geojson={analysis.geom_geojson} />
<ParcelPassportCard analysis={analysis} />
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}>
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
<InvestScoreBlock analysis={analysis} forecastReport={forecastReport} />
<button
type="button"
className={styles.gaugeButton}
onClick={() => onOpenDrawer("buildability")}
aria-label="Открыть детализацию Buildability Index"
>
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
</button>
<InvestScoreBlock
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={onOpenDrawer}
/>
</div>
</section>
);

View file

@ -2,22 +2,28 @@
/**
* ScanCard generic dark cockpit card: title (+ optional badge), metric rows,
* and a "Подробнее" button that is RENDERED but disabled in INCREMENT 1 (drill-
* down detail ships in a later release).
* and a "Подробнее" button that opens the card's detail drawer.
*
* Each row's value flows from a {value, isReal, caption} view-model: placeholder
* When `drawerKey` + `onOpen` are provided the button is ENABLED and opens the
* matching drawer; without them it stays disabled (no drill-down wired). Each
* row's value flows from a {value, isReal, caption} view-model: placeholder
* fields are rendered muted with their caption so they never read as live data.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { PticaScanCard } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
card: PticaScanCard;
/** When set (with onOpen) the "Подробнее" button opens this drawer. */
drawerKey?: DrawerKey;
onOpen?: (key: DrawerKey) => void;
}
export function ScanCard({ card }: Props) {
export function ScanCard({ card, drawerKey, onOpen }: Props) {
const { title, badge, rows, emptyState } = card;
const canOpen = Boolean(drawerKey && onOpen);
return (
<div className={`${styles.card} ${styles.scanCard}`}>
@ -50,9 +56,14 @@ export function ScanCard({ card }: Props) {
<button
type="button"
className={styles.detailBtn}
disabled
aria-disabled="true"
title="Детализация — в следующем релизе"
disabled={!canOpen}
aria-disabled={!canOpen}
title={
canOpen ? "Открыть детализацию" : "Детализация — в следующем релизе"
}
onClick={
canOpen && drawerKey && onOpen ? () => onOpen(drawerKey) : undefined
}
>
Подробнее
</button>

View file

@ -0,0 +1,853 @@
"use client";
/**
* DrawerContent per-key detail-drawer bodies, built from the typed drawer
* view-models in ptica-adapt.ts (REAL data first, honest «» placeholders for
* absent fields). Each export renders the prototype's
* summary table/sub-tabs source-line optional export structure.
*
* Real-vs-placeholder lives entirely in the adapters; these components only
* present the view-models, so a placeholder field is always styled muted with
* its caption rather than shown as a live number.
*/
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 type {
DrawerKvRow,
PticaField,
PticaFactorBar,
} from "@/components/site-finder/ptica/ptica-adapt";
import {
adaptPassportDrawer,
adaptBuildabilityDrawer,
adaptUrbanDrawer,
adaptRestrictionsDrawer,
adaptLegalDrawer,
adaptEngineeringDrawer,
adaptMarketDrawer,
adaptEnvironmentDrawer,
adaptGeorisksDrawer,
adaptRisksDrawer,
adaptProductDrawer,
adaptPotentialDrawer,
adaptEconomyDrawer,
adaptFinanceDrawer,
adaptOksDrawer,
} from "@/components/site-finder/ptica/ptica-adapt";
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
import {
DrawerSection,
SummaryBox,
DKvRow,
StatusRow,
DTable,
HBars,
DrawerTabs,
ConfPill,
SourceLine,
DrawerActions,
SoonCard,
} from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
const SRC_UPDATED = "источник: /analyze";
// ── shared helpers ────────────────────────────────────────────────────────────
/** Render a kv field value: muted span + caption tooltip for placeholders. */
function FieldValue({ field }: { field: PticaField }) {
if (field.isReal) return <>{field.value}</>;
return (
<span className={styles.kvPlaceholder} title={field.caption}>
{field.value}
</span>
);
}
function KvRows({ rows }: { rows: DrawerKvRow[] }) {
return (
<>
{rows.map((r) => (
<DKvRow
key={r.k}
k={r.k}
v={<FieldValue field={r.field} />}
muted={!r.field.isReal}
/>
))}
</>
);
}
function factorBars(factors: PticaFactorBar[]) {
return factors.map((f) => ({
name: f.name,
pct: f.pct,
value: f.value,
tone: f.tone,
}));
}
// ══════════════════════════════════════════════════════════════════════════════
// passport
// ══════════════════════════════════════════════════════════════════════════════
export function PassportContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptPassportDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ХАРАКТЕРИСТИКИ УЧАСТКА">
<KvRows rows={d.registry} />
</DrawerSection>
<DrawerSection heading="ОГРАНИЧЕНИЯ · ЗОУИТ">
<StatusRow
label="Зоны с особыми условиями (ЗОУИТ)"
state={d.zouitCount != null ? `${d.zouitCount}` : "нет данных НСПД"}
tone={d.zouitCount != null && d.zouitCount > 0 ? "warn" : "ok"}
/>
{d.zouitOverlaps.length > 0 && (
<DTable
columns={[{ header: "Слой" }, { header: "Подкатегория" }]}
rows={d.zouitOverlaps.map((z) => ({ cells: [z.name, z.sub] }))}
/>
)}
</DrawerSection>
<SourceLine
source="Источник: ЕГРН (Росреестр) · НСПД"
updated={SRC_UPDATED}
/>
<DrawerSection>
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
</DrawerSection>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// buildability
// ══════════════════════════════════════════════════════════════════════════════
export function BuildabilityContent({
analysis,
}: {
analysis: ParcelAnalysis;
}) {
const d = adaptBuildabilityDrawer(analysis);
return (
<>
<DrawerSection>
<div className={styles.drawerGaugeRow}>
<BuildabilityGauge gauge={d.gauge} title="Buildability Index" />
</div>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ВКЛАД ФАКТОРОВ · предв.">
<HBars items={factorBars(d.factors)} />
<DTable
columns={[
{ header: "Фактор" },
{ header: "Оценка" },
{ header: "Обоснование" },
]}
rows={d.factors.map((f) => ({ cells: [f.name, f.value, f.reason] }))}
/>
</DrawerSection>
<DrawerSection heading="ГЕОМЕТРИЯ УЧАСТКА">
<KvRows rows={d.geometry} />
{d.penalties.length > 0 && (
<DTable
columns={[{ header: "Штраф" }, { header: "Влияние" }]}
rows={d.penalties.map((p) => ({ cells: [p.label, p.impact] }))}
/>
)}
</DrawerSection>
<SourceLine
source="Источник: gate_verdict (НСПД) · geometry_suitability · utilities"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// urban
// ══════════════════════════════════════════════════════════════════════════════
export function UrbanContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptUrbanDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="РЕГЛАМЕНТ ЗОНЫ">
<KvRows rows={d.regulation} />
</DrawerSection>
<DrawerSection heading="НСПД · ЗОНИРОВАНИЕ">
<StatusRow
label="Территориальная зона"
state={<FieldValue field={d.zoneCode} />}
tone={d.zoneCode.isReal ? "ok" : "muted"}
/>
<StatusRow
label="Наименование зоны"
state={<FieldValue field={d.zoneName} />}
tone={d.zoneName.isReal ? "ok" : "muted"}
/>
</DrawerSection>
<SourceLine
source="Источник: НСПД-зонирование (nspd_zoning) · ЕГРН"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// restrictions
// ══════════════════════════════════════════════════════════════════════════════
export function RestrictionsContent({
analysis,
}: {
analysis: ParcelAnalysis;
}) {
const d = adaptRestrictionsDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="СТАТУС ОГРАНИЧЕНИЙ">
{d.statusRows.map((r) => (
<StatusRow
key={r.label}
label={r.label}
state={r.state}
tone={r.tone}
/>
))}
</DrawerSection>
{d.zouitRows.length > 0 && (
<DrawerSection heading="ПЕРЕСЕЧЕНИЯ ЗОУИТ">
<DTable
columns={[
{ header: "Слой" },
{ header: "Категория" },
{ header: "Группа" },
]}
rows={d.zouitRows.map((z) => ({ cells: [z.layer, z.sub, z.reg] }))}
/>
</DrawerSection>
)}
<SourceLine
source="Источник: НСПД (nspd_zouit_overlaps) · ЕГРН"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// legal
// ══════════════════════════════════════════════════════════════════════════════
export function LegalContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptLegalDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ГЕЙТ-ВЕРДИКТ">
<KvRows rows={d.gate} />
</DrawerSection>
<DrawerSection heading="ОБРЕМЕНЕНИЯ И ОГРАНИЧЕНИЯ">
{d.restrictions.map((r) => (
<StatusRow
key={r.label}
label={r.label}
state={r.state}
tone={r.tone}
/>
))}
<KvRows rows={d.registry} />
</DrawerSection>
<SourceLine
source="Источник: gate_verdict · ЕГРН · НСПД"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// engineering
// ══════════════════════════════════════════════════════════════════════════════
export function EngineeringContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptEngineeringDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
{d.hasData ? (
<>
<DrawerSection heading="БЛИЖАЙШИЕ ПОДКЛЮЧЕНИЯ (НСПД)">
<DTable
columns={[
{ header: "Ресурс" },
{ header: "Дистанция", num: true },
{ header: "Объект" },
{ header: "В радиусе 2 км", num: true },
]}
rows={d.rows.map((r) => ({
cells: [r.label, r.nearest, r.name, r.count],
}))}
/>
</DrawerSection>
<DrawerSection heading="УДАЛЁННОСТЬ ОТ УЗЛОВ">
<HBars items={factorBars(d.bars)} />
</DrawerSection>
</>
) : (
<DrawerSection>
<div className={styles.emptyState}>
Нет данных по инженерным сетям
</div>
</DrawerSection>
)}
<SourceLine
source="Источник: utilities.summary (НСПД)"
updated={SRC_UPDATED}
/>
{d.hasData && (
<DrawerSection>
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
</DrawerSection>
)}
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// market (4 sub-tabs)
// ══════════════════════════════════════════════════════════════════════════════
export function MarketContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptMarketDrawer(analysis);
const compPanel = (
<>
{d.competitors.length > 0 ? (
<DTable
columns={[
{ header: "ЖК" },
{ header: "Застройщик" },
{ header: "Класс" },
{ header: "Квартир", num: true },
{ header: "Дист.", num: true },
{ header: "Статус" },
]}
rows={d.competitors.map((c) => ({
cells: [c.name, c.dev, c.cls, c.flats, c.distance, c.status],
}))}
/>
) : (
<div className={styles.emptyState}>Конкуренты не найдены</div>
)}
<DKvRow
k="Медиана цены района"
v={<FieldValue field={d.medianField} />}
muted={!d.medianField.isReal}
/>
</>
);
const planPanel = (
<>
<SummaryBox>{d.plan.note}</SummaryBox>
{d.plan.available && d.plan.pipelineByClass.length > 0 && (
<DTable
columns={[{ header: "Класс" }, { header: "Проектов", num: true }]}
rows={d.plan.pipelineByClass.map((p) => ({
cells: [p.cls, String(p.count)],
}))}
/>
)}
</>
);
const speedPanel = d.speed.available ? (
<>
<DKvRow k="Velocity score" v={d.speed.velocityScore} />
<DKvRow k="Темп (объект)" v={d.speed.monthly} />
<DKvRow k="Медиана ЕКБ" v={d.speed.median} />
<DKvRow k="Уверенность" v={d.speed.confidence} />
</>
) : (
<div className={styles.emptyState}>Скорость после прогноза</div>
);
const trendPanel = d.trend.available ? (
<>
<DKvRow k="Δ цены за 6 мес" v={<FieldValue field={d.trend.field} />} />
<DKvRow k="Текущая средняя" v={d.trend.recent} />
<DKvRow k="Прошлая средняя" v={d.trend.prior} />
<DKvRow k="Сделок (период)" v={d.trend.count} />
</>
) : (
<div className={styles.emptyState}>Тренд после прогноза</div>
);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="СРАВНЕНИЕ И ДИНАМИКА">
<DrawerTabs
tabs={[
{ id: "comp", label: "Конкуренты", content: compPanel },
{ id: "plan", label: "Планировки", content: planPanel },
{ id: "speed", label: "Скорость", content: speedPanel },
{ id: "trend", label: "Тренд", content: trendPanel },
]}
/>
</DrawerSection>
<SourceLine
source="Источник: competitors · market_trend · velocity · pipeline_24mo"
updated={SRC_UPDATED}
/>
{d.competitors.length > 0 && (
<DrawerSection>
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
</DrawerSection>
)}
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// environment (Среда — 4 sub-tabs)
// ══════════════════════════════════════════════════════════════════════════════
export function EnvironmentContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptEnvironmentDrawer(analysis);
const noisePanel = (
<>
<KvRows rows={d.noise.rows} />
{d.noise.sources.length > 0 && (
<DTable
columns={[
{ header: "Источник" },
{ header: "дБ", num: true },
{ header: "Дист.", num: true },
]}
rows={d.noise.sources.map((s) => ({ cells: [s.name, s.db, s.dist] }))}
/>
)}
</>
);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ЭКОЛОГИЯ И КЛИМАТ">
<DrawerTabs
tabs={[
{ id: "noise", label: "Шум", content: noisePanel },
{
id: "air",
label: "Воздух",
content: <KvRows rows={d.air.rows} />,
},
{
id: "wind",
label: "Ветер",
content: <KvRows rows={d.wind.rows} />,
},
{
id: "weather",
label: "Погода",
content: <KvRows rows={d.weather.rows} />,
},
]}
/>
</DrawerSection>
<SourceLine
source="Источник: noise · air_quality · wind · weather (/analyze)"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// georisks (3 sub-tabs)
// ══════════════════════════════════════════════════════════════════════════════
export function GeorisksContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptGeorisksDrawer(analysis);
const geologyPanel = (
<>
<KvRows rows={d.geology.rows} />
{d.geology.links.length > 0 && (
<div className={styles.drawerLinks}>
{d.geology.links.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noopener noreferrer"
className={styles.drawerLink}
>
{l.label}
</a>
))}
</div>
)}
</>
);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ИНЖЕНЕРНО-ГЕОЛОГИЧЕСКИЕ УСЛОВИЯ">
<DrawerTabs
tabs={[
{ id: "geology", label: "Геология", content: geologyPanel },
{
id: "hydro",
label: "Гидрология",
content: <KvRows rows={d.hydrology.rows} />,
},
{
id: "geotech",
label: "Геотехника",
content: <KvRows rows={d.geotech.rows} />,
},
]}
/>
</DrawerSection>
<SourceLine
source="Источник: geology · hydrology · geotech_risk (/analyze)"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// risks (composite, derived)
// ══════════════════════════════════════════════════════════════════════════════
export function RisksContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptRisksDrawer(analysis);
return (
<>
<DrawerSection>
<div className={styles.drawerGaugeRow}>
<BuildabilityGauge gauge={d.gauge} title="Сводный риск" />
</div>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="РАЗЛОЖЕНИЕ РИСКА · предв.">
<HBars items={factorBars(d.breakdown)} />
<DTable
columns={[
{ header: "Компонент" },
{ header: "Оценка" },
{ header: "Источник" },
]}
rows={d.breakdown.map((f) => ({
cells: [f.name, f.value, f.reason],
}))}
/>
</DrawerSection>
{(d.blockers.length > 0 || d.warnings.length > 0) && (
<DrawerSection heading="ГЕЙТ · БЛОКЕРЫ И ПРЕДУПРЕЖДЕНИЯ">
{d.blockers.map((b) => (
<StatusRow
key={b.code}
label={b.detail}
state="блокер"
tone="bad"
/>
))}
{d.warnings.map((w) => (
<StatusRow
key={w.code}
label={w.detail}
state="предупр."
tone="warn"
/>
))}
</DrawerSection>
)}
<SourceLine
source="Источник: gate_verdict · geometry_suitability · noise"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// product (§22 product_tz)
// ══════════════════════════════════════════════════════════════════════════════
export function ProductContent({
forecastReport,
}: {
forecastReport?: ForecastReport;
}) {
const d = adaptProductDrawer(forecastReport);
if (!d.available) {
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection>
<div className={styles.emptyState}>После прогноза (Сценарии)</div>
</DrawerSection>
</>
);
}
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="РЕКОМЕНДОВАННЫЙ КЛАСС">
<DKvRow
k="Класс продукта"
v={
d.objClass.isReal ? (
<ConfPill level="high">{d.objClass.value}</ConfPill>
) : (
<FieldValue field={d.objClass} />
)
}
muted={!d.objClass.isReal}
/>
</DrawerSection>
{d.mix.length > 0 && (
<DrawerSection heading="КВАРТИРОГРАФИЯ">
<HBars
items={d.mix.map((m) => ({
name: m.bucket,
pct: m.pct,
value: m.signal,
tone: "neutral",
}))}
/>
</DrawerSection>
)}
{d.usp.length > 0 && (
<DrawerSection heading="USP-НИШИ">
<DTable
columns={[{ header: "Ниша" }, { header: "Класс" }]}
rows={d.usp.map((u) => ({ cells: [u.text, u.cls] }))}
/>
</DrawerSection>
)}
{d.reasons.length > 0 && (
<DrawerSection heading="ОБОСНОВАНИЕ">
{d.reasons.map((r, i) => (
<p key={i} className={styles.drawerNote}>
{r}
</p>
))}
</DrawerSection>
)}
<SourceLine
source="Источник: §22 product_tz (advisory)"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// potential
// ══════════════════════════════════════════════════════════════════════════════
export function PotentialContent({ analysis }: { analysis: ParcelAnalysis }) {
const d = adaptPotentialDrawer(analysis);
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ЦЕПОЧКА РАСЧЁТА ЁМКОСТИ">
<KvRows rows={d.rows} />
</DrawerSection>
<SourceLine
source="Источник: geometry_suitability · НСПД-зонирование (max_far)"
updated={SRC_UPDATED}
/>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// economy / finance
// ══════════════════════════════════════════════════════════════════════════════
export function EconomyContent() {
const d = adaptEconomyDrawer();
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="КЛЮЧЕВЫЕ ПОКАЗАТЕЛИ · после финмодели">
<KvRows rows={d.rows} />
</DrawerSection>
<SourceLine source="Источник: финмодель §22 (асинхронно)" />
</>
);
}
export function FinanceContent() {
const d = adaptFinanceDrawer();
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ФИНАНСОВАЯ МОДЕЛЬ · после финмодели">
<KvRows rows={d.rows} />
</DrawerSection>
<SourceLine source="Источник: финмодель §22 (DCF, асинхронно)" />
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// oks
// ══════════════════════════════════════════════════════════════════════════════
export function OksContent() {
const d = adaptOksDrawer();
return (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection>
<div className={styles.emptyState}>Нет данных</div>
</DrawerSection>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// insolation / future3d — СКОРО (3D module lands in PR#5)
// ══════════════════════════════════════════════════════════════════════════════
export function InsolationContent() {
return (
<>
<DrawerSection>
<SoonCard
text="Расчёт инсоляции и теней (КЕО, продолжительность инсоляции по СанПиН) по 3D-массе застройки и окружению."
roadmap="Roadmap · после 3D-модуля (PR#5)"
/>
</DrawerSection>
<DrawerSection heading="ЧТО БУДЕТ В МОДУЛЕ">
<StatusRow
label="Карта инсоляции фасадов"
state="в разработке"
tone="warn"
/>
<StatusRow
label="Теневой анализ по сезонам"
state="в разработке"
tone="warn"
/>
<StatusRow
label="Проверка норм СанПиН / КЕО"
state="в разработке"
tone="warn"
/>
</DrawerSection>
</>
);
}
export function Future3dContent() {
return (
<>
<DrawerSection>
<SoonCard
text="Объёмная модель застройки по КСИТ / пятну / высоте с проверкой норм и предпросмотром продукта."
roadmap="Roadmap · PR#5 (Three.js)"
/>
</DrawerSection>
<DrawerSection heading="СОСТАВ МОДУЛЯ">
<StatusRow
label="Генеративная масса по регламенту"
state="в разработке"
tone="warn"
/>
<StatusRow
label="Этажность и плотность 3D"
state="в разработке"
tone="warn"
/>
<StatusRow
label="Экспорт в DXF / IFC"
state="в разработке"
tone="warn"
/>
</DrawerSection>
</>
);
}

View file

@ -0,0 +1,307 @@
"use client";
/**
* DrawerPrimitives typed React port of the prototype's drawer markup vocabulary
* (`.drawer-section` `.summary-box` table / kvrows / status-rows / hbars
* `.source-line` `.drawer-actions`). Pure presentational building blocks the
* per-key drawer content modules compose; no data logic lives here.
*
* Sub-tabs (`.dtabs` / `.dtab-panel`) are a self-contained client component
* (DrawerTabs) that manages the active tab with local useState the cockpit's
* market / environment / georisks drawers reuse it.
*/
import { useId, useState } from "react";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
// ── Section + summary ─────────────────────────────────────────────────────────
export function DrawerSection({
heading,
children,
}: {
heading?: string;
children: React.ReactNode;
}) {
return (
<div className={styles.drawerSection}>
{heading && <h4 className={styles.drawerH4}>{heading}</h4>}
{children}
</div>
);
}
export function SummaryBox({ children }: { children: React.ReactNode }) {
return <div className={styles.summaryBox}>{children}</div>;
}
// ── Key/value rows ────────────────────────────────────────────────────────────
export function DKvRow({
k,
v,
muted,
}: {
k: React.ReactNode;
v: React.ReactNode;
muted?: boolean;
}) {
return (
<div className={`${styles.kvrow} ${muted ? styles.kvrowPlaceholder : ""}`}>
<span className={styles.k}>{k}</span>
<span className={styles.v}>{v}</span>
</div>
);
}
// ── Status row (dot + label + state pill) ─────────────────────────────────────
export type DrawerTone = "ok" | "warn" | "bad" | "muted";
export function StatusRow({
label,
state,
tone = "muted",
}: {
label: React.ReactNode;
state: React.ReactNode;
tone?: DrawerTone;
}) {
const dotClass =
tone === "ok"
? styles.drDotOk
: tone === "warn"
? styles.drDotWarn
: tone === "bad"
? styles.drDotBad
: styles.drDotMuted;
const stateClass =
tone === "ok"
? styles.drStateOk
: tone === "warn"
? styles.drStateWarn
: tone === "bad"
? styles.drStateBad
: styles.drStateMuted;
return (
<div className={styles.statusRow}>
<span className={`${styles.drDot} ${dotClass}`} aria-hidden="true" />
<span className={styles.drLabel}>{label}</span>
<span className={`${styles.drState} ${stateClass}`}>{state}</span>
</div>
);
}
// ── Data table ────────────────────────────────────────────────────────────────
export interface DTableColumn {
header: string;
/** true → right-aligned mono cell (numbers). */
num?: boolean;
}
export interface DTableRow {
cells: React.ReactNode[];
/** highlight (e.g. totals / target). */
emphasis?: boolean;
}
export function DTable({
columns,
rows,
}: {
columns: DTableColumn[];
rows: DTableRow[];
}) {
return (
<table className={styles.dtable}>
<thead>
<tr>
{columns.map((c, i) => (
<th key={i} className={c.num ? styles.thNum : undefined}>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className={r.emphasis ? styles.rowTarget : undefined}>
{r.cells.map((cell, ci) => (
<td
key={ci}
className={columns[ci]?.num ? styles.tdNum : undefined}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// ── Horizontal bars (квартирография / contributions) ──────────────────────────
export interface HBarItem {
name: string;
/** 0..100 fill width. */
pct: number;
value: React.ReactNode;
tone?: "good" | "bad" | "neutral";
}
export function HBars({ items }: { items: HBarItem[] }) {
return (
<div className={styles.hbars}>
{items.map((b, i) => {
const fillClass =
b.tone === "good"
? styles.hbarFillGood
: b.tone === "bad"
? styles.hbarFillBad
: styles.hbarFillNeutral;
return (
<div key={i} className={styles.hbar}>
<span className={styles.hbarName}>{b.name}</span>
<span className={styles.hbarTrack}>
<span
className={`${styles.hbarFill} ${fillClass}`}
style={{ width: `${Math.max(0, Math.min(100, b.pct))}%` }}
/>
</span>
<span className={styles.hbarValue}>{b.value}</span>
</div>
);
})}
</div>
);
}
// ── Confidence pill / tag ─────────────────────────────────────────────────────
export function ConfPill({
level,
children,
}: {
level: "high" | "medium" | "low";
children: React.ReactNode;
}) {
const cls =
level === "high"
? styles.confPillHigh
: level === "medium"
? styles.confPillMedium
: styles.confPillLow;
return <span className={`${styles.confPill} ${cls}`}>{children}</span>;
}
export function Tag({ children }: { children: React.ReactNode }) {
return <span className={styles.tag}>{children}</span>;
}
// ── Source line + export actions ──────────────────────────────────────────────
export function SourceLine({
source,
updated,
}: {
source: string;
updated?: string;
}) {
return (
<div className={styles.sourceLine}>
<span>{source}</span>
{updated && <span>{updated}</span>}
</div>
);
}
/**
* DrawerActions light export/copy buttons (kept inert: the cockpit's drawer
* exports are not wired to an endpoint yet, so these are disabled placeholders
* matching the prototype's `.drawer-actions` rather than fake-firing a download).
*/
export function DrawerActions({ labels }: { labels: string[] }) {
return (
<div className={styles.drawerActions}>
{labels.map((l) => (
<button
key={l}
type="button"
className={styles.detailBtn}
disabled
aria-disabled="true"
title="Экспорт — в следующем релизе"
>
{l}
</button>
))}
</div>
);
}
// ── Placeholder card (СКОРО) ──────────────────────────────────────────────────
export function SoonCard({
text,
roadmap,
}: {
text: string;
roadmap?: string;
}) {
return (
<div className={`${styles.card} ${styles.soonCard}`}>
<div className={styles.soon}>СКОРО</div>
<p>{text}</p>
{roadmap && <div className={styles.roadmap}>{roadmap}</div>}
</div>
);
}
// ── Sub-tabs (.dtabs / .dtab-panel) ───────────────────────────────────────────
export interface DrawerTab {
id: string;
label: string;
content: React.ReactNode;
}
export function DrawerTabs({ tabs }: { tabs: DrawerTab[] }) {
const [active, setActive] = useState<string>(tabs[0]?.id ?? "");
const groupId = useId();
return (
<>
<div className={styles.dtabs} role="tablist">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`${groupId}-tab-${t.id}`}
aria-selected={active === t.id}
aria-controls={`${groupId}-panel-${t.id}`}
className={active === t.id ? styles.dtabActive : ""}
onClick={() => setActive(t.id)}
>
{t.label}
</button>
))}
</div>
{tabs.map((t) => (
<div
key={t.id}
role="tabpanel"
id={`${groupId}-panel-${t.id}`}
aria-labelledby={`${groupId}-tab-${t.id}`}
hidden={active !== t.id}
className={styles.dtabPanel}
>
{active === t.id && t.content}
</div>
))}
</>
);
}

View file

@ -0,0 +1,95 @@
"use client";
/**
* PticaDrawer controlled right-slider detail drawer (CoStar drill-down pattern,
* per ui-conventions.md: 480px desktop, full-screen on phone).
*
* Behaviour ported from the prototype `openDrawer`/`closeDrawer` (app.js):
* - scrim click closes,
* - Escape closes,
* - the close button is focused on open (a11y),
* - body scroll is locked while open.
*
* `role="dialog"` + `aria-modal` + `aria-label` make it a proper modal surface.
* The component is fully controlled `open` drives mount/visibility, the host
* (PticaPageContent) owns the open-key state and URL deep-link.
*/
import { useEffect, useRef } from "react";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
interface Props {
open: boolean;
title: string;
/** Sub-title under the drawer title (e.g. "Полные данные ЕГРН / НСПД"). */
sub?: string;
onClose: () => void;
children: React.ReactNode;
}
export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
const closeRef = useRef<HTMLButtonElement | null>(null);
// Esc-to-close + body-scroll-lock + focus the close button on open.
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Focus the close button so keyboard users land inside the dialog.
closeRef.current?.focus();
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
return (
<>
<div
className={`${styles.drawerScrim} ${open ? styles.drawerScrimOpen : ""}`}
onClick={onClose}
aria-hidden="true"
/>
<aside
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
role="dialog"
aria-modal="true"
aria-label={title}
aria-hidden={!open}
>
<div className={styles.drawerHead}>
<div>
<h3 className={styles.drawerTitle}>{title}</h3>
{sub && <div className={styles.drawerSub}>{sub}</div>}
</div>
<button
ref={closeRef}
type="button"
className={styles.drawerClose}
onClick={onClose}
aria-label="Закрыть"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<div className={styles.drawerBody}>{open && children}</div>
</aside>
</>
);
}

View file

@ -0,0 +1,41 @@
/**
* DrawerKey the canonical string-literal union of every detail-drawer the
* cockpit can open. Kept in its own (pure, no-React) module so both the trigger
* components (ScanCard / hero cards) and the registry import the same union
* without pulling in React content.
*
* Keys mirror the prototype `#drawer-templates` (`<template data-key="...">`) so
* deep-links (`?drawer=market`) line up 1:1 with the redesign reference.
*/
export const DRAWER_KEYS = [
"passport",
"buildability",
"urban",
"restrictions",
"engineering",
"market",
"economy",
"risks",
"georisks",
"oks",
"potential",
"product",
"finance",
"legal",
"environment",
"insolation",
"future3d",
] as const;
export type DrawerKey = (typeof DRAWER_KEYS)[number];
/** Narrow an arbitrary string (e.g. a `?drawer=` query value) to a DrawerKey. */
export function asDrawerKey(
value: string | null | undefined,
): DrawerKey | null {
if (!value) return null;
return (DRAWER_KEYS as readonly string[]).includes(value)
? (value as DrawerKey)
: null;
}

View file

@ -0,0 +1,151 @@
"use client";
/**
* drawer-registry maps each DrawerKey { title, sub, content }, building the
* content node from `analysis` (ParcelAnalysis) + optional `forecastReport`
* (ForecastReport). PticaPageContent renders the matching entry inside a single
* controlled <PticaDrawer>. Titles / subs mirror the prototype `data-title` /
* `data-sub`.
*/
import type { ReactNode } from "react";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
import {
PassportContent,
BuildabilityContent,
UrbanContent,
RestrictionsContent,
LegalContent,
EngineeringContent,
MarketContent,
EnvironmentContent,
GeorisksContent,
RisksContent,
ProductContent,
PotentialContent,
EconomyContent,
FinanceContent,
OksContent,
InsolationContent,
Future3dContent,
} from "@/components/site-finder/ptica/drawers/DrawerContent";
export interface DrawerEntry {
title: string;
sub: string;
content: ReactNode;
}
export function getDrawerEntry(
key: DrawerKey,
analysis: ParcelAnalysis,
forecastReport?: ForecastReport,
): DrawerEntry {
switch (key) {
case "passport":
return {
title: "Паспорт участка",
sub: "Полные данные ЕГРН / НСПД",
content: <PassportContent analysis={analysis} />,
};
case "buildability":
return {
title: "Buildability Index",
sub: "Из чего складывается оценка · предв.",
content: <BuildabilityContent analysis={analysis} />,
};
case "urban":
return {
title: "Градостроительство",
sub: "ПЗЗ · регламент · НСПД",
content: <UrbanContent analysis={analysis} />,
};
case "restrictions":
return {
title: "Ограничения · ЗОУИТ",
sub: "Зоны с особыми условиями",
content: <RestrictionsContent analysis={analysis} />,
};
case "engineering":
return {
title: "Инженерия",
sub: "Точки подключения ресурсов",
content: <EngineeringContent analysis={analysis} />,
};
case "market":
return {
title: "Рынок",
sub: "Конкуренты · планировки · скорость · тренд",
content: <MarketContent analysis={analysis} />,
};
case "economy":
return {
title: "Экономика",
sub: "Предварительная оценка · после финмодели",
content: <EconomyContent />,
};
case "risks":
return {
title: "Сводный риск",
sub: "Композиция факторов · предв.",
content: <RisksContent analysis={analysis} />,
};
case "georisks":
return {
title: "Геориски",
sub: "Геология · гидрология · геотехника",
content: <GeorisksContent analysis={analysis} />,
};
case "oks":
return {
title: "Объекты кап. строительства",
sub: "ОКС на участке",
content: <OksContent />,
};
case "potential":
return {
title: "Расчёт ёмкости",
sub: "Плотность · пятно · продаваемая",
content: <PotentialContent analysis={analysis} />,
};
case "product":
return {
title: "Продуктовая стратегия",
sub: "Рекомендуемый класс и квартирография",
content: <ProductContent forecastReport={forecastReport} />,
};
case "finance":
return {
title: "Финансовая модель",
sub: "Investment Clearance",
content: <FinanceContent />,
};
case "legal":
return {
title: "Правовой статус",
sub: "Обременения, ограничения и регламент",
content: <LegalContent analysis={analysis} />,
};
case "environment":
return {
title: "Среда · Экология",
sub: "Шум · воздух · ветер · погода",
content: <EnvironmentContent analysis={analysis} />,
};
case "insolation":
return {
title: "Инсоляционная матрица",
sub: "Планируется в следующей версии",
content: <InsolationContent />,
};
case "future3d":
return {
title: "3D-масса застройки",
sub: "Генеративная масса по регламенту",
content: <Future3dContent />,
};
}
}

View file

@ -10,8 +10,11 @@
* truth for the real-vs-placeholder decision.
*/
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ──────────────────────────────────────────────────────────────
@ -423,6 +426,950 @@ export function adaptOksCard(): PticaScanCard {
};
}
// ══════════════════════════════════════════════════════════════════════════════
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
// ══════════════════════════════════════════════════════════════════════════════
//
// Each drawer consumes a typed view-model below rather than reaching into
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
// muted by the drawer content so they never read as live.
const DASH = "—";
function notReal(caption: string): PticaField {
return { value: DASH, isReal: false, caption };
}
/** A single "Параметр → Значение" row with real/placeholder provenance. */
export interface DrawerKvRow {
k: string;
field: PticaField;
}
function metersField(
m: number | null | undefined,
caption: string,
): PticaField {
return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
}
// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────
export interface PticaPassportDrawer {
summary: string;
registry: DrawerKvRow[];
zouitCount: number | null;
zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
}
export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
const p = adaptPassport(a);
const areaHa = a.geometry_suitability?.area_ha;
const zouit = a.nspd_zouit_overlaps ?? null;
const districtPart = a.district?.district_name
? `, ${a.district.district_name} р`
: "";
const summary =
`Участок ${a.cad_num}${districtPart}. ` +
(a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
(a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
(a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");
const registry: DrawerKvRow[] = [
{ k: "Кадастровый номер", field: real(a.cad_num) },
{ k: "Адрес", field: p.address },
{ k: "Район", field: p.district },
{ k: "Площадь", field: p.area },
{
k: "Площадь, га",
field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
},
{ k: "Категория земель", field: p.landCategory },
{ k: "ВРИ", field: p.vri },
{ k: "Статус ЕГРН", field: p.status },
{
k: "Кадастровая стоимость",
field:
a.egrn?.cadastral_value_rub != null
? real(`${formatInt(a.egrn.cadastral_value_rub)}`)
: notReal("нет данных ЕГРН"),
},
{
k: "Стоимость, ₽/м²",
field:
a.egrn?.cost_per_m2_rub != null
? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
: notReal("нет данных ЕГРН"),
},
{
k: "Дата обновления ЕГРН",
field: a.egrn?.last_egrn_update_date
? real(a.egrn.last_egrn_update_date)
: notReal("нет данных ЕГРН"),
},
{ k: "Обременения", field: { value: DASH, isReal: true } },
];
const zouitOverlaps = (zouit ?? []).map((z) => ({
layer: z.layer,
name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
sub: z.subcategory != null ? String(z.subcategory) : DASH,
}));
return {
summary,
registry,
zouitCount: zouit != null ? zouit.length : null,
zouitOverlaps,
};
}
// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────
export interface PticaFactorBar {
name: string;
pct: number;
value: string;
tone: "good" | "bad" | "neutral";
reason: string;
}
export interface PticaBuildabilityDrawer {
gauge: PticaGauge;
summary: string;
factors: PticaFactorBar[];
geometry: DrawerKvRow[];
penalties: Array<{ label: string; impact: string }>;
isReal: boolean;
}
export function adaptBuildabilityDrawer(
a: ParcelAnalysis,
): PticaBuildabilityDrawer {
const gauge = adaptBuildabilityGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const utilCount = a.utilities?.summary?.length ?? null;
// Soft, derived factor contributions — labelled «предв.» everywhere.
const factors: PticaFactorBar[] = [
{
name: "Гейт-вердикт",
pct:
gate?.can_build_mkd === true
? 90
: gate?.can_build_mkd === false
? 20
: 55,
value: gate?.verdict_label ?? DASH,
tone:
gate?.can_build_mkd === true
? "good"
: gate?.can_build_mkd === false
? "bad"
: "neutral",
reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
},
{
name: "Геометрия",
pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
value:
geom?.suitability_score != null
? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
},
{
name: "Инженерия",
pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
value: utilCount != null ? `${utilCount} сетей` : DASH,
tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
reason: "близость инж. узлов (utilities)",
},
];
const geometry: DrawerKvRow[] = [
{
k: "Площадь",
field:
geom?.area_ha != null
? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
: notReal("нет геометрии"),
},
{
k: "Периметр",
field: metersField(geom?.perimeter_m, "нет геометрии"),
},
{
k: "Соотношение сторон",
field:
geom?.aspect_ratio != null
? real(fmtNum(geom.aspect_ratio, 2))
: notReal("нет геометрии"),
},
{
k: "Suitability score",
field:
geom?.suitability_score != null
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
: notReal("нет геометрии"),
},
{ k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
{ k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") },
];
const penalties = (geom?.penalties ?? []).map((p) => ({
label: p,
impact: "штраф",
}));
const summary =
"Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
return {
gauge,
summary,
factors,
geometry,
penalties,
isReal: false,
};
}
// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────
export interface PticaUrbanDrawer {
summary: string;
regulation: DrawerKvRow[];
zoneCode: PticaField;
zoneName: PticaField;
}
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
const zoning = a.nspd_zoning;
const zoneCode = zoning?.zone_code
? real(zoning.zone_code)
: notReal("источник НСПД-зонирование");
const zoneName = zoning?.zone_name
? real(zoning.zone_name)
: notReal("источник НСПД-зонирование");
const regulation: DrawerKvRow[] = [
{ k: "Территориальная зона", field: zoneCode },
{ k: "Наименование зоны", field: zoneName },
{
k: "ВРИ (основной)",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{ k: "КСИТ (max FAR)", field: notReal("нужен max_far (НСПД)") },
{ k: "Предельная высота", field: notReal("источник НСПД-зонирование") },
{
k: "Коэфф. застройки (пятно)",
field: notReal("источник НСПД-зонирование"),
},
{ k: "Плотность (КСИТ)", field: notReal("после расчёта потенциала") },
];
const summary =
"Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
"недоступны в выгрузке — отмечены «—» с источником.";
return { summary, regulation, zoneCode, zoneName };
}
// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────
export interface PticaRestrictionsDrawer {
summary: string;
zouitCount: number | null;
zouitRows: Array<{ layer: string; sub: string; reg: string }>;
statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
}
export function adaptRestrictionsDrawer(
a: ParcelAnalysis,
): PticaRestrictionsDrawer {
const zouit = a.nspd_zouit_overlaps ?? null;
const count = zouit != null ? zouit.length : null;
const zouitRows = (zouit ?? []).map((z) => ({
layer: z.name ?? z.type_zone ?? z.layer,
sub:
z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
reg: z.group_key,
}));
const statusRows: Array<{
label: string;
state: string;
tone: "ok" | "warn";
}> = [
{
label: "ЗОУИТ (зоны с особыми условиями)",
state: count != null ? `${count}` : "нет данных НСПД",
tone: count != null && count > 0 ? "warn" : "ok",
},
{
label: "Красные линии",
state:
a.nspd_red_lines != null
? `${a.nspd_red_lines.length}`
: "источник НСПД",
tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
},
{
label: "ОКН (объекты культ. наследия)",
state: "источник НСПД",
tone: "ok",
},
{ label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
];
const summary =
count != null
? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
"Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
: "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
"сервитуты отмечены источником.";
return { summary, zouitCount: count, zouitRows, statusRows };
}
export interface PticaLegalDrawer {
summary: string;
gate: DrawerKvRow[];
restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
registry: DrawerKvRow[];
}
export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
const gate = a.gate_verdict;
const restr = adaptRestrictionsDrawer(a);
const gateRows: DrawerKvRow[] = [
{
k: "Можно строить МКД",
field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
},
{
k: "Статус участка",
field: a.egrn?.parcel_status
? real(a.egrn.parcel_status)
: notReal("нет данных ЕГРН"),
},
{
k: "Категория земель",
field: a.egrn?.land_category
? real(a.egrn.land_category)
: notReal("нет данных ЕГРН"),
},
{
k: "ВРИ",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{
k: "Зона ПЗЗ",
field: a.nspd_zoning?.zone_code
? real(a.nspd_zoning.zone_code)
: notReal("источник НСПД"),
},
];
const registry: DrawerKvRow[] = [
{
k: "ЗОУИТ",
field:
restr.zouitCount != null
? real(formatInt(restr.zouitCount))
: notReal("источник НСПД"),
},
{ k: "Обременения", field: { value: DASH, isReal: true } },
{
k: "Блокеры (gate)",
field: gate
? real(formatInt(gate.blockers.length))
: notReal("нет gate_verdict"),
},
{
k: "Предупреждения (gate)",
field: gate
? real(formatInt(gate.warnings.length))
: notReal("нет gate_verdict"),
},
];
const summary =
"Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
"ограничений. Обременения в текущей выгрузке не раскрываются («—»).";
return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
}
// ── engineering — utilities.summary (REAL) ────────────────────────────────────
export interface PticaEngineeringDrawer {
summary: string;
rows: Array<{ label: string; nearest: string; name: string; count: string }>;
bars: PticaFactorBar[];
hasData: boolean;
}
export function adaptEngineeringDrawer(
a: ParcelAnalysis,
): PticaEngineeringDrawer {
const summaryArr = a.utilities?.summary ?? [];
const hasData = summaryArr.length > 0;
const rows = summaryArr.map((u) => ({
label: utilityLabel(u.subtype),
nearest: `${formatInt(u.nearest_m)} м`,
name: u.name ?? DASH,
count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
}));
// bar = inverse distance (closer → fuller). Cap reference 2000 m.
const bars: PticaFactorBar[] = summaryArr.map((u) => ({
name: utilityLabel(u.subtype),
pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
value: `${formatInt(u.nearest_m)} м`,
tone: u.nearest_m <= 700 ? "good" : "neutral",
reason: u.name ?? "",
}));
const summary = hasData
? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
"анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
: "Данные по инженерным сетям недоступны для участка.";
return { summary, rows, bars, hasData };
}
// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────
export interface PticaMarketCompetitor {
name: string;
dev: string;
cls: string;
flats: string;
distance: string;
status: string;
}
export interface PticaMarketDrawer {
summary: string;
competitors: PticaMarketCompetitor[];
medianField: PticaField;
trend: {
field: PticaField;
recent: string;
prior: string;
count: string;
available: boolean;
};
speed: {
field: PticaField;
velocityScore: string;
monthly: string;
median: string;
confidence: string;
available: boolean;
};
plan: {
available: boolean;
note: string;
pipelineByClass: Array<{ cls: string; count: number }>;
};
}
function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
return {
name: c.comm_name ?? `#${c.obj_id}`,
dev: c.dev_name ?? DASH,
cls: c.obj_class ?? DASH,
flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
distance: `${formatInt(c.distance_m)} м`,
status: c.site_status ?? DASH,
};
}
export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
const median = a.district?.median_price_per_m2;
const medianField =
median != null
? real(formatRubPerM2(median))
: notReal("нет данных района");
const mt = a.market_trend;
const trend = {
field:
mt != null
? real(
`${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`,
)
: notReal("после прогноза"),
recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH,
prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH,
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
available: mt != null,
};
const v = a.velocity ?? null;
const speed = {
field:
v != null
? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
: notReal("после прогноза"),
velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
confidence: v != null ? v.confidence : DASH,
available: v != null,
};
const pipeline = a.pipeline_24mo ?? null;
const pipelineByClass = pipeline
? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
: [];
const plan = {
available: pipeline != null,
note:
pipeline != null
? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
`${formatInt(pipeline.flats_total)} квартир.`
: "Планировки и квартирография — после прогноза (§22).",
pipelineByClass,
};
const summary =
`Конкурентов в радиусе анализа: ${competitors.length}` +
(median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
(pipeline != null
? `, ${pipeline.objects_count} проектов в пайплайне`
: "") +
". Тренд, скорость и планировки уточняются прогнозом §22.";
return { summary, competitors, medianField, trend, speed, plan };
}
// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────
export interface PticaEnvironmentDrawer {
summary: string;
noise: {
rows: DrawerKvRow[];
sources: Array<{ name: string; db: string; dist: string }>;
available: boolean;
};
air: { rows: DrawerKvRow[]; available: boolean };
wind: { rows: DrawerKvRow[]; available: boolean };
weather: { rows: DrawerKvRow[]; available: boolean };
}
const WIND_DIR_NOTE = "источник: прогноз ветра";
export function adaptEnvironmentDrawer(
a: ParcelAnalysis,
): PticaEnvironmentDrawer {
const n = a.noise;
const aq = a.air_quality;
const w = a.wind;
const wt = a.weather;
const noiseRows: DrawerKvRow[] = n
? [
{ k: "Уровень", field: real(n.level) },
{ k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
{ k: "Скор", field: real(fmtNum(n.score, 2)) },
{ k: "Источников шума", field: real(formatInt(n.sources.length)) },
]
: [{ k: "Шум", field: notReal("нет данных шума") }];
const airRows: DrawerKvRow[] = aq
? [
{ k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
{ k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
{ k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
{ k: "Источник", field: real(aq.source) },
]
: [{ k: "Воздух", field: notReal("нет данных AQI") }];
const windRows: DrawerKvRow[] = w
? [
{
k: "Направление",
field: real(
`${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
),
},
{
k: "Макс. скорость",
field:
w.max_speed_m_s != null
? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
: notReal(WIND_DIR_NOTE),
},
{
k: "Горизонт прогноза",
field: real(`${formatInt(w.forecast_days)} дн.`),
},
]
: [{ k: "Ветер", field: notReal("нет данных ветра") }];
const weatherRows: DrawerKvRow[] = wt
? [
{
k: "Ср. макс. °C",
field:
wt.temperature.avg_max_c != null
? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Ср. мин. °C",
field:
wt.temperature.avg_min_c != null
? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Осадки (сумма)",
field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
},
{ k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
]
: [{ k: "Погода", field: notReal("нет данных погоды") }];
const sources = (n?.sources ?? []).map((s) => ({
name: s.name ?? s.source_type,
db: `${formatInt(s.estimated_db)} дБ`,
dist: `${formatInt(s.distance_m)} м`,
}));
const summary =
"Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
"погодный фон. Данные из атмосферного слоя /analyze.";
return {
summary,
noise: { rows: noiseRows, sources, available: n != null },
air: { rows: airRows, available: aq != null },
wind: { rows: windRows, available: w != null },
weather: { rows: weatherRows, available: wt != null },
};
}
// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────
export interface PticaGeorisksDrawer {
summary: string;
geology: {
rows: DrawerKvRow[];
links: Array<{ label: string; href: string }>;
available: boolean;
};
hydrology: { rows: DrawerKvRow[]; available: boolean };
geotech: { rows: DrawerKvRow[]; available: boolean };
}
export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
const g = a.geology;
const h = a.hydrology;
const gt = a.geotech_risk;
const geologyRows: DrawerKvRow[] = g
? [
{
k: "Данные геологии",
field: g.data_available
? real("доступны")
: notReal("нет ИГИ — нужен полевой расчёт"),
},
{ k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
]
: [{ k: "Геология", field: notReal("нет данных геологии") }];
const geologyLinks = g
? [
{ label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
{ label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
]
: [];
const hydrologyRows: DrawerKvRow[] = h
? [
{
k: "Риск подтопления",
field: real(h.flood_risk_flag ? "есть" : "нет"),
},
{
k: "Ближайший водоём",
field:
h.nearest.length > 0
? real(`${formatInt(h.nearest[0].distance_m)} м`)
: notReal("нет данных"),
},
{ k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
]
: [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];
const geotechRows: DrawerKvRow[] = gt
? [
{ k: "Сейсмика", field: real(gt.seismic_label) },
{
k: "Баллы (сейсм.)",
field:
gt.seismic_intensity_balls != null
? real(formatInt(gt.seismic_intensity_balls))
: notReal("нет данных"),
},
{
k: "Многолетняя мерзлота",
field: real(gt.permafrost ? "есть" : "нет"),
},
{
k: "Пром. объектов <500 м",
field: real(formatInt(gt.industrial_within_500m)),
},
]
: [{ k: "Геотехника", field: notReal("нет данных геотехники") }];
const summary =
"Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
"риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";
return {
summary,
geology: { rows: geologyRows, links: geologyLinks, available: g != null },
hydrology: { rows: hydrologyRows, available: h != null },
geotech: { rows: geotechRows, available: gt != null },
};
}
// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────
export interface PticaRisksDrawer {
gauge: PticaGauge;
summary: string;
breakdown: PticaFactorBar[];
blockers: Array<{ code: string; detail: string }>;
warnings: Array<{ code: string; detail: string }>;
isReal: boolean;
}
export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
const gauge = adaptRiskGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const n = a.noise;
const breakdown: PticaFactorBar[] = [
{
name: "Правовые / гейт",
pct: gate
? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
: 0,
value: gate
? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
: DASH,
tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
reason: gate ? "из gate_verdict" : "нет gate_verdict",
},
{
name: "Геометрия",
pct:
geom?.suitability_score != null
? (1 - geom.suitability_score) * 100
: 0,
value:
geom?.suitability_score != null
? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? geom.label : "нет геометрии",
},
{
name: "Шум / среда",
pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
reason: n != null ? "из шумового слоя" : "нет данных шума",
},
];
const summary =
"Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
"средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";
return {
gauge,
summary,
breakdown,
blockers: gate?.blockers ?? [],
warnings: gate?.warnings ?? [],
isReal: false,
};
}
// ── product — §22 product_tz («после прогноза» while absent) ───────────────────
export interface PticaProductDrawer {
available: boolean;
summary: string;
objClass: PticaField;
mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
usp: Array<{ text: string; cls: string }>;
reasons: string[];
}
function mixBarPct(entry: ProductMixEntry, total: number): number {
if (entry.pct != null) return entry.pct;
// No explicit share → even split as a visual hint only.
return total > 0 ? Math.round(100 / total) : 0;
}
export function adaptProductDrawer(
report?: ForecastReport,
): PticaProductDrawer {
const tz = report?.product_tz;
if (!tz) {
return {
available: false,
summary:
"Продуктовая стратегия (класс, квартирография, USP) формируется в " +
"асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
objClass: notReal("после прогноза (Scenarios)"),
mix: [],
usp: [],
reasons: [],
};
}
const mix = tz.mix.map((m) => ({
bucket: m.bucket ?? DASH,
cls: m.obj_class ?? DASH,
signal: m.signal ?? DASH,
pct: mixBarPct(m, tz.mix.length),
}));
const usp = tz.usp.map((u) => ({
text: u.usp_text ?? DASH,
cls: u.obj_class ?? DASH,
}));
const reasons = tz.reasons
.map((r) => r.why)
.filter((w): w is string => Boolean(w));
return {
available: true,
summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
objClass: tz.obj_class
? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
: notReal("нет рекомендации класса"),
mix,
usp,
reasons,
};
}
// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────
export interface PticaPotentialDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const areaHa = a.geometry_suitability?.area_ha;
const areaField =
areaHa != null
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
: notReal("нет геометрии");
const rows: DrawerKvRow[] = [
{ k: "Площадь участка", field: areaField },
{ k: "КСИТ (max_far)", field: notReal("нужен max_far (НСПД)") },
{ k: "Ёмкость по КСИТ", field: notReal("нужен max_far (НСПД)") },
{ k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
{ k: "Продаваемая площадь", field: notReal("после расчёта потенциала") },
{
k: "Расчётная этажность",
field: notReal("нужна предельная высота (НСПД)"),
},
];
const summary =
"Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
return { summary, rows };
}
// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────
export interface PticaEconomyDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptEconomyDrawer(): PticaEconomyDrawer {
return {
summary:
"Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
"финансовой модели (§22). Пока показатели недоступны.",
rows: [
{ k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
{ k: "Себестоимость", field: notReal("после финмодели") },
{ k: "Маржа", field: notReal("после финмодели") },
{ k: "ROI", field: notReal("после финмодели") },
{ k: "IRR", field: notReal("после финмодели") },
],
};
}
export function adaptFinanceDrawer(): PticaEconomyDrawer {
return {
summary:
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
rows: [
{ k: "GDV (выручка)", field: notReal("после финмодели") },
{ k: "Совокупные затраты", field: notReal("после финмодели") },
{ k: "Чистая прибыль", field: notReal("после финмодели") },
{ k: "ROI / IRR", field: notReal("после финмодели") },
{ k: "Срок проекта", field: notReal("после финмодели") },
],
};
}
// ── oks — Нет данных ──────────────────────────────────────────────────────────
export interface PticaOksDrawer {
summary: string;
available: boolean;
}
export function adaptOksDrawer(): PticaOksDrawer {
return {
summary:
"Объекты капитального строительства на участке в текущей выгрузке /analyze " +
"не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
available: false,
};
}
// ── Map geometry ──────────────────────────────────────────────────────────────
/** EKB fallback center (lat, lon). */