gendesign/frontend/src/components/site-finder/ptica/PticaShell.tsx
Light1YT 05eb70eb62
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 55s
CI / openapi-codegen-check (pull_request) Successful in 1m57s
feat(site-finder): ПТИЦА dark cockpit — increment 1 (shell + hero + Development Scan)
New non-destructive route /site-finder/analysis/[cad]/ptica rendering the existing
/analyze data in the «ПТИЦА» operator-terminal cockpit. The light analysis page,
Section1-6, globals.css and package.json are untouched.

PR#1 scope: app-shell (topbar + 4 tabs + left rail), hero (dark Leaflet map +
parcel passport KV-grid + Buildability radial gauge + invest-score block), and the
Development Scan card grid — wired to useParcelAnalyzeQuery / ParcelAnalysis.

Data honesty: real fields render live (passport egrn.*, district, utilities.summary,
median_price, pipeline_24mo); absent fields show muted placeholders with source
captions (buildability/risk = derived proxy «предв.»; invest/buy-signal «после
прогноза»; КСИТ/height/ОКС/economy «—») via typed ptica-adapt.ts — no fake numbers
presented as live.

Dark theme is a CSS-module scoped under .pticaRoot[data-theme=dark] (cannot leak to
the light app); IBM Plex Mono added via next/font (no dep/lockfile change). Leaflet
via dynamic(ssr:false). TS strict, no any. Deferred to follow-up PRs: Scenarios
(forecast), Reports+Compare tabs, 16 detail drawers, Three.js 3D massing.
2026-06-20 15:29:53 +05:00

152 lines
5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* PticaShell — cockpit chrome: sticky topbar (ПТИЦА wordmark + 4 tab buttons,
* active state driven by props) and a left rail of anchor buttons that
* scrollIntoView the matching hero/scan sections, plus a mono timestamp footer.
*
* The timestamp is rendered only after mount (useState gated by an effect) to
* avoid an SSR/client hydration mismatch on the clock value. The effect drives a
* UI clock — no HTTP — which is the allowed use of useEffect here.
*/
import { useEffect, useState } from "react";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
export type PticaTab = "analysis" | "scenarios" | "reports" | "compare";
interface TabDef {
id: PticaTab;
label: string;
}
const TABS: TabDef[] = [
{ id: "analysis", label: "Анализ участка" },
{ id: "scenarios", label: "Сценарии" },
{ id: "reports", label: "Отчёты" },
{ id: "compare", label: "Сравнение" },
];
interface RailItem {
anchor: string;
label: string;
}
const RAIL: RailItem[] = [
{ anchor: "passport", label: "Паспорт" },
{ anchor: "potential", label: "Потенциал" },
{ anchor: "scan", label: "Скан" },
{ anchor: "economy", label: "Экономика" },
{ anchor: "risks", label: "Риски" },
{ anchor: "compare-anchor", label: "Сравнение" },
];
const BirdMark = ({ className }: { className: string }) => (
<svg className={className} viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path
d="M6 15 C14 13 18 17 20 23 C22 17 26 13 34 15 C27 17 23 22 20 30 C17 22 13 17 6 15 Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
interface Props {
activeTab: PticaTab;
onTabChange: (tab: PticaTab) => void;
children: React.ReactNode;
}
function scrollToAnchor(anchor: string) {
if (typeof document === "undefined") return;
const el = document.getElementById(anchor);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
export function PticaShell({ activeTab, onTabChange, children }: Props) {
const [clock, setClock] = useState<string | null>(null);
useEffect(() => {
function tick() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
setClock(`${hh}:${mm}:${ss}`);
}
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
return (
<div className={styles.shell}>
{/* ── Left rail ───────────────────────────────────────────────── */}
<aside className={styles.rail}>
<div className={styles.brand}>
<BirdMark className={styles.brandMark} />
</div>
<nav className={styles.nav} aria-label="Разделы анализа">
{RAIL.map((item) => (
<button
key={item.anchor}
type="button"
className={styles.navItem}
onClick={() => scrollToAnchor(item.anchor)}
>
<span>{item.label}</span>
</button>
))}
</nav>
<div className={styles.navFoot}>
<div className={styles.mono} suppressHydrationWarning>
DATA
<br />
{clock ?? "—"}
</div>
</div>
</aside>
{/* ── Main column ─────────────────────────────────────────────── */}
<div className={styles.main}>
<header className={styles.topbar}>
<div className={styles.wordmark}>
<BirdMark className={styles.bird} />
<div>
<div className={styles.wordmarkTitle}>ПТИЦА</div>
<div className={styles.wordmarkSub}>
платформа территориального
<br />
информационно-цифрового анализа
</div>
</div>
</div>
<nav className={styles.tabs} aria-label="Режимы">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${
activeTab === tab.id ? styles.tabActive : ""
}`}
aria-current={activeTab === tab.id ? "page" : undefined}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<div className={styles.sysbar}>
<div className={styles.clock} suppressHydrationWarning>
<b>{clock ?? "—"}</b>
</div>
</div>
</header>
<div className={styles.canvas}>{children}</div>
</div>
</div>
);
}