feat(ptica): live 3D massing in Инсоляция card + honest Recommended Product verdict
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m59s

- 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.
This commit is contained in:
Light1YT 2026-06-21 03:11:15 +05:00
parent 2a40b4fcc6
commit 8c43410ad2
8 changed files with 187 additions and 82 deletions

View file

@ -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;

View file

@ -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 (
<div className={styles.card}>
<div className={styles.cardHead}>
@ -21,23 +31,12 @@ export function InsolationCard({ onOpenDrawer }: Props) {
<span className={styles.openTag}>превью</span>
</div>
<div className={styles.isoBoxPreview}>
<svg viewBox="0 0 200 74" fill="none" aria-hidden="true">
<path
d="M60 50 L100 30 L100 8 L60 28 Z"
className={styles.isoPreviewLeft}
<MassingViewer
preview
areaM2={areaM2}
maxFar={maxFar}
farIsReal={farIsReal}
/>
<path
d="M100 30 L140 50 L140 28 L100 8 Z"
className={styles.isoPreviewTop}
/>
<path
d="M60 50 L100 70 L140 50 L100 30 Z"
className={styles.isoPreviewRight}
/>
<path d="M100 30 L100 70" className={styles.isoPreviewEdge} />
</svg>
</div>
<div className={styles.placeholder}>
<div className={styles.placeholderSoon}>ПРЕВЬЮ</div>

View file

@ -38,7 +38,7 @@ export function PticaLowerGrid({
forecastReport={forecastReport}
onOpenDrawer={onOpenDrawer}
/>
<InsolationCard onOpenDrawer={onOpenDrawer} />
<InsolationCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
</section>
);
}

View file

@ -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 (
<div id="ptica-product" className={styles.card}>
<div className={styles.cardHead}>
@ -201,9 +241,22 @@ export function RecommendedProductCard({
</p>
)}
</>
) : degenerateSignal ? (
<div className={styles.emptyState}>
Сигнал по форматам в этом прогнозе недоступен
) : hasProduct && degenerateSignal ? (
<div className={styles.verdictPanel}>
<span
className={`${styles.signalBadge} ${
avoid ? styles.signalBadgeBad : ""
}`}
>
{avoid ? "Избегать" : (verdict?.signal ?? "Нет сигнала")}
</span>
{verdict?.deficitIndex != null && (
<p className={styles.verdictReason}>
{capitalize(deficitWord(verdict.deficitIndex))} рынка · индекс
дефицита {fmtNum(verdict.deficitIndex, 2)}
</p>
)}
{gateCaveat && <p className={styles.verdictCaveat}>{gateCaveat}</p>}
</div>
) : (
<div className={styles.emptyState}>после прогноза (Сценарии)</div>

View file

@ -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);
// 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 (
<div className={styles.massingViewport}>
<div
className={preview ? styles.massingPreviewViewport : styles.massingViewport}
>
<div className={styles.massingFallback}>
<div className={styles.massingFallbackTitle}>
3D-просмотр недоступен
@ -608,6 +628,11 @@ export default function MassingScene({
);
}
// Preview: only the autorotating viewport — no sliders / metrics chrome.
if (preview) {
return <div ref={mountRef} className={styles.massingPreviewViewport} />;
}
return (
<div className={styles.massingWrap}>
<div ref={mountRef} className={styles.massingViewport} />

View file

@ -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 (
<MassingScenePreview {...props} />
);
}
return <MassingScene {...props} />;
}
const MassingScenePreview = dynamic(
() => import("@/components/site-finder/ptica/massing/MassingScene"),
{
ssr: false,
loading: () => (
<div className={styles.massingPreviewViewport}>
<div className={styles.massingSkeleton} aria-hidden="true">
Загрузка 3D
</div>
</div>
),
},
);
export default MassingViewer;

View file

@ -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;
}

View file

@ -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) ───────────────────────