Merge pull request 'feat(ptica): live 3D massing in Инсоляция card + honest Recommended Product verdict' (#1860) from fix/ptica-3d-preview-product-verdict into main
This commit is contained in:
commit
ff77987260
8 changed files with 187 additions and 82 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<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>
|
||||
<MassingViewer
|
||||
preview
|
||||
areaM2={areaM2}
|
||||
maxFar={maxFar}
|
||||
farIsReal={farIsReal}
|
||||
/>
|
||||
|
||||
<div className={styles.placeholder}>
|
||||
<div className={styles.placeholderSoon}>ПРЕВЬЮ</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function PticaLowerGrid({
|
|||
forecastReport={forecastReport}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
<InsolationCard onOpenDrawer={onOpenDrawer} />
|
||||
<InsolationCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue