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>
167 lines
4.5 KiB
TypeScript
167 lines
4.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|