gendesign/frontend/src/components/site-finder/OverviewTab.tsx
Light1YT f7773c71d4
All checks were successful
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 48s
fix(site-finder): P3 a11y/UI batch from audit #1871
- add <h1> page title to analysis page (иерархия стартовала с h2 — a11y)
- emoji → Lucide во всех вердикт/баннер/статус UI (ui-conventions: emoji
  запрещены): GateVerdictBanner (VerdictColor.icon string→ComponentType,
  sourceHint→ReactNode, SectionLabels), + найдены ещё 3 файла:
  OverviewTab (⚠ ЛЭП/трамвай), GeotechRiskBlock (🟡 вечная мерзлота → Snowflake,
  ⚠ загрязнение), HydrologyBlock (⚠ паводок, 🏞💦🪷🚣💧 subtype-map → Waves/Droplet).
  Все иконки декоративные → aria-hidden, семантика дублируется текстом.
  Семантические цвета через var(--success/--warn/--danger) + hex-fallback.
- расшифровка аббревиатур при первом упоминании (ui-microcopy):
  Section2 «строительство в охранной зоне (ОЗ)... зоне с особыми условиями
  использования территорий (ЗОУИТ), тип 5»; Section4 title-tooltip для ЗОУИТ.
- ForecastChart: axis-chrome hex (:65/67/68) задокументирован как canvas-renderer
  исключение (echarts canvas не резолвит CSS var() — var() сломал бы chrome,
  как и VIZ-series). Цвета не тронуты, только комментарий.

НЕ в scope: ForecastChart CI-band (±p25/p75 — backend не отдаёт, отдельная
задача); ★ в WeightProfilePanel <option> (SVG в option невозможен);
токенизация legacy-hex в Geotech/Hydrology (отдельный pass).

138 frontend vitest passed, tsc/lint clean. lucide-react уже в deps.

Refs #1871
2026-06-23 12:52:32 +05:00

