Merge pull request 'fix(ptica): visual parity 1:1 with prototype (ОКС stretch + restored elements)' (#1854) from fix/ptica-visual-parity into main
This commit is contained in:
commit
721623bd0e
5 changed files with 306 additions and 4 deletions
|
|
@ -200,6 +200,19 @@
|
|||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Rail nav-foot help-dot (?) button (prototype .help-dot). */
|
||||
.helpDot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: var(--line) solid var(--border-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* main column */
|
||||
.main {
|
||||
|
|
@ -286,15 +299,52 @@
|
|||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Topbar sysbar icon-buttons (prototype .icon-btn). */
|
||||
.iconBtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: var(--line) solid transparent;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.iconBtn:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sysbar .iconBtn + .iconBtn {
|
||||
margin-left: -2px;
|
||||
}
|
||||
.iconBtn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* canvas (scroll area) */
|
||||
.canvas {
|
||||
padding: 14px 18px 24px;
|
||||
padding: 14px 18px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Bottom disclaimer footer bar (prototype .app-footer). */
|
||||
.appFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px 14px;
|
||||
border-top: var(--line) solid var(--border);
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===================== PANEL / CARD primitives ===================== */
|
||||
.panel,
|
||||
.card,
|
||||
|
|
@ -563,6 +613,27 @@
|
|||
.investPlaceholder {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
/* Hero stat-line semantic value colors (prototype .v.good / .v.warn). */
|
||||
.vGood {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.vWarn {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
/* Hero invest-score sparkline (prototype .sparkline). */
|
||||
.sparkline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 26px;
|
||||
}
|
||||
.sparkline span {
|
||||
flex: 1;
|
||||
background: linear-gradient(var(--accent-cyan), var(--accent-blue));
|
||||
border-radius: 1px;
|
||||
min-height: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
|
|
@ -712,6 +783,46 @@
|
|||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
/* Scan status-rows (prototype Инженерия / Экология .status-row): dot + label
|
||||
+ state. Scoped under .scanCard so they never collide with the unrelated
|
||||
DRAWER .statusRow variant (drDot / drLabel / drState). */
|
||||
.scanCard .statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.scanCard .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
color: var(--accent-green);
|
||||
flex: none;
|
||||
}
|
||||
.scanCard .dotWarn {
|
||||
background: var(--accent-yellow);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.scanCard .dotBad {
|
||||
background: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.scanCard .label {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scanCard .state {
|
||||
font-size: 10px;
|
||||
}
|
||||
.scanCard .stateOk {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.scanCard .stateWarn {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
/* Risks card centres its gauge; title stays left-aligned like the other cards. */
|
||||
.scanCardCentered {
|
||||
align-items: center;
|
||||
|
|
@ -2200,6 +2311,27 @@
|
|||
display: grid;
|
||||
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
|
||||
gap: 10px;
|
||||
/* HEADLINE BUG FIX: default align-items:stretch made the sparse/empty ОКС
|
||||
card (isoBox 84px + emptyState flex:1 + button) stretch to the tallest
|
||||
sibling, and .emptyState's flex:1 then filled the void → huge empty box.
|
||||
align-items:start lets each card size to its own content. */
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Lower-grid квартирография bars match the prototype .bar-row (narrow label /
|
||||
value columns, 2px track radius) — SCOPED so the shared base .hbar used by
|
||||
the drawer (110px 1fr 46px, 3px radius) is NOT crushed. */
|
||||
.lowerGrid .hbars {
|
||||
gap: 8px;
|
||||
}
|
||||
.lowerGrid .hbar {
|
||||
grid-template-columns: 46px 1fr 38px;
|
||||
}
|
||||
.lowerGrid .hbarTrack {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.lowerGrid .hbarFill {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* shared detail-btn that needs top separation inside flow cards */
|
||||
|
|
@ -2280,7 +2412,7 @@
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */
|
||||
|
|
@ -2341,6 +2473,9 @@
|
|||
display: grid;
|
||||
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
|
||||
gap: 10px;
|
||||
/* Same grid-stretch defect as .lowerGrid — sparse Legal Status / cards must
|
||||
not stretch to the tallest sibling. Pin each card to the top. */
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Investment Clearance — bignum row */
|
||||
|
|
@ -2434,6 +2569,9 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
/* Prototype .verdict-card plaque gradient — scoped dark cockpit (authoritative
|
||||
here over the global flat-token rule). */
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-2));
|
||||
}
|
||||
.verdictSplit {
|
||||
display: grid;
|
||||
|
|
@ -2494,6 +2632,8 @@
|
|||
}
|
||||
.checkItemWarn .checkIc {
|
||||
color: var(--accent-yellow);
|
||||
/* Prototype removes the icon border ring on warn checklist items. */
|
||||
border: none;
|
||||
}
|
||||
.checkItemBad .checkIc {
|
||||
color: var(--accent-red);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,19 @@ 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 { PticaValueTone } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
/** Decorative hero sparkline shape (prototype data-spark="4,5,6,5,7,6,8,7,9,8"). */
|
||||
const SPARK = [4, 5, 6, 5, 7, 6, 8, 7, 9, 8];
|
||||
const SPARK_MAX = Math.max(...SPARK);
|
||||
|
||||
function toneClass(tone: PticaValueTone): string {
|
||||
if (tone === "good") return styles.vGood;
|
||||
if (tone === "warn") return styles.vWarn;
|
||||
return "";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
|
||||
|
|
@ -48,17 +59,31 @@ export function InvestScoreBlock({
|
|||
{inv.score.caption && (
|
||||
<span className={styles.fieldCaption}>{inv.score.caption}</span>
|
||||
)}
|
||||
<div className={styles.sparkline} aria-hidden="true">
|
||||
{SPARK.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ height: `${Math.round((v / SPARK_MAX) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.statLine}>
|
||||
<span className={styles.k}>Потенциал</span>
|
||||
<span className={styles.v} title={inv.potential.caption}>
|
||||
<span
|
||||
className={`${styles.v} ${toneClass(inv.potentialTone)}`}
|
||||
title={inv.potential.caption}
|
||||
>
|
||||
{inv.potential.value}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statLine}>
|
||||
<span className={styles.k}>Риск</span>
|
||||
<span className={styles.v} title={inv.risk.caption}>
|
||||
<span
|
||||
className={`${styles.v} ${toneClass(inv.riskTone)}`}
|
||||
title={inv.risk.caption}
|
||||
>
|
||||
{inv.risk.value}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -215,6 +215,16 @@ const BirdMark = ({ className }: { className: string }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
/** Toggle native fullscreen for the whole document (topbar fullscreen icon). */
|
||||
function toggleFullscreen() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen?.();
|
||||
} else {
|
||||
void document.documentElement.requestFullscreen?.();
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeTab: PticaTab;
|
||||
onTabChange: (tab: PticaTab) => void;
|
||||
|
|
@ -294,6 +304,15 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
|
|||
<br />
|
||||
{clock ?? "—"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.helpDot}
|
||||
title="Справка — скоро"
|
||||
aria-label="Справка"
|
||||
aria-disabled="true"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -330,10 +349,76 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
|
|||
<div className={styles.clock} suppressHydrationWarning>
|
||||
<b>{clock ?? "—"}</b>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
title="Экспорт / отчёты"
|
||||
aria-label="Экспорт — открыть отчёты"
|
||||
onClick={() => onTabChange("reports")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v3h16v-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
title="Настройки — скоро"
|
||||
aria-label="Настройки"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M12 2.5l1.4 2.2 2.5-.7.6 2.6 2.6.6-.7 2.5 2.2 1.4-2.2 1.4.7 2.5-2.6.6-.6 2.6-2.5-.7L12 21.5l-1.4-2.2-2.5.7-.6-2.6-2.6-.6.7-2.5L3.4 13l2.2-1.4-.7-2.5 2.6-.6.6-2.6 2.5.7L12 2.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
title="Полный экран"
|
||||
aria-label="Полный экран"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.canvas}>{children}</div>
|
||||
|
||||
<footer className={styles.appFooter}>
|
||||
<span>
|
||||
ПТИЦА · платформа территориального информационно-цифрового анализа
|
||||
</span>
|
||||
<span>
|
||||
Не является публичной офертой · информация носит оценочный характер
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,30 @@ export function ScanCard({ card, drawerKey, onOpen }: Props) {
|
|||
<div className={styles.emptyState}>{emptyState}</div>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
// Status-row variant (prototype .status-row): dot + label + state.
|
||||
if (row.status) {
|
||||
const dotClass =
|
||||
row.status === "warn"
|
||||
? styles.dotWarn
|
||||
: row.status === "bad"
|
||||
? styles.dotBad
|
||||
: "";
|
||||
const stateClass =
|
||||
row.status === "ok" ? styles.stateOk : styles.stateWarn;
|
||||
return (
|
||||
<div key={row.key} className={styles.statusRow}>
|
||||
<span className={`${styles.dot} ${dotClass}`} />
|
||||
<span className={styles.label}>{row.key}</span>
|
||||
<span
|
||||
className={`${styles.state} ${stateClass}`}
|
||||
title={row.field.caption}
|
||||
>
|
||||
{row.field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder = !row.field.isReal;
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -49,9 +49,18 @@ export interface PticaScanCard {
|
|||
emptyState?: string;
|
||||
}
|
||||
|
||||
/** Tone for a status-row dot + state label (prototype .dot / .state). */
|
||||
export type PticaStatusTone = "ok" | "warn" | "bad";
|
||||
|
||||
export interface PticaScanRow {
|
||||
key: string;
|
||||
field: PticaField;
|
||||
/**
|
||||
* When set, the row renders as a prototype status-row (dot + label + state)
|
||||
* instead of the default kvrow. `field.value` is the state text. Used by the
|
||||
* Инженерия card (Экология stays honest kvrow placeholders, no status dots).
|
||||
*/
|
||||
status?: PticaStatusTone;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = "—";
|
||||
|
|
@ -234,11 +243,18 @@ export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge {
|
|||
|
||||
// ── Invest score block (PLACEHOLDER — после прогноза) ─────────────────────────
|
||||
|
||||
/** Semantic value tone for a hero stat-line (prototype .v.good / .v.warn). */
|
||||
export type PticaValueTone = "good" | "warn" | "none";
|
||||
|
||||
export interface PticaInvestScore {
|
||||
score: PticaField;
|
||||
potential: PticaField;
|
||||
risk: PticaField;
|
||||
buySignal: PticaField;
|
||||
/** Hero stat-line value color for «Потенциал». */
|
||||
potentialTone: PticaValueTone;
|
||||
/** Hero stat-line value color for «Риск». */
|
||||
riskTone: PticaValueTone;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -275,6 +291,9 @@ export function adaptInvestScore(
|
|||
risk.value != null
|
||||
? { value: risk.label, isReal: false, caption: "предв." }
|
||||
: placeholder("предв.");
|
||||
// Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise.
|
||||
const riskTone: PticaValueTone =
|
||||
risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn";
|
||||
|
||||
const overall =
|
||||
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
|
||||
|
|
@ -294,6 +313,9 @@ export function adaptInvestScore(
|
|||
potential: placeholder("после финмодели"),
|
||||
risk: riskField,
|
||||
buySignal,
|
||||
// Потенциал stays muted until the financial model surfaces a real value.
|
||||
potentialTone: "none",
|
||||
riskTone,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +324,8 @@ export function adaptInvestScore(
|
|||
potential: placeholder("после прогноза (Scenarios)"),
|
||||
risk: riskField,
|
||||
buySignal,
|
||||
potentialTone: "none",
|
||||
riskTone,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -404,9 +428,13 @@ export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard {
|
|||
}
|
||||
return {
|
||||
title: "Инженерия",
|
||||
// Prototype Инженерия renders status-rows (dot + label + state). The state
|
||||
// text is the nearest-node distance; tone is «ok» when the узел is within
|
||||
// ~1 km, «warn» when further (ограниченная доступность).
|
||||
rows: summary.map((u) => ({
|
||||
key: utilityLabel(u.subtype),
|
||||
field: real(`${formatInt(u.nearest_m)} м`),
|
||||
status: (u.nearest_m <= 1000 ? "ok" : "warn") as PticaStatusTone,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue