PticaDrawer: proper modal focus management — - focus-trap: Tab/Shift+Tab wrap inside the dialog (focusables re-queried per Tab so it survives lazy panels/sub-tabs), focus-already-outside pulled back in; - focus-restore: the trigger element is captured on open and re-focused on close (guards null + detached node); - onClose held in a ref so the effect depends on [open] only — fixes focus thrashing when the host's URL/searchParams change mid-open. Esc/scrim close, body-scroll-lock, role=dialog/aria-modal preserved. Cleanups (code-review follow-ups): - MassingScene: drop duplicate initial mass build (floors/sections effect owns it); corrected the effect-ordering comment. - Static inline styles → CSS-module classes (scan/gauge/score/spaced rows). - DrawerPrimitives: remove !important on .dtabActive via specificity (.dtabs button.dtabActive). - Remove unused SoonCard component + its CSS. tsc/lint/prettier clean; next build green (route bundled). ptica-only, no any.
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* RecommendedProduct — 6.4 «Рекомендованный продукт». §13.4 product_tz: the
|
||
* recommended obj_class (tag) + квартирография as horizontal bars (доля when an
|
||
* explicit `pct` mix exists, else |deficit_index| as signal strength — the
|
||
* current overlay shape), plus the overall product success-score (scoring.overall)
|
||
* and the optional target price when the caller supplies it.
|
||
*
|
||
* GRACEFUL: returns null when product_tz carries nothing meaningful.
|
||
*/
|
||
|
||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||
import type { ProductMixEntry, ReportProductTz } from "@/types/forecast";
|
||
import { DEFICIT_BALANCE_EPS, deficitTone, fmtNum } from "./scenario-helpers";
|
||
|
||
interface Props {
|
||
productTz: ReportProductTz;
|
||
/** Overall product success-score ∈ 0..1 (scoring.overall) — null when absent. */
|
||
successScore: number | null;
|
||
/** Target price (₽/м²) when the caller can supply it (district median) — optional. */
|
||
targetPrice: string | null;
|
||
}
|
||
|
||
const TONE_CLASS = {
|
||
good: styles.hbarFillGood,
|
||
bad: styles.hbarFillBad,
|
||
warn: styles.hbarFillBad,
|
||
neutral: styles.hbarFillNeutral,
|
||
} as const;
|
||
|
||
function isNonEmpty(productTz: ReportProductTz): boolean {
|
||
return (
|
||
productTz.obj_class != null ||
|
||
productTz.mix.length > 0 ||
|
||
!!productTz.summary
|
||
);
|
||
}
|
||
|
||
function mixLabel(m: ProductMixEntry): string {
|
||
return m.bucket ?? "формат";
|
||
}
|
||
|
||
interface MixBar {
|
||
key: string;
|
||
label: string;
|
||
widthPct: number;
|
||
valueStr: string;
|
||
tone: "good" | "warn" | "bad" | "neutral";
|
||
}
|
||
|
||
function buildBars(mix: ProductMixEntry[]): {
|
||
bars: MixBar[];
|
||
byShare: boolean;
|
||
} {
|
||
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
|
||
const byShare = rows.some((m) => m.pct != null);
|
||
const bars = rows.map((m, i): MixBar => {
|
||
if (m.pct != null) {
|
||
const pct = Math.max(0, Math.min(100, m.pct));
|
||
return {
|
||
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
|
||
label: mixLabel(m),
|
||
widthPct: pct,
|
||
valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`,
|
||
tone: "neutral",
|
||
};
|
||
}
|
||
const di = m.deficit_index ?? 0;
|
||
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||
return {
|
||
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
|
||
label: mixLabel(m),
|
||
widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)),
|
||
valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`,
|
||
tone: balanced ? "neutral" : deficitTone(di),
|
||
};
|
||
});
|
||
return { bars, byShare };
|
||
}
|
||
|
||
export function RecommendedProduct({
|
||
productTz,
|
||
successScore,
|
||
targetPrice,
|
||
}: Props) {
|
||
if (!isNonEmpty(productTz)) return null;
|
||
|
||
const { bars, byShare } = buildBars(productTz.mix);
|
||
|
||
return (
|
||
<div className={styles.panel}>
|
||
<div className={styles.cardHead}>
|
||
<h3 className={styles.cardTitle}>Рекомендованный продукт</h3>
|
||
{productTz.obj_class && (
|
||
<span className={styles.tag}>{capitalize(productTz.obj_class)}</span>
|
||
)}
|
||
</div>
|
||
|
||
{bars.length > 0 && (
|
||
<div className={styles.hbars}>
|
||
{bars.map((b) => (
|
||
<div key={b.key} className={styles.hbar}>
|
||
<span className={styles.hbarName}>{b.label}</span>
|
||
<div className={styles.hbarTrack}>
|
||
<div
|
||
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
|
||
style={{ width: `${b.widthPct}%` }}
|
||
/>
|
||
</div>
|
||
<span className={styles.hbarValue}>{b.valueStr}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!byShare && bars.length > 0 && (
|
||
<p className={styles.panelHint}>
|
||
Длина полосы — сила сигнала по индексу дефицита формата: зелёный —
|
||
недонасыщенность, красный — затоварка.
|
||
</p>
|
||
)}
|
||
|
||
{targetPrice && (
|
||
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
|
||
<span className={styles.scK}>Целевая цена</span>
|
||
<span className={styles.scV}>{targetPrice}</span>
|
||
</div>
|
||
)}
|
||
<div className={styles.scKvRow}>
|
||
<span className={styles.scK}>Success-score формата</span>
|
||
<span className={styles.scV}>
|
||
{successScore != null ? fmtNum(successScore, 2) : "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function capitalize(text: string): string {
|
||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||
}
|