gendesign/frontend/src/components/site-finder/ConfidenceBadge.tsx
lekss361 3777a77a42
feat(site-finder): X2 confidence indicator + caveats (#48) (#88)
Backend (parcels.py):
- _compute_confidence() composite score 0..1 from 7 subscores: poi_freshness,
  geom_source (parcel vs quarter), district, market_trend (rosreestr_deals depth),
  competitors, environment (noise/air/weather availability), zoning (placeholder
  до G1).
- confidence_label: high (>0.75) / medium (0.4-0.75) / low (<0.4)
- confidence_caveats: list of конкретных проблем для UI
- confidence_breakdown: per-subscore 0..1 для прозрачности

Это stub-версия (полная — после G1/G2/D1/D2). Использует только текущие сигналы.

Frontend:
- Новый ConfidenceBadge.tsx — color-coded (green/yellow/red) badge с %
- Caveats для low — показываются сразу; для medium/high — под toggle
- Toggle "Подробнее" → breakdown per-subscore + полный список caveats
- Размещён в начале OverviewTab (выше "Район")
- TS типы расширены: confidence, confidence_label, confidence_breakdown, confidence_caveats

Closes #48.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 01:04:25 +03:00

167 lines
4.5 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 { useState } from "react";
interface Props {
value: number;
label: "high" | "medium" | "low";
breakdown?: Record<string, number>;
caveats?: string[];
}
const LABEL_RU: Record<Props["label"], string> = {
high: "высокая",
medium: "средняя",
low: "низкая",
};
const COLOR: Record<
Props["label"],
{ bg: string; fg: string; border: string }
> = {
high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" },
low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
};
const BREAKDOWN_RU: Record<string, string> = {
poi_freshness: "Свежесть OSM POI",
geom_source: "Точность геометрии",
district: "Известность района",
market_trend: "Глубина ДДУ",
competitors: "Покрытие конкурентами",
environment: "Экологические данные",
zoning: "ПЗЗ / зонирование",
};
export function ConfidenceBadge({ value, label, breakdown, caveats }: Props) {
const [expanded, setExpanded] = useState(false);
const c = COLOR[label];
const pct = Math.round(value * 100);
const hasDetails =
(caveats && caveats.length > 0) ||
(breakdown && Object.keys(breakdown).length > 0);
return (
<div
style={{
border: `1px solid ${c.border}`,
background: c.bg,
borderRadius: 10,
padding: "10px 14px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: c.fg,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Достоверность
</span>
<span
style={{
fontSize: 14,
fontWeight: 700,
color: c.fg,
fontVariantNumeric: "tabular-nums",
}}
title="Composite confidence: средневзвешенная по 7 подскорам"
>
{pct}% · {LABEL_RU[label]}
</span>
</div>
{hasDetails && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: c.fg,
fontSize: 12,
cursor: "pointer",
fontWeight: 500,
textDecoration: "underline",
}}
>
{expanded ? "Скрыть" : "Подробнее"}
</button>
)}
</div>
{/* Caveats — показываем сразу для low, под toggle для medium/high */}
{caveats && caveats.length > 0 && (label === "low" || expanded) && (
<ul
style={{
margin: 0,
padding: 0,
paddingLeft: 18,
fontSize: 12,
color: c.fg,
display: "flex",
flexDirection: "column",
gap: 3,
}}
>
{caveats.map((cv, i) => (
<li key={i}>{cv}</li>
))}
</ul>
)}
{/* Breakdown — под toggle */}
{expanded && breakdown && Object.keys(breakdown).length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 12,
color: c.fg,
paddingTop: 6,
borderTop: `1px solid ${c.border}`,
}}
>
{Object.entries(breakdown).map(([k, v]) => (
<div
key={k}
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<span>{BREAKDOWN_RU[k] ?? k}</span>
<span
style={{
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
}}
>
{Math.round(v * 100)}%
</span>
</div>
))}
</div>
)}
</div>
);
}