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.
248 lines
8.2 KiB
TypeScript
248 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|