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.
152 lines
5 KiB
TypeScript
152 lines
5 KiB
TypeScript
"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>
|
||
);
|
||
}
|