360 lines
12 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";
import { AlertTriangle } from "lucide-react";
import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { GateVerdictBanner } from "./GateVerdictBanner";
import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
import { ScoreBreakdownStackedBar } from "./ScoreBreakdownStackedBar";
interface Props {
data: ParcelAnalysis;
onIsochronesResult?: (fc: FeatureCollection) => void;
}
const CATEGORY_LABELS: Record<string, string> = {
school: "Школы",
kindergarten: "Детские сады",
pharmacy: "Аптеки",
hospital: "Больницы / клиники",
shop_mall: "ТЦ / ТРЦ",
shop_supermarket: "Супермаркеты",
shop_small: "Магазины",
park: "Парки",
tram_stop: "Трамвайные остановки",
bus_stop: "Автобусные остановки",
metro_stop: "Метро",
};
function avgDist(items: Array<{ distance_m: number }>): number {
if (!items.length) return 0;
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
}
export function OverviewTab({ data, onIsochronesResult }: Props) {
const tramPois = data.score_breakdown["tram_stop"] ?? [];
const nearestTram =
tramPois.length > 0
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
: null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* G5 (#32): gate verdict banner — МКД buildability */}
{data.gate_verdict && <GateVerdictBanner verdict={data.gate_verdict} />}
{/* X2 (#48): confidence indicator на самом верху Overview */}
{data.confidence !== undefined && data.confidence_label && (
<ConfidenceBadge
value={data.confidence}
label={data.confidence_label}
breakdown={data.confidence_breakdown}
caveats={data.confidence_caveats}
/>
)}
{/* District info */}
{data.district && (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>Район</SectionLabel>
<div style={{ fontSize: 16, fontWeight: 700, color: "#111827" }}>
{data.district.district_name}
</div>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Медиана:{" "}
<strong style={{ color: "#374151" }}>
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс /м²
</strong>
<span className="text-xs text-slate-500 ml-1">(12 мес)</span>
<span style={{ marginLeft: 12 }}>
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
</span>
</div>
{data.location && (
<div
style={{ fontSize: 12, color: "#9ca3af" }}
title={data.location.note}
>
До центра ЕКБ:{" "}
<strong style={{ color: "#374151" }}>
{data.location.distance_to_center_km.toFixed(1)} км
</strong>
{data.location.center_bonus > 0 && (
<span
style={{ color: "#16a34a", fontWeight: 600, marginLeft: 6 }}
>
(+{data.location.center_bonus.toFixed(1)} к score)
</span>
)}
</div>
)}
</div>
)}
{/* B5 EGRN compact (added 2026-05-18 — expose new field в legacy Обзор) */}
{data.egrn && (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>ЕГРН</SectionLabel>
<div style={{ fontSize: 13, color: "#374151" }}>
{data.egrn.address && (
<div>
Адрес: <strong>{data.egrn.address}</strong>
</div>
)}
{data.egrn.permitted_use_text && (
<div>
ВРИ: <strong>{data.egrn.permitted_use_text}</strong>
</div>
)}
{data.egrn.land_category && (
<div>
Категория: <strong>{data.egrn.land_category}</strong>
</div>
)}
{data.egrn.parcel_status && (
<div>
Статус: <strong>{data.egrn.parcel_status}</strong>
</div>
)}
{data.egrn.right_type && (
<div>
Право: <strong>{data.egrn.right_type}</strong>
</div>
)}
</div>
</div>
)}
{/* B5 Utilities summary (added 2026-05-18 — expose new field в legacy Обзор) */}
{data.utilities &&
Array.isArray(data.utilities.summary) &&
data.utilities.summary.length > 0 && (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>Сети рядом</SectionLabel>
<div style={{ fontSize: 13, color: "#374151" }}>
{data.utilities.summary
.map((u) => {
const label =
u.subtype === "substation"
? "электричество"
: u.subtype === "power_line"
? "ЛЭП"
: u.subtype === "pipeline"
? "газ"
: u.subtype === "water_intake"
? "вода"
: u.subtype === "pumping_station"
? "канализация"
: u.subtype;
return `${label} ${u.nearest_m} м`;
})
.join(" · ")}
</div>
{data.utilities.power_line_охранная_зона_flag && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: "#b3261e",
fontWeight: 600,
marginTop: 4,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>
Охранная зона ЛЭП капитальное строительство запрещено
</span>
</div>
)}
</div>
)}
{/* Tram warning */}
{nearestTram !== null && (
<div
style={{
padding: "10px 16px",
background: "#fff7ed",
border: "1px solid #fed7aa",
borderRadius: 10,
fontSize: 13,
color: "#c2410c",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>Трамвай в {nearestTram} м возможный источник шума</span>
</div>
)}
{/* Score details */}
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<SectionLabel style={{ marginBottom: 6 }}>Балл</SectionLabel>
<div style={{ fontSize: 13, color: "#6b7280" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
{data.score_explanation && (
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
{data.score_explanation}
</div>
)}
{data.score_without_center !== undefined && (
<div style={{ fontSize: 12, color: "#9ca3af" }}>
Без бонуса центра: {data.score_without_center.toFixed(2)}
</div>
)}
</div>
{/* X1 (#47): per-factor score breakdown с verbal explain */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownPanel
topPositives={data.score_top_3_positives ?? []}
topNegatives={data.score_top_3_negatives ?? []}
byGroup={data.score_by_group ?? []}
detailed={data.score_breakdown_detailed}
/>
)}
{/* #114: score breakdown stacked bar by category */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownStackedBar breakdown={data.score_breakdown_detailed} />
)}
{/* POI breakdown */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<SectionLabel style={{ marginBottom: 12 }}>
POI по категориям
</SectionLabel>
{Object.entries(data.score_breakdown).length === 0 ? (
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{Object.entries(data.score_breakdown).map(([cat, items]) => (
<div
key={cat}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 13,
}}
>
<span
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
>
{CATEGORY_LABELS[cat] ?? cat}
</span>
<span style={{ color: "#6b7280", fontSize: 12 }}>
{items.length} шт · ср. {avgDist(items)} м
</span>
</div>
))}
</div>
)}
</div>
{/* Isochrones */}
{data.isochrones_available === false ? (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#f9fafb",
}}
>
<SectionLabel style={{ marginBottom: 6 }}>
Изохроны доступности
</SectionLabel>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
Недоступны нужен OpenRouteService API key (см.{" "}
<code style={{ fontFamily: "monospace" }}>
backend/.env.runtime
</code>
)
</div>
</div>
) : data.isochrones_available === true && onIsochronesResult ? (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
overflow: "hidden",
}}
>
<IsochronesPanel
cadNum={data.cad_num}
onResult={onIsochronesResult}
/>
</div>
) : null}
</div>
);
}