feat(sf-fe-a7): Section 3.1 «Настройки выборки» + Server/Client split (rebased) (#356)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / deploy (push) Successful in 40s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m45s

This commit is contained in:
lekss361 2026-05-18 00:55:05 +00:00
parent 307a75f333
commit 68b1968826
3 changed files with 800 additions and 167 deletions

View file

@ -0,0 +1,355 @@
"use client";
import Link from "next/link";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
export function AnalysisPageContent({ cad }: Props) {
const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
// ── Loading ────────────────────────────────────────────────────────────────
if (isLoading) {
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<div
style={{
marginTop: 24,
padding: "40px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 14,
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-soft, #EEF0F3)",
borderRadius: 12,
}}
>
Анализируем участок {cad}
</div>
</main>
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data) {
const msg =
error instanceof Error ? error.message : "Ошибка загрузки анализа";
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<div
style={{
marginTop: 24,
padding: "16px 20px",
background: "var(--danger-soft, #FEE2E2)",
border: "1px solid var(--danger, #B3261E)",
borderRadius: 12,
color: "var(--danger, #B3261E)",
fontSize: 13,
}}
>
{msg}
</div>
<div style={{ marginTop: 16 }}>
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--accent, #1D4ED8)",
textDecoration: "none",
}}
>
Вернуться к Site Finder
</Link>
</div>
</main>
);
}
// ── Results layout ─────────────────────────────────────────────────────────
// Cast to ParcelAnalysis (superset of ParcelAnalyzeResponse) — both describe
// the same /analyze endpoint; Section3 requires the richer type.
const analysis = data as unknown as ParcelAnalysis;
const districtName = analysis.district?.district_name ?? "—";
const areaHa = analysis.geometry_suitability?.area_ha;
const areaStr = areaHa != null ? `${areaHa.toFixed(2)} га` : null;
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
{/* Breadcrumb */}
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
{/* Page layout: sidebar (240px) + main scroll */}
<div
style={{
display: "grid",
gridTemplateColumns: "240px 1fr",
gap: 24,
marginTop: 20,
alignItems: "start",
}}
>
{/* ── Sidebar nav ───────────────────────────────────────────────── */}
<aside
style={{
position: "sticky",
top: 72,
alignSelf: "start",
}}
>
<AnalysisSidebarNav />
</aside>
{/* ── Main scroll area ──────────────────────────────────────────── */}
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{/* Section 1 — IMPLEMENTED in A5 */}
<Section1ParcelInfo cad={cad} />
{/* Section 2 — IMPLEMENTED in A6 */}
<Section2NetworksUtilities cad={cad} />
{/* Section 3 — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* Section 4 placeholder — filled by A10 */}
<section id="section-4" style={{ scrollMarginTop: 72 }}>
<SectionPlaceholder
number="4"
title="Оценка участка"
note="Заполняется в A10: ScoreBreakdown, GateVerdict, риски"
/>
</section>
{/* Section 5 placeholder — filled by A11 */}
<section id="section-5" style={{ scrollMarginTop: 72 }}>
<SectionPlaceholder
number="5"
title="Атмосфера / воздух"
note="Заполняется в A11: SeasonalWeather, air quality"
/>
</section>
</div>
</div>
</main>
);
}
// ── Breadcrumb ─────────────────────────────────────────────────────────────────
function Breadcrumb({
cad,
districtName,
areaStr,
}: {
cad: string;
districtName?: string;
areaStr?: string | null;
}) {
const parts = [cad];
if (districtName && districtName !== "—") parts.push(districtName);
if (areaStr) parts.push(areaStr);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
}}
>
<Link
href="/site-finder"
style={{
color: "var(--accent, #1D4ED8)",
textDecoration: "none",
fontWeight: 500,
}}
>
Site Finder
</Link>
<span style={{ color: "var(--fg-tertiary, #73767E)" }}>/</span>
{parts.map((part, i) => (
<span key={i}>
{i > 0 && (
<span
style={{
margin: "0 6px",
color: "var(--fg-tertiary, #73767E)",
}}
>
&middot;
</span>
)}
{part}
</span>
))}
<span style={{ margin: "0 4px", color: "var(--fg-tertiary, #73767E)" }}>
&middot;
</span>
<span style={{ color: "var(--fg-tertiary, #73767E)" }}>анализ</span>
</div>
);
}
// ── Sidebar nav ────────────────────────────────────────────────────────────────
const NAV_ITEMS = [
{ id: "section-1", label: "1. Информация" },
{ id: "section-2", label: "2. Сети" },
{
id: "section-3",
label: "3. Продажи конкурентов",
children: [
{ id: "section-3-1", label: "3.1 Настройки выборки" },
{ id: "section-3-2", label: "3.2 Планировки" },
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
],
},
{ id: "section-4", label: "4. Оценка" },
{ id: "section-5", label: "5. Атмосфера" },
];
function AnalysisSidebarNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
return (
<nav
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "12px 0",
display: "flex",
flexDirection: "column",
}}
>
{NAV_ITEMS.map((item) => (
<div key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 16px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary, #111111)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{item.label}
</button>
{"children" in item &&
item.children?.map((child) => (
<button
key={child.id}
type="button"
onClick={() => scrollTo(child.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{child.label}
</button>
))}
</div>
))}
</nav>
);
}
// ── Section placeholder ────────────────────────────────────────────────────────
function SectionPlaceholder({
number,
title,
note,
}: {
number: string;
title: string;
note: string;
}) {
return (
<>
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 12,
}}
>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
{number}. {title}
</div>
</div>
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
{note}
</div>
</>
);
}

