gendesign/frontend/src/components/site-finder/OverviewTab.tsx
lekss361 6ecd7a9269 feat(#254,#114): custom POI UI + score breakdown stacked-bar viz
Task A (#254): Leaflet click-to-add custom POIs
- sessionId.ts: getOrCreateSessionId() from localStorage
- types/customPoi.ts: CustomPoi, CustomPoiCreate, CustomPoiUpdate
- hooks/useCustomPois.ts: useCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi (TanStack Query, X-Session-Id header)
- CustomPoiLayer.tsx: LayerGroup with CircleMarker (green/red/gray by weight), popup with edit/delete
- CustomPoiToggleButton.tsx: crosshair mode toggle button
- CustomPoiAddModal.tsx: modal form (name, 26 categories, weight -5..+5, notes)
- CustomPoiEditModal.tsx: same form for editing existing POI
- SiteMap.tsx: MapClickHandler (useMapEvents), integrated all custom POI components
- page.tsx: useCustomPois hook + passes customPois + parcelCad to SiteMap

Task B (#114): ScoreBreakdownStackedBar
- ScoreBreakdownStackedBar.tsx: horizontal stacked bar by category with positive/negative split, toggle between category and factor views, custom POIs highlighted orange with dashed outline
- OverviewTab.tsx: renders ScoreBreakdownStackedBar below ScoreBreakdownPanel

Requires backend PR with /api/v1/custom-pois endpoints merged before POI CRUD works.
2026-05-17 09:35:35 +03:00

248 lines
8.2 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 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 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>
)}
{/* 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,
}}
>
<span style={{ fontSize: 16 }}></span>
<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>
);
}