From 8c43410ad2d08116f8fe8652f7ca59f3c85060af Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 21 Jun 2026 03:11:15 +0500 Subject: [PATCH] =?UTF-8?q?feat(ptica):=20live=203D=20massing=20in=20?= =?UTF-8?q?=D0=98=D0=BD=D1=81=D0=BE=D0=BB=D1=8F=D1=86=D0=B8=D1=8F=20card?= =?UTF-8?q?=20+=20honest=20Recommended=20Product=20verdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MassingScene: preview prop — viewport-only, hands-off autorotate (OrbitControls input disabled, autoRotate on), canvas pointer-events:none; teardown preserved. - InsolationCard: replaces the abstract SVG cube with a live autorotating massing built from real parcel area + НСПД max_far (farIsReal). «Открыть 3D» still opens the full interactive drawer. - RecommendedProductCard: degenerate mix no longer shows «сигнал недоступен» — it shows the real advisory verdict «Избегать» + reason (затоварка · индекс дефицита из mix.deficit_index) + product_tz.gate_caveat. Meaningful mixes still render quartirography bars. «Подробнее» → product drawer (full reasons). - drop dead iso-cube CSS (pticaIsoSpin/.isoBoxPreview/.isoPreview*); add .massingPreviewViewport + verdict styles (tokens, dark-scoped). - forecast type: product_tz.gate_caveat. Clickability sweep: no dead controls found; cockpit already cleanly wired. --- .../analysis/[cad]/ptica/ptica.module.css | 106 +++++++++--------- .../ptica/cockpit/InsolationCard.tsx | 41 ++++--- .../ptica/cockpit/PticaLowerGrid.tsx | 2 +- .../ptica/cockpit/RecommendedProductCard.tsx | 59 +++++++++- .../ptica/massing/MassingScene.tsx | 33 +++++- .../ptica/massing/MassingViewer.tsx | 21 ++++ .../site-finder/ptica/ptica-adapt.ts | 2 +- frontend/src/types/forecast.ts | 5 + 8 files changed, 187 insertions(+), 82 deletions(-) diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index c1f4bd48..b87003c1 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -1322,6 +1322,40 @@ line-height: 1.5; color: var(--text-soft); } + +/* Recommended Product — honest advisory verdict (degenerate-mix case: every + формат «избегать», deficit_index −1.0 → затоварка рынка). Tokens resolve + under .pticaRoot[data-theme="dark"]. */ +.verdictPanel { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.signalBadge { + display: inline-flex; + align-self: flex-start; + padding: 2px 9px; + border-radius: 999px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.signalBadgeBad { + border: var(--line) solid var(--accent-red); + background: rgba(210, 101, 91, 0.08); + color: var(--accent-red); +} +.verdictReason { + font-size: 11px; + line-height: 1.4; + color: var(--text); +} +.verdictCaveat { + font-size: 10px; + line-height: 1.5; + color: var(--text-soft); +} .emptyPanel { display: grid; place-items: center; @@ -1898,6 +1932,23 @@ .massingViewport canvas { display: block; } +/* Compact always-on preview canvas in the «Инсоляционная матрица» lower-grid + card — autorotating mini massing, hands-off (canvas pointer-events:none so it + never steals page scroll/clicks). Tokens resolve under .pticaRoot[data-theme="dark"]. */ +.massingPreviewViewport { + position: relative; + width: 100%; + height: 150px; + margin: 2px 0 10px; + border: var(--line) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-inset); + overflow: hidden; +} +.massingPreviewViewport canvas { + display: block; + pointer-events: none; +} .massingSkeleton, .massingFallback { position: absolute; @@ -2511,58 +2562,9 @@ margin-bottom: 1px; } -/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */ -/* Ambient «turntable» rotation of the massing-preview cube — gives a sense of - the 3D module without opening the drawer. A constant slight rotateX keeps a - sliver of depth visible as it passes edge-on. */ -@keyframes pticaIsoSpin { - from { - transform: rotateX(14deg) rotateY(0deg); - } - to { - transform: rotateX(14deg) rotateY(360deg); - } -} -.isoBoxPreview { - height: 74px; - display: grid; - place-items: center; - margin: 2px 0 10px; - opacity: 0.55; - perspective: 540px; -} -.isoBoxPreview svg { - width: 100%; - height: 100%; - transform-style: preserve-3d; - transform-origin: 50% 50%; - will-change: transform; - animation: pticaIsoSpin 8s linear infinite; -} -@media (prefers-reduced-motion: reduce) { - .isoBoxPreview svg { - animation: none; - } -} -.isoPreviewLeft { - fill: rgba(150, 192, 214, 0.1); - stroke: var(--border-strong); - stroke-width: 1; -} -.isoPreviewTop { - fill: rgba(150, 192, 214, 0.06); - stroke: var(--border-strong); - stroke-width: 1; -} -.isoPreviewRight { - fill: rgba(150, 192, 214, 0.14); - stroke: var(--border-strong); - stroke-width: 1; -} -.isoPreviewEdge { - stroke: var(--border); - stroke-width: 0.6; -} +/* Инсоляционная матрица — live preview massing (.massingPreviewViewport above) + + ПРЕВЬЮ placeholder. The former faint SVG iso-cube (.isoBoxPreview / + @keyframes pticaIsoSpin) was replaced by the real autorotating 3D model. */ .placeholder { display: flex; flex-direction: column; diff --git a/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx index 868007d2..9db236a2 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx @@ -1,19 +1,29 @@ "use client"; /** - * InsolationCard — lower-grid «Инсоляционная матрица» preview card. Ports the - * prototype's faint isometric massing visual + «ПРЕВЬЮ» state; «Открыть 3D» - * opens the insolation / future3d drawer (the 3D massing + shadow module). + * InsolationCard — lower-grid «Инсоляционная матрица» preview card. Renders a + * LIVE compact autorotating 3D massing model (a hands-off mini version of the + * «Открыть 3D» drawer) instead of the old abstract SVG cube, fed by the REAL + * parcel area (geometry) + НСПД КСИТ (max_far). «Открыть 3D» opens the + * insolation drawer (3D massing + shadow module). */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { parcelAreaM2 } from "@/components/site-finder/ptica/ptica-adapt"; +import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { + analysis: ParcelAnalysis; onOpenDrawer: (key: DrawerKey) => void; } -export function InsolationCard({ onOpenDrawer }: Props) { +export function InsolationCard({ analysis, onOpenDrawer }: Props) { + const areaM2 = parcelAreaM2(analysis) ?? undefined; + const maxFar = analysis.nspd_zoning?.max_far ?? undefined; + const farIsReal = maxFar != null; + return (
@@ -21,23 +31,12 @@ export function InsolationCard({ onOpenDrawer }: Props) { превью
-
- -
+
ПРЕВЬЮ
diff --git a/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx b/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx index 64801dea..6c709158 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx @@ -38,7 +38,7 @@ export function PticaLowerGrid({ forecastReport={forecastReport} onOpenDrawer={onOpenDrawer} /> - + ); } diff --git a/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx index cfd9a834..bf3a8585 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx @@ -20,6 +20,7 @@ import type { import { DEFICIT_BALANCE_EPS, deficitTone, + deficitWord, fmtNum, } from "@/components/site-finder/ptica/scenarios/scenario-helpers"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; @@ -107,6 +108,35 @@ function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } +interface ClassVerdict { + /** REAL deficit_index for the class (median of its mix rows), or null. */ + deficitIndex: number | null; + /** REAL per-format signal when uniform across the class (e.g. «избегать»). */ + signal: string | null; +} + +/** + * Honest per-class verdict from the REAL mix: the class deficit_index (its mix + * rows share one value in the degenerate ±1.0 case) + the per-format `signal` + * when every format agrees. No fabrication — null when the class is absent. + */ +function classVerdict( + tz: ReportProductTz, + selectedClass: string | null, +): ClassVerdict { + const rows = + selectedClass != null + ? tz.mix.filter((m) => m.obj_class === selectedClass) + : tz.mix; + const di = rows.find((m) => m.deficit_index != null)?.deficit_index ?? null; + const signals = rows.map((m) => m.signal).filter((s): s is string => !!s); + const uniform = + signals.length > 0 && signals.every((s) => s === signals[0]) + ? signals[0] + : null; + return { deficitIndex: di, signal: uniform }; +} + export function RecommendedProductCard({ forecastReport, onOpenDrawer, @@ -141,6 +171,16 @@ export function RecommendedProductCard({ const bars = degenerateSignal ? [] : allBars.slice(0, MAX_BARS); + // Honest verdict for the degenerate case (no informative bars): the forecast + // IS available — it says AVOID. Built from the active class's REAL signal / + // deficit_index + the gate caveat. NO fabricated доля%/bars. + const verdict = tz ? classVerdict(tz, activeClass) : null; + const avoid = + verdict != null && + ((verdict.signal === "избегать") || + (verdict.deficitIndex != null && verdict.deficitIndex < 0)); + const gateCaveat = tz?.gate_caveat ?? null; + return (
@@ -201,9 +241,22 @@ export function RecommendedProductCard({

)} - ) : degenerateSignal ? ( -
- Сигнал по форматам в этом прогнозе недоступен + ) : hasProduct && degenerateSignal ? ( +
+ + {avoid ? "Избегать" : (verdict?.signal ?? "Нет сигнала")} + + {verdict?.deficitIndex != null && ( +

+ {capitalize(deficitWord(verdict.deficitIndex))} рынка · индекс + дефицита {fmtNum(verdict.deficitIndex, 2)} +

+ )} + {gateCaveat &&

{gateCaveat}

}
) : (
после прогноза (Сценарии)
diff --git a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx index 1596b96d..46fc692d 100644 --- a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx +++ b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx @@ -30,6 +30,12 @@ export interface MassingSceneProps { maxFar?: number; /** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */ farIsReal?: boolean; + /** + * Compact preview mode — renders ONLY the autorotating viewport (no sliders / + * metrics), with all user interaction disabled (hands-off mini massing for the + * Инсоляционная матрица lower-grid card). + */ + preview?: boolean; } // ── constants (scene units = metres) ────────────────────────────────────────── @@ -301,6 +307,7 @@ export default function MassingScene({ areaM2, maxFar, farIsReal = false, + preview = false, }: MassingSceneProps): React.JSX.Element { const parcelArea = areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA; @@ -416,7 +423,17 @@ export default function MassingScene({ controls.maxDistance = 420; controls.maxPolarAngle = Math.PI / 2 - 0.04; controls.target.set(0, 24, 0); - controls.autoRotate = autoRotate; + // Preview mode is hands-off: kill all user interaction so the always-on + // canvas never steals page scroll / clicks; keep autoRotate driving the + // turntable (controls.update() in the RAF below keeps it spinning). + if (preview) { + controls.enableZoom = false; + controls.enablePan = false; + controls.enableRotate = false; + controls.autoRotate = true; + } else { + controls.autoRotate = autoRotate; + } controls.autoRotateSpeed = 0.6; controls.update(); controlsRef.current = controls; @@ -587,14 +604,17 @@ export default function MassingScene({ placeSun(hour); }, [hour, placeSun]); - // apply auto-rotate toggle. + // apply auto-rotate toggle (preview keeps it permanently on — no control). useEffect(() => { + if (preview) return; if (controlsRef.current) controlsRef.current.autoRotate = autoRotate; - }, [autoRotate]); + }, [autoRotate, preview]); if (webglFailed) { return ( -
+
3D-просмотр недоступен @@ -608,6 +628,11 @@ export default function MassingScene({ ); } + // Preview: only the autorotating viewport — no sliders / metrics chrome. + if (preview) { + return
; + } + return (
diff --git a/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx b/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx index 84c4333c..07796e17 100644 --- a/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx +++ b/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx @@ -29,7 +29,28 @@ const MassingScene = dynamic( ); export function MassingViewer(props: MassingSceneProps): React.JSX.Element { + // The compact preview canvas (lower-grid card) gets a matching compact + // skeleton so the lazy-mount placeholder is the same ~150px box, not 380px. + if (props.preview) { + return ( + + ); + } return ; } +const MassingScenePreview = dynamic( + () => import("@/components/site-finder/ptica/massing/MassingScene"), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + export default MassingViewer; diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index f772e2fc..c639dd65 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -111,7 +111,7 @@ function formatFar(far: number): string { * Real parcel area in m² when geometry is known (area_ha × 10000); null * otherwise. The single point at which we turn area_ha into m² for ёмкость. */ -function parcelAreaM2(a: ParcelAnalysis): number | null { +export function parcelAreaM2(a: ParcelAnalysis): number | null { const areaHa = a.geometry_suitability?.area_ha; return areaHa != null ? areaHa * 10000 : null; } diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index c8034dd6..d8f4f47d 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -262,6 +262,11 @@ export interface ReportProductTz { reasons: ProductReason[]; /** Короткий RU-текст про рекомендованный продукт. */ summary: string | null; + /** + * Оговорка по gate-вердикту: рекомендация условна, если МКД-строительство под + * вопросом (gate: Нельзя). null когда gate не ограничивает. + */ + gate_caveat?: string | null; } // ── Scoring (§13.6 — прозрачность скоринга, #985/#986) ───────────────────────