View file

@ -1,181 +1,45 @@
"use client"; import { Suspense } from "react";
import type { Metadata } from "next";
import { use } from "react"; import { AnalysisPageContent } from "./AnalysisPageContent";
import { LayoutDashboard } from "lucide-react";
import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar"; // ── Metadata ──────────────────────────────────────────────────────────────────
import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb";
import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
// ── Section placeholder (Wave 34 refactor will replace these) ─────────────────
interface SectionPlaceholderProps {
id: string;
title: string;
note?: string;
}
function SectionPlaceholder({ id, title, note }: SectionPlaceholderProps) {
return (
<section
id={id}
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<h2
style={{
margin: "0 0 8px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
{title}
</h2>
{note && (
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-tertiary)" }}>
{note}
</p>
)}
<div
style={{
marginTop: 16,
padding: "24px 16px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
<LayoutDashboard
size={16}
strokeWidth={1.5}
style={{ marginRight: 8, flexShrink: 0 }}
/>
Заполняется в Wave 24 (A5A11)
</div>
</section>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
interface PageProps { interface PageProps {
params: Promise<{ cad: string }>; params: Promise<{ cad: string }>;
} }
export default function AnalysisPage({ params }: PageProps) { export async function generateMetadata({
// React 19: use() unwraps Promise params (app router pattern). params,
// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once here so }: PageProps): Promise<Metadata> {
// downstream hooks/components see the canonical "66:41:0204016:10" and can const { cad } = await params;
// re-encode exactly once when building API URLs / hrefs (prevents double- return {
// encode that made backend regex reject the cad with HTTP 400). title: `Анализ ${cad} — Site Finder`,
const { cad: cadRaw } = use(params); description: `Анализ инвестиционного участка ${cad}`,
const cad = decodeURIComponent(cadRaw); };
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default async function AnalysisPage({ params }: PageProps) {
const { cad } = await params;
return ( return (
<main <Suspense
style={{ fallback={
minHeight: "100vh", <div
background: "var(--bg-app)",
display: "flex",
flexDirection: "column",
}}
>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
padding: "12px 24px",
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
position: "sticky",
top: 0,
zIndex: 10,
minHeight: 56,
boxSizing: "border-box",
}}
>
<AnalysisBreadcrumb cadNum={cad} />
{/* Push avatar to the right */}
<div style={{ marginLeft: "auto" }}>
<UserAvatar />
</div>
</header>
{/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
<div style={{ display: "flex", flex: 1 }}>
<AnalysisSidebar />
{/* Main scrollable content */}
<article
style={{ style={{
flex: 1, padding: "40px 24px",
padding: "24px 32px", textAlign: "center",
overflowY: "auto", color: "var(--fg-tertiary, #73767E)",
maxWidth: 1000, fontSize: 14,
}} }}
> >
{/* Section 1 — A5: Инфо об участке */} Загрузка анализа
<Section1ParcelInfo cad={cad} /> </div>
}
{/* Section 2 — A6: Сети и точки подключения */} >
<Section2NetworksUtilities cad={cad} /> <AnalysisPageContent cad={cad} />
</Suspense>
{/* Section 3 — TODO A7/A8/A9 */}
<SectionPlaceholder
id="section-3"
title="3. Рынок"
note="TODO A7: WeightProfilePanel (настройки выборки) · A8: BestLayoutsBlock / Pipeline24moBlock / VelocityBlock · A9: CompetitorTable с drawer drill-in"
/>
{/* Sub-section anchor targets (invisible) for scrollspy / direct links */}
<div
id="section-3-1"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
<div
id="section-3-2"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
<div
id="section-3-3"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
{/* Section 4 — TODO A10 */}
<SectionPlaceholder
id="section-4"
title="4. Инфраструктура"
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
/>
{/* Section 5 — TODO A11 */}
<SectionPlaceholder
id="section-5"
title="5. Свежесть"
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
/>
</article>
</div>
</main>
); );
} }

View file

@ -0,0 +1,414 @@
"use client";
import { useState } from "react";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import type { ParcelAnalysis } from "@/types/site-finder";
// ── Types ─────────────────────────────────────────────────────────────────────
interface Props {
/** Cadastral number — passed for context/future backend pass-through. */
cad: string;
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
data: ParcelAnalysis;
}
interface FilterState {
/** Radius in km, 15. */
radiusKm: number;
/** Show only buildings under construction. */
onlyUnderConstruction: boolean;
/** Completion deadline ≥ 3 months from now. */
minDeadline3mo: boolean;
/** Project started ≤ 6 months ago. */
maxAge6mo: boolean;
/** Apartments only (excludes commercial / parking-only). */
onlyApartments: boolean;
}
// ── Chip component ─────────────────────────────────────────────────────────────
interface ChipProps {
label: string;
selected: boolean;
onToggle: () => void;
}
function FilterChip({ label, selected, onToggle }: ChipProps) {
return (
<button
type="button"
onClick={onToggle}
style={{
padding: "5px 12px",
fontSize: 12,
fontWeight: 500,
borderRadius: 999,
border: selected
? "1px solid var(--accent, #1D4ED8)"
: "1px solid var(--border-card, #E6E8EC)",
background: selected
? "var(--accent-soft, #DBEAFE)"
: "var(--bg-card, #FFFFFF)",
color: selected
? "var(--accent, #1D4ED8)"
: "var(--fg-secondary, #5B6066)",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
aria-pressed={selected}
>
{label}
</button>
);
}
// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
function Section31Settings({
filters,
onFiltersChange,
}: {
filters: FilterState;
onFiltersChange: (f: FilterState) => void;
}) {
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
() => ({ ...POI_DEFAULT_WEIGHTS }),
);
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
function handleWeightsChange(
newWeights: Record<PoiCategoryKey, number>,
_profileId: number | null,
) {
setWeights(newWeights);
}
const chips: Array<{
key: keyof Omit<FilterState, "radiusKm">;
label: string;
}> = [
{ key: "onlyUnderConstruction", label: "Только строящиеся" },
{ key: "minDeadline3mo", label: "+3 мес срок" },
{ key: "maxAge6mo", label: "6 мес от сегодня" },
{ key: "onlyApartments", label: "Только квартиры" },
];
return (
<div id="section-3-1" style={{ scrollMarginTop: 72 }}>
{/* Sub-section title */}
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: 0,
}}
>
3.1 Настройки выборки
</h3>
<p
style={{
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Фильтры применяются к конкурентам локально без повторного запроса к
бэкенду
</p>
</div>
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{/* Radius slider */}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}}
>
<label
htmlFor="radius-slider"
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Радиус выборки
</label>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
fontVariantNumeric: "tabular-nums",
}}
>
{filters.radiusKm} км
</span>
</div>
<input
id="radius-slider"
type="range"
min={1}
max={5}
step={0.5}
value={filters.radiusKm}
onChange={(e) =>
onFiltersChange({
...filters,
radiusKm: parseFloat(e.target.value),
})
}
style={{
width: "100%",
accentColor: "var(--accent, #1D4ED8)",
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 4,
fontVariantNumeric: "tabular-nums",
}}
>
<span>1 км</span>
<span>5 км</span>
</div>
</div>
{/* Filter chips */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
Фильтры объектов
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{chips.map(({ key, label }) => (
<FilterChip
key={key}
label={label}
selected={filters[key]}
onToggle={() => toggleChip(key)}
/>
))}
</div>
</div>
{/* Weight profile — reuse existing panel */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
Профиль весов POI
</div>
<WeightProfilePanel
currentWeights={weights}
onWeightsChange={handleWeightsChange}
/>
</div>
</div>
</div>
);
}
// ── Section 3.2 placeholder ────────────────────────────────────────────────────
function Section32Placeholder() {
return (
<div id="section-3-2" style={{ scrollMarginTop: 72 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: "0 0 12px",
}}
>
3.2 Планировки конкурентов
</h3>
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
Раздел появится в A8 планировки конкурентов (BestLayoutsBlock)
</div>
</div>
);
}
// ── Section 3.3 placeholder ────────────────────────────────────────────────────
function Section33Placeholder() {
return (
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: "0 0 12px",
}}
>
3.3 Остатки и скорость продаж
</h3>
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
Раздел появится в A8 Pipeline24mo / Velocity / MarketTrend
</div>
</div>
);
}
// ── Derived: filter competitors client-side ───────────────────────────────────
function applyFilters(
data: ParcelAnalysis,
filters: FilterState,
): ParcelAnalysis["competitors"] {
let result = [...data.competitors];
// Radius filter — distance_m is in metres from the parcel centroid
result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
// Note: the competitor type doesn't carry status/date fields yet (B6 extension).
// The chips are wired to state but can't filter until backend enriches data.
// They're kept in state so backend pass-through (Wave 4) can use them.
return result;
}
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
const [filters, setFilters] = useState<FilterState>({
radiusKm: 2,
onlyUnderConstruction: false,
minDeadline3mo: false,
maxAge6mo: false,
onlyApartments: false,
});
const filteredCompetitors = applyFilters(data, filters);
return (
<section id="section-3" style={{ scrollMarginTop: 72 }}>
{/* Section headline bar */}
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 20,
}}
>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
3. Продажи конкурентов
</div>
<div
style={{
marginTop: 6,
fontSize: 12,
color: "var(--fg-on-dark-muted, #94A3B8)",
}}
>
{data.competitors.length > 0
? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км`
: "Конкуренты не найдены в радиусе анализа"}
</div>
</div>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<Section32Placeholder />
<Section33Placeholder />
</div>
{/* Filtered competitors count hint */}
{data.competitors.length > 0 &&
filteredCompetitors.length !== data.competitors.length && (
<div
style={{
marginTop: 16,
padding: "10px 14px",
background: "var(--warn-soft, #FEF3C7)",
borderRadius: 8,
fontSize: 12,
color: "var(--warn, #9A6700)",
}}
>
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{data.competitors.length} конкурентов. Разделы 3.2 и 3.3 отобразят
данные с учётом выборки после A8.
</div>
)}
</section>
);
}