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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) ───────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue