gendesign/frontend/src/components/concept/ConceptVariantsResult.tsx
bot-backend 9815e0607e
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 2m2s
fix(concept): §7 massing-сцена осознаёт весь MultiPolygon участка
extractPolygon схлопывал MultiPolygon до coordinates[0], поэтому 3D/2D
massing-сцена брала начало проекции, bbox OSM-тайла и контур от ПЕРВОЙ части,
а бэкенд анкорит соседей к ЦЕЛОЙ геометрии — для многочастных участков контекст
уезжал до ~1.6км от part[0] (163 участка на проде, max 1611м).

Сцена теперь считает origin (ringsCentroid), ground bounds (ringsBounds) и
рисует контур по ВСЕМ частям (polygonOuterRings) целого Polygon|MultiPolygon;
камера охватывает все части + корпуса. Запрос /concepts по-прежнему шлёт один
Polygon, но берёт НАИБОЛЬШУЮ по площади часть, не coordinates[0] (побочный
латентный баг — part[0] мог быть осколком). 2D Leaflet-карта фитится по всему
экстенту. Одночастные участки не затронуты (helpers делегируют к прежней логике,
байт-в-байт). ptica-песочница area-based → не затронута.

Refs #2180
2026-07-07 13:07:09 +05:00

834 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* ConceptVariantsResult — variant tabs + per-variant placement map, ТЭП KPIs,
* financial model, and export controls. Driven by the ConceptOutput returned
* from POST /concepts. The map is lazy-mounted (Leaflet uses `window`).
*/
import dynamic from "next/dynamic";
import { useState } from "react";
import { Box, Map as MapIcon } from "lucide-react";
import { KpiCard } from "@/components/analytics/KpiCard";
import { Section } from "@/components/analytics/Section";
import { Badge } from "@/components/ui/Badge";
import {
priceSourceCaption,
STRATEGY_HINTS,
STRATEGY_LABELS,
type ConceptVariant,
type FinancialModel,
} from "@/lib/concept-api";
import type { MultiPolygon, Polygon } from "geojson";
import { ConceptExportButtons } from "./ConceptExportButtons";
const ConceptResultMap = dynamic(
() =>
import("./ConceptResultMap").then((m) => ({ default: m.ConceptResultMap })),
{
ssr: false,
loading: () => (
<div
style={{
height: 360,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка карты
</div>
),
},
);
// 3D massing viewer — Three.js, client-only (ssr:false), lazy so it never bloats
// the report bundle and only the active placement tab mounts its engine.
const Massing3DViewer = dynamic(
() =>
import("./Massing3DViewer").then((m) => ({ default: m.Massing3DViewer })),
{
ssr: false,
loading: () => (
<div
style={{
height: 420,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка 3D-сцены
</div>
),
},
);
/** Desktop defaults to 3D; phones (<768px) default to the lighter 2D plan. */
function defaultPlacementView(): "2d" | "3d" {
if (typeof window === "undefined") return "3d";
return window.innerWidth < 768 ? "2d" : "3d";
}
// ── Formatters (ru microcopy) ─────────────────────────────────────────────────
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
function formatMoneyCompact(rub: number): string {
const abs = Math.abs(rub);
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
return `${nf.format(Math.round(rub))}`;
}
function formatInt(n: number): string {
return nf.format(Math.round(n));
}
/**
* Число корпусов на участке = count of placed building footprints (each Feature
* in buildings_geojson is one корпус). Guards a missing FeatureCollection.
*/
function buildingCount(variant: ConceptVariant): number {
return variant.buildings_geojson?.features?.length ?? 0;
}
/**
* A program variant is built from a `building_program` (not a 1b strategy): the
* backend tags every placed feature `properties.strategy === "program"` while
* reporting the variant under the fixed "balanced" strategy slot. Detect it from
* the first feature so we can use the honest «Ваша программа» label instead of
* STRATEGY_LABELS["balanced"] («Баланс»), which would mislabel it.
*/
function isProgramVariant(variant: ConceptVariant): boolean {
const first = variant.buildings_geojson?.features?.[0];
return first?.properties?.strategy === "program";
}
/** Display label: «Ваша программа» for program variants, else strategy label. */
function variantLabel(variant: ConceptVariant): string {
return isProgramVariant(variant)
? "Ваша программа"
: STRATEGY_LABELS[variant.strategy];
}
/**
* Variant subtitle hint: program variants get a program-appropriate sentence
* (the 1b STRATEGY_HINTS speak of «приоритет площади/инсоляции/баланс», which
* misdescribes a user-defined program reported under the "balanced" slot).
*/
function variantHint(variant: ConceptVariant): string {
return isProgramVariant(variant)
? "Раскладка заданной программы по геометрии участка."
: STRATEGY_HINTS[variant.strategy];
}
/** Russian plural for «корпус» (1 корпус / 24 корпуса / 5+ корпусов). */
function pluralizeCorpus(n: number): string {
const mod100 = n % 100;
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 14) return "корпусов";
if (mod10 === 1) return "корпус";
if (mod10 >= 2 && mod10 <= 4) return "корпуса";
return "корпусов";
}
/** «3 корпуса» / «1 корпус» / «12 корпусов» — count + agreed plural. */
function formatCorpuses(n: number): string {
return `${formatInt(n)} ${pluralizeCorpus(n)}`;
}
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
/**
* Знаковый процент — для метрик, которые честно могут быть отрицательными
* (IRR на собственные средства при негативном леверидже). Отрицательное число
* печатается с Unicode-минусом «−» (U+2212), не ASCII «-» (ui-microcopy).
*/
function formatSignedPct(fraction: number): string {
const body = `${(Math.abs(fraction) * 100).toFixed(1)}%`;
return fraction < 0 ? `${body}` : body;
}
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
function formatPayback(months: number | null): string {
if (months === null) return "не окупается";
return `${months.toFixed(1)} мес`;
}
// ── Placement view tab (2D / 3D) ──────────────────────────────────────────────
function PlacementTab({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: active ? "var(--accent)" : "transparent",
border: "none",
borderRadius: 6,
color: active ? "#fff" : "var(--fg-secondary)",
fontSize: 13,
fontWeight: active ? 600 : 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{icon}
{label}
</button>
);
}
// ── Variant panel ─────────────────────────────────────────────────────────────
interface PanelProps {
/** Parcel geometry — Polygon OR MultiPolygon (whole geometry, all parts). */
parcel: Polygon | MultiPolygon;
variant: ConceptVariant;
/** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */
floorHeightM?: number;
/**
* Кадастровый номер участка — для подгрузки соседних зданий (макет квартала)
* в 2D-план / 3D-сцену (#2180). Отсутствует на standalone /concept без
* кадастра → соседи не грузятся.
*/
cadNum?: string | null;
}
function VariantPanel({
parcel,
variant,
floorHeightM = 3.0,
cadNum,
}: PanelProps) {
const { teap, financial } = variant;
// Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop
// defaults to 3D; phones default to 2D and mount 3D only on tap. Only the
// active tab is rendered, so the inactive engine frees its GPU/Leaflet
// resources via its teardown effect on switch.
const [view, setView] = useState<"2d" | "3d">(defaultPlacementView);
const netPositive =
financial.net_profit_rub > 0
? true
: financial.net_profit_rub < 0
? false
: null;
// Stage 3b (#1965) — partial-fit honest note: program mode (requested_count
// != null) where fewer sections fit than were requested. Neutral, not an
// error: the user asked for M, the parcel takes N < M. Greedy / full-fit → null.
const partialFit =
variant.requested_count != null &&
variant.placed_count != null &&
variant.placed_count < variant.requested_count
? { placed: variant.placed_count, requested: variant.requested_count }
: null;
return (
<div>
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
<div
style={{
background: "var(--bg-headline)",
color: "var(--fg-on-dark)",
borderRadius: 12,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 15, fontWeight: 600 }}>
{variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
</div>
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid rgba(255,255,255,0.12)",
fontSize: 12,
color: "var(--fg-on-dark-muted)",
}}
>
{variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} ·
IRR {formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
.
</div>
</div>
{/* Stage 3b — partial-fit note: program asked for more sections than fit. */}
{partialFit && (
<div
role="note"
style={{
marginTop: 12,
padding: "12px 16px",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-secondary)",
}}
>
Размещено {formatInt(partialFit.placed)} из{" "}
{formatCorpuses(partialFit.requested)} участок вмещает меньше, чем в
заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам.
</div>
)}
{/* Placement — 2D plan / 3D massing tab switch (same FeatureCollection). */}
<Section
title="Размещение застройки"
subtitle={`Полигон участка и ${formatCorpuses(
buildingCount(variant),
)} — 2D-план или объёмная модель по программе.`}
right={
<div
role="tablist"
aria-label="Вид размещения"
style={{
display: "inline-flex",
gap: 4,
padding: 4,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 8,
}}
>
<PlacementTab
active={view === "2d"}
icon={<MapIcon size={16} strokeWidth={1.5} aria-hidden="true" />}
label="План (2D)"
onClick={() => setView("2d")}
/>
<PlacementTab
active={view === "3d"}
icon={<Box size={16} strokeWidth={1.5} aria-hidden="true" />}
label="Объём (3D)"
onClick={() => setView("3d")}
/>
</div>
}
>
{view === "2d" ? (
<ConceptResultMap
parcel={parcel}
buildings={variant.buildings_geojson}
variantKey={variant.strategy}
cadNum={cadNum}
/>
) : (
<Massing3DViewer
parcel={parcel}
variant={variant}
floorHeightM={floorHeightM}
cadNum={cadNum}
/>
)}
</Section>
{/* ТЭП — KPI grid */}
<Section
title="ТЭП — технико-экономические показатели"
subtitle="Расчёт движка генерации по выбранной стратегии размещения."
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Площадь застройки"
value={formatInt(teap.built_area_sqm)}
unit="м²"
/>
<KpiCard
label="Общая площадь"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
/>
<KpiCard
label="Жилая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
/>
<KpiCard
label="Плотность (FAR)"
value={teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/>
<KpiCard
label="Машино-мест"
value={formatInt(teap.parking_spaces)}
unit="шт"
/>
</div>
</Section>
{/* Финмодель */}
<Section
title="Финансовая модель"
subtitle="Девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI; поверх — помесячный DCF (NPV, IRR, окупаемость)."
right={<ConceptExportButtons variant={variant} />}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
/>
<KpiCard
label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)}
/>
<KpiCard
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
delta={{
value:
netPositive === true
? "Положительная (после НДС и налога на прибыль)"
: netPositive === false
? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая",
positive: netPositive,
}}
/>
<KpiCard
label="ROI на затраты"
value={formatPct(financial.roi)}
delta={{
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: `Окупаемость ${formatPayback(financial.payback_months)}`,
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{
value: priceSourceCaption(financial),
positive: null,
}}
/>
</div>
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
<details style={{ marginTop: 12 }}>
<summary
style={{
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-secondary)",
}}
>
Подробнее каскад затрат и расчёт прибыли
</summary>
<div style={{ marginTop: 12 }}>
<FinancialCascadeTable financial={financial} />
<p
style={{
marginTop: 12,
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
NPV / IRR / окупаемость рассчитаны помесячным DCF.{" "}
{financial.schedule_is_default
? `График фаз и темп продаж — типовые допущения (ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт ${formatPct(
financial.discount_rate_used,
)} годовых); метрики рассчитаны DCF по этому графику, точность зависит от графика конкретного проекта. `
: ""}
{financial.irr_is_proxy
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
: ""}
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
Входной НДС по строительству уже учтён в себестоимости. Налог на
прибыль 25% (с 2025 года). Цена продажи жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
{financial.financing_enabled && financial.financing_is_simplified
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
"кредитом по ставке-нормативу, проценты капитализируются, " +
"эскроу не моделируется точно. Проценты — реальная стоимость " +
"(чистая прибыль после финансирования ниже прибыли без него)."
: ""}
{financial.financing_enabled
? " IRR на собственные средства — доходность на собственные " +
"средства инвестора при кредите LTC 70 %. Может быть выше " +
"проектной IRR при положительном леверидже или ниже неё " +
"(вплоть до отрицательной), если проект не покрывает стоимость " +
"долга." +
(financial.levered_irr_is_proxy
? " Помечена как оценочная: посчитана по вырожденному " +
"equity-потоку (proxy), а не DCF."
: "")
: ""}
</p>
</div>
</details>
</Section>
</div>
);
}
// ── Cost cascade / P&L detail table ────────────────────────────────────────────
function CascadeRow({
label,
value,
emphasis = false,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<tr>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
color: emphasis ? "var(--fg-primary)" : "var(--fg-secondary)",
fontWeight: emphasis ? 600 : 400,
}}
>
{label}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
textAlign: "right",
fontWeight: emphasis ? 600 : 400,
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</td>
</tr>
);
}
function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<tbody>
<CascadeRow
label="Выручка — жильё"
value={formatMoneyCompact(financial.revenue_residential_rub)}
/>
<CascadeRow
label="Выручка — паркинг"
value={formatMoneyCompact(financial.revenue_parking_rub)}
/>
<CascadeRow
label="Выручка — нежилое (1-й этаж)"
value={formatMoneyCompact(financial.revenue_office_rub)}
/>
<CascadeRow
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
emphasis
/>
<CascadeRow
label="СМР"
value={formatMoneyCompact(financial.construction_rub)}
/>
<CascadeRow
label="ПИР (проектирование)"
value={formatMoneyCompact(financial.pir_rub)}
/>
<CascadeRow
label="Сети (ТУ)"
value={formatMoneyCompact(financial.networks_rub)}
/>
<CascadeRow
label="Услуги заказчика"
value={formatMoneyCompact(financial.developer_services_rub)}
/>
<CascadeRow
label="Резерв"
value={formatMoneyCompact(financial.contingency_rub)}
/>
<CascadeRow
label="Маркетинг и риэлтор"
value={formatMoneyCompact(financial.marketing_rub)}
/>
<CascadeRow
label="Земля"
value={formatMoneyCompact(financial.land_rub)}
/>
<CascadeRow
label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)}
emphasis
/>
<CascadeRow
label="Валовая маржа"
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (паркинг)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow
label="Прибыль до налога"
value={formatMoneyCompact(financial.profit_before_tax_rub)}
/>
<CascadeRow
label="Налог на прибыль (25%)"
value={formatMoneyCompact(financial.profit_tax_rub)}
/>
<CascadeRow
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
emphasis
/>
<CascadeRow
label={`NPV (DCF, дисконт ${formatPct(financial.discount_rate_used)})`}
value={formatMoneyCompact(financial.npv_rub)}
/>
<CascadeRow
label={`IRR (годовой)${financial.irr_is_proxy ? ", оценочный" : ""}`}
value={formatPct(financial.irr)}
/>
<CascadeRow
label="Окупаемость (PBP)"
value={formatPayback(financial.payback_months)}
/>
{/* #1881 PR-5 — финансирование (кредит). Показываем только когда движок
смоделировал финансирование (financing_enabled). */}
{financial.financing_enabled ? (
<>
<CascadeRow
label="Пиковый долг"
value={formatMoneyCompact(financial.peak_debt_rub)}
/>
<CascadeRow
label={`Проценты по кредиту (ставка ${formatPct(
financial.annual_rate_used,
)} годовых)`}
value={formatMoneyCompact(financial.total_interest_rub)}
/>
<CascadeRow
label="Чистая прибыль после финансирования"
value={formatMoneyCompact(
financial.net_profit_after_financing_rub,
)}
emphasis
/>
{/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше
проектной IRR (положительный леверидж) или ниже / отрицательной
(проект не покрывает стоимость долга) — знак показываем честно. */}
<CascadeRow
label={`IRR на собственные средства (с учётом кредита, LTC 70%)${
financial.levered_irr_is_proxy ? ", оценочный" : ""
}`}
value={formatSignedPct(financial.levered_irr)}
/>
</>
) : null}
</tbody>
</table>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
/**
* Parcel geometry for the placement views — Polygon OR MultiPolygon. The §7
* report passes the WHOLE geometry (`extractParcelGeometry`) so the 2D/3D
* scenes render every part and stay geo-aligned with backend-anchored
* neighbours; the standalone /concept page passes its single drawn/resolved
* Polygon.
*/
parcel: Polygon | MultiPolygon;
variants: ConceptVariant[];
/**
* Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's
* `_FLOOR_HEIGHT`. Optional so Phase 2 can thread the form's live knob without
* a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж).
*/
floorHeightM?: number;
/**
* Кадастровый номер участка — прокидывается в 2D-план и 3D-сцену для загрузки
* соседних зданий (макет квартала, #2180). В отчёте берётся из analysis.cad_num;
* на standalone /concept (границы рисуются / резолвятся вручную) не передаётся →
* соседи не грузятся, сцена работает как раньше.
*/
cadNum?: string | null;
}
export function ConceptVariantsResult({
parcel,
variants,
floorHeightM = 3.0,
cadNum,
}: Props) {
const [active, setActive] = useState(0);
if (variants.length === 0) {
return (
<p style={{ fontSize: 14, color: "var(--fg-secondary)" }}>
Движок не вернул ни одного варианта застройки.
</p>
);
}
const activeVariant = variants[Math.min(active, variants.length - 1)];
// A lone tab is noise (common for program mode → one variant). Hide the tab
// bar entirely with a single variant; keep it as-is when there's a choice.
const showTabs = variants.length > 1;
return (
<div>
{/* Variant tabs */}
{showTabs && (
<div
role="tablist"
aria-label="Варианты застройки"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid var(--border-card)",
marginBottom: 4,
overflowX: "auto",
}}
>
{variants.map((v, i) => {
const isActive = i === active;
return (
<button
key={v.strategy}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActive(i)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "transparent",
border: "none",
borderBottom: isActive
? "2px solid var(--accent)"
: "2px solid transparent",
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{variantLabel(v)}
<Badge variant={isActive ? "info" : "neutral"} size="sm">
{v.teap.apartments_count > 0
? `${formatInt(v.teap.apartments_count)} кв.`
: "—"}
</Badge>
</button>
);
})}
</div>
)}
<VariantPanel
parcel={parcel}
variant={activeVariant}
floorHeightM={floorHeightM}
cadNum={cadNum}
/>
</div>
);
}