Merge pull request 'feat(ptica): live 3D massing in Инсоляция card + honest Recommended Product verdict' (#1860) from fix/ptica-3d-preview-product-verdict into main
All checks were successful
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m23s
Deploy / deploy (push) Successful in 1m12s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped

This commit is contained in:
bot-backend 2026-06-20 22:11:20 +00:00
commit ff77987260
8 changed files with 187 additions and 82 deletions

View file

@ -1322,6 +1322,40 @@
line-height: 1.5; line-height: 1.5;
color: var(--text-soft); 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 { .emptyPanel {
display: grid; display: grid;
place-items: center; place-items: center;
@ -1898,6 +1932,23 @@
.massingViewport canvas { .massingViewport canvas {
display: block; 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, .massingSkeleton,
.massingFallback { .massingFallback {
position: absolute; position: absolute;
@ -2511,58 +2562,9 @@
margin-bottom: 1px; margin-bottom: 1px;
} }
/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */ /* Инсоляционная матрица live preview massing (.massingPreviewViewport above)
/* Ambient «turntable» rotation of the massing-preview cube gives a sense of + ПРЕВЬЮ placeholder. The former faint SVG iso-cube (.isoBoxPreview /
the 3D module without opening the drawer. A constant slight rotateX keeps a @keyframes pticaIsoSpin) was replaced by the real autorotating 3D model. */
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;
}
.placeholder { .placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,19 +1,29 @@
"use client"; "use client";
/** /**
* InsolationCard lower-grid «Инсоляционная матрица» preview card. Ports the * InsolationCard lower-grid «Инсоляционная матрица» preview card. Renders a
* prototype's faint isometric massing visual + «ПРЕВЬЮ» state; «Открыть 3D» * LIVE compact autorotating 3D massing model (a hands-off mini version of the
* opens the insolation / future3d drawer (the 3D massing + shadow module). * «Открыть 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 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"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props { interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: DrawerKey) => void; 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 ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHead}> <div className={styles.cardHead}>
@ -21,23 +31,12 @@ export function InsolationCard({ onOpenDrawer }: Props) {
<span className={styles.openTag}>превью</span> <span className={styles.openTag}>превью</span>
</div> </div>
<div className={styles.isoBoxPreview}> <MassingViewer
<svg viewBox="0 0 200 74" fill="none" aria-hidden="true"> preview
<path areaM2={areaM2}
d="M60 50 L100 30 L100 8 L60 28 Z" maxFar={maxFar}
className={styles.isoPreviewLeft} 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.placeholder}>
<div className={styles.placeholderSoon}>ПРЕВЬЮ</div> <div className={styles.placeholderSoon}>ПРЕВЬЮ</div>

View file

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

View file

@ -20,6 +20,7 @@ import type {
import { import {
DEFICIT_BALANCE_EPS, DEFICIT_BALANCE_EPS,
deficitTone, deficitTone,
deficitWord,
fmtNum, fmtNum,
} from "@/components/site-finder/ptica/scenarios/scenario-helpers"; } from "@/components/site-finder/ptica/scenarios/scenario-helpers";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; 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); 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({ export function RecommendedProductCard({
forecastReport, forecastReport,
onOpenDrawer, onOpenDrawer,
@ -141,6 +171,16 @@ export function RecommendedProductCard({
const bars = degenerateSignal ? [] : allBars.slice(0, MAX_BARS); 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 ( return (
<div id="ptica-product" className={styles.card}> <div id="ptica-product" className={styles.card}>
<div className={styles.cardHead}> <div className={styles.cardHead}>
@ -201,9 +241,22 @@ export function RecommendedProductCard({
</p> </p>
)} )}
</> </>
) : degenerateSignal ? ( ) : hasProduct && degenerateSignal ? (
<div className={styles.emptyState}> <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>
) : ( ) : (
<div className={styles.emptyState}>после прогноза (Сценарии)</div> <div className={styles.emptyState}>после прогноза (Сценарии)</div>

View file

@ -30,6 +30,12 @@ export interface MassingSceneProps {
maxFar?: number; maxFar?: number;
/** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */ /** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */
farIsReal?: boolean; 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) ────────────────────────────────────────── // ── constants (scene units = metres) ──────────────────────────────────────────
@ -301,6 +307,7 @@ export default function MassingScene({
areaM2, areaM2,
maxFar, maxFar,
farIsReal = false, farIsReal = false,
preview = false,
}: MassingSceneProps): React.JSX.Element { }: MassingSceneProps): React.JSX.Element {
const parcelArea = const parcelArea =
areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA; areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA;
@ -416,7 +423,17 @@ export default function MassingScene({
controls.maxDistance = 420; controls.maxDistance = 420;
controls.maxPolarAngle = Math.PI / 2 - 0.04; controls.maxPolarAngle = Math.PI / 2 - 0.04;
controls.target.set(0, 24, 0); 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.autoRotateSpeed = 0.6;
controls.update(); controls.update();
controlsRef.current = controls; controlsRef.current = controls;
@ -587,14 +604,17 @@ export default function MassingScene({
placeSun(hour); placeSun(hour);
}, [hour, placeSun]); }, [hour, placeSun]);
// apply auto-rotate toggle. // apply auto-rotate toggle (preview keeps it permanently on — no control).
useEffect(() => { useEffect(() => {
if (preview) return;
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate; if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
}, [autoRotate]); }, [autoRotate, preview]);
if (webglFailed) { if (webglFailed) {
return ( return (
<div className={styles.massingViewport}> <div
className={preview ? styles.massingPreviewViewport : styles.massingViewport}
>
<div className={styles.massingFallback}> <div className={styles.massingFallback}>
<div className={styles.massingFallbackTitle}> <div className={styles.massingFallbackTitle}>
3D-просмотр недоступен 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 ( return (
<div className={styles.massingWrap}> <div className={styles.massingWrap}>
<div ref={mountRef} className={styles.massingViewport} /> <div ref={mountRef} className={styles.massingViewport} />

View file

@ -29,7 +29,28 @@ const MassingScene = dynamic(
); );
export function MassingViewer(props: MassingSceneProps): React.JSX.Element { 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} />; 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; 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 * 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 ёмкость. * 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; const areaHa = a.geometry_suitability?.area_ha;
return areaHa != null ? areaHa * 10000 : null; return areaHa != null ? areaHa * 10000 : null;
} }

View file

@ -262,6 +262,11 @@ export interface ReportProductTz {
reasons: ProductReason[]; reasons: ProductReason[];
/** Короткий RU-текст про рекомендованный продукт. */ /** Короткий RU-текст про рекомендованный продукт. */
summary: string | null; summary: string | null;
/**
* Оговорка по gate-вердикту: рекомендация условна, если МКД-строительство под
* вопросом (gate: Нельзя). null когда gate не ограничивает.
*/
gate_caveat?: string | null;
} }
// ── Scoring (§13.6 — прозрачность скоринга, #985/#986) ─────────────────────── // ── Scoring (§13.6 — прозрачность скоринга, #985/#986) ───────────────────────