feat(site-finder): X1 score breakdown + verbal explain (#47) #92
4 changed files with 578 additions and 9 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
|
@ -270,6 +271,55 @@ _POI_WEIGHTS: dict[str, float] = {
|
|||
"tram_stop": -0.5, # негативный вес — шум / вибрация
|
||||
}
|
||||
|
||||
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||||
_POI_CATEGORY_RU: dict[str, str] = {
|
||||
"school": "Школа",
|
||||
"kindergarten": "Детсад",
|
||||
"pharmacy": "Аптека",
|
||||
"hospital": "Больница",
|
||||
"shop_mall": "ТЦ",
|
||||
"shop_supermarket": "Супермаркет",
|
||||
"shop_small": "Магазин",
|
||||
"park": "Парк",
|
||||
"bus_stop": "Автобус",
|
||||
"metro_stop": "Метро",
|
||||
"tram_stop": "Трамвай",
|
||||
}
|
||||
|
||||
# Группировка POI по тематическим эшелонам — для stacked-bar % contribution
|
||||
# (X1 score breakdown). Расширяй по мере добавления новых категорий.
|
||||
_POI_GROUP: dict[str, str] = {
|
||||
"school": "Социалка",
|
||||
"kindergarten": "Социалка",
|
||||
"pharmacy": "Социалка",
|
||||
"hospital": "Социалка",
|
||||
"shop_mall": "Торговля",
|
||||
"shop_supermarket": "Торговля",
|
||||
"shop_small": "Торговля",
|
||||
"park": "Парки",
|
||||
"bus_stop": "Транспорт",
|
||||
"metro_stop": "Транспорт",
|
||||
"tram_stop": "Шум/трамвай",
|
||||
}
|
||||
|
||||
|
||||
def _verbal_for_poi(
|
||||
cat: str,
|
||||
name: str | None,
|
||||
distance_m: float,
|
||||
contribution: float,
|
||||
) -> str:
|
||||
"""Сгенерировать verbal explain для одного POI-вклада.
|
||||
|
||||
Пример: "Школа №125 в 400м — +0.90 баллов".
|
||||
Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов".
|
||||
"""
|
||||
label = _POI_CATEGORY_RU.get(cat, cat)
|
||||
safe_name = (name or "").strip()
|
||||
name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else ""
|
||||
sign = "+" if contribution >= 0 else "−"
|
||||
return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
|
||||
|
||||
|
||||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||
# Добавляй регионы по мере расширения географии продукта.
|
||||
|
|
@ -530,8 +580,6 @@ def _compute_confidence(
|
|||
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||||
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
caveats: list[str] = []
|
||||
subscores: dict[str, float] = {}
|
||||
|
||||
|
|
@ -567,9 +615,15 @@ def _compute_confidence(
|
|||
if not district_row:
|
||||
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
|
||||
|
||||
# 4) Market trend — есть ли rosreestr_deals
|
||||
if market_trend and market_trend.get("recent_deals_count"):
|
||||
n_recent = int(market_trend["recent_deals_count"])
|
||||
# 4) Market trend — есть ли rosreestr_deals.
|
||||
# Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric
|
||||
# из external/legacy paths; без guard int() крашнет 500.
|
||||
n_recent_raw = (market_trend or {}).get("recent_deals_count")
|
||||
try:
|
||||
n_recent = int(n_recent_raw) if n_recent_raw is not None else 0
|
||||
except (ValueError, TypeError):
|
||||
n_recent = 0
|
||||
if n_recent > 0:
|
||||
# порог 5 сделок за 6 мес — достаточно для тренда
|
||||
subscores["market_trend"] = min(1.0, n_recent / 10.0)
|
||||
if n_recent < 5:
|
||||
|
|
@ -751,16 +805,20 @@ def analyze_parcel(
|
|||
# 4) Scoring: weighted sum с distance decay
|
||||
score = 0.0
|
||||
by_category: dict[str, list[dict[str, Any]]] = {}
|
||||
for p in poi_rows:
|
||||
# X1 (#47): per-POI breakdown с verbal explain для UI
|
||||
factors_detailed: list[dict[str, Any]] = []
|
||||
for idx, p in enumerate(poi_rows):
|
||||
cat: str = p["category"]
|
||||
w = _POI_WEIGHTS.get(cat, 0.0)
|
||||
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
||||
decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
|
||||
score += w * decay
|
||||
distance_m = float(p["distance_m"])
|
||||
decay = max(0.0, 1.0 - distance_m / 1000.0)
|
||||
contribution = w * decay
|
||||
score += contribution
|
||||
by_category.setdefault(cat, []).append(
|
||||
{
|
||||
"name": p["name"],
|
||||
"distance_m": round(float(p["distance_m"])),
|
||||
"distance_m": round(distance_m),
|
||||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||||
"last_edit": (
|
||||
|
|
@ -768,6 +826,26 @@ def analyze_parcel(
|
|||
),
|
||||
}
|
||||
)
|
||||
# Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен.
|
||||
if abs(contribution) < 0.01:
|
||||
continue
|
||||
factors_detailed.append(
|
||||
{
|
||||
# Include idx чтобы избежать React key collision: два POI одной
|
||||
# категории на одинаково округлённом расстоянии иначе дали бы
|
||||
# дубль (например, two аптеки 450м в плотном районе).
|
||||
"factor": f"{cat}_{round(distance_m)}m_{idx}",
|
||||
"category": cat,
|
||||
"category_ru": _POI_CATEGORY_RU.get(cat, cat),
|
||||
"group": _POI_GROUP.get(cat, "Прочее"),
|
||||
"value": round(distance_m, 1),
|
||||
"weight": w,
|
||||
"contribution": round(contribution, 2),
|
||||
"verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution),
|
||||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
|
||||
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||||
|
|
@ -832,6 +910,28 @@ def analyze_parcel(
|
|||
else:
|
||||
center_bonus = 0.0
|
||||
|
||||
# X1 (#47): centrality как отдельный synthetic factor в breakdown.
|
||||
# NB: для centrality decay не применяется (bonus IS the value), поэтому
|
||||
# weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
|
||||
if center_bonus > 0:
|
||||
factors_detailed.append(
|
||||
{
|
||||
"factor": f"center_bonus_{round(dist_to_center_km)}km",
|
||||
"category": "centrality",
|
||||
"category_ru": "Центральность",
|
||||
"group": "Локация",
|
||||
"value": round(dist_to_center_km, 2),
|
||||
"weight": 1.0,
|
||||
"contribution": round(center_bonus, 2),
|
||||
"verbal": (
|
||||
f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
|
||||
f"+{center_bonus:.2f} баллов"
|
||||
),
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
}
|
||||
)
|
||||
|
||||
# 7) Noise score — шумовые источники в радиусе 2 км
|
||||
noise_rows = (
|
||||
db.execute(
|
||||
|
|
@ -1196,6 +1296,41 @@ def analyze_parcel(
|
|||
|
||||
score_final = score + center_bonus
|
||||
|
||||
# X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
|
||||
# Базис для процентов — сумма абсолютных значений всех факторов; это даёт
|
||||
# стабильное соотношение независимо от знака и не делится на 0.
|
||||
abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0
|
||||
for f in factors_detailed:
|
||||
f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)
|
||||
|
||||
factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
|
||||
# Convention: оба top-list'а отсортированы "dominant first":
|
||||
# positives → most-positive first (factors_sorted desc → [:3])
|
||||
# negatives → most-negative first (sort negatives asc → [:3])
|
||||
# Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же
|
||||
# результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc
|
||||
# короче и не зависит от тонкостей slicing.
|
||||
score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
|
||||
negatives_only = [f for f in factors_sorted if f["contribution"] < 0]
|
||||
score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3]
|
||||
|
||||
# By-group totals — для stacked-bar в UI. count это int, contribution* — float.
|
||||
group_totals: dict[str, dict[str, float | int]] = {}
|
||||
for f in factors_detailed:
|
||||
g = group_totals.setdefault(
|
||||
f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}
|
||||
)
|
||||
g["contribution"] += f["contribution"]
|
||||
g["count"] += 1
|
||||
group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0
|
||||
for g_val in group_totals.values():
|
||||
g_val["contribution"] = round(g_val["contribution"], 2)
|
||||
g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1)
|
||||
score_by_group = [
|
||||
{"group": k, **v}
|
||||
for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"]))
|
||||
]
|
||||
|
||||
# X2 (#48): composite confidence + caveats
|
||||
confidence_info = _compute_confidence(
|
||||
source=source,
|
||||
|
|
@ -1223,6 +1358,11 @@ def analyze_parcel(
|
|||
">40 = редко, типичный город. центр 15-30."
|
||||
),
|
||||
"score_breakdown": by_category,
|
||||
# X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.
|
||||
"score_breakdown_detailed": factors_sorted,
|
||||
"score_top_3_positives": score_top_3_positives,
|
||||
"score_top_3_negatives": score_top_3_negatives,
|
||||
"score_by_group": score_by_group,
|
||||
"poi_count": len(poi_rows),
|
||||
"location": {
|
||||
"distance_to_center_km": round(dist_to_center_km, 2),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FeatureCollection } from "geojson";
|
|||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||
import { IsochronesPanel } from "./IsochronesPanel";
|
||||
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
|
||||
|
||||
interface Props {
|
||||
data: ParcelAnalysis;
|
||||
|
|
@ -165,6 +166,17 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
|||
)}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* POI breakdown */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
391
frontend/src/components/site-finder/ScoreBreakdownPanel.tsx
Normal file
391
frontend/src/components/site-finder/ScoreBreakdownPanel.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
topPositives: FactorContribution[];
|
||||
topNegatives: FactorContribution[];
|
||||
byGroup: ScoreGroupTotal[];
|
||||
detailed: FactorContribution[];
|
||||
}
|
||||
|
||||
// Цвета stacked bar — соответствуют тематическим эшелонам
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
Социалка: "#0ea5e9",
|
||||
Торговля: "#a855f7",
|
||||
Парки: "#16a34a",
|
||||
Транспорт: "#eab308",
|
||||
"Шум/трамвай": "#dc2626",
|
||||
Локация: "#1d4ed8",
|
||||
Прочее: "#94a3b8",
|
||||
};
|
||||
|
||||
function fmtContribution(v: number): string {
|
||||
const sign = v >= 0 ? "+" : "−";
|
||||
return `${sign}${Math.abs(v).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function ScoreBreakdownPanel({
|
||||
topPositives,
|
||||
topNegatives,
|
||||
byGroup,
|
||||
detailed,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Stacked bar — только positive groups (для визуальной шкалы вклада)
|
||||
const positiveGroups = byGroup.filter((g) => g.contribution > 0);
|
||||
const totalPositive =
|
||||
positiveGroups.reduce((s, g) => s + g.contribution, 0) || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 10,
|
||||
padding: "14px 18px",
|
||||
background: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Почему такой балл
|
||||
</div>
|
||||
|
||||
{/* Stacked bar — % contribution по группам */}
|
||||
{positiveGroups.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
border: "1px solid #e5e7eb",
|
||||
}}
|
||||
role="img"
|
||||
aria-label="Состав положительного вклада по группам"
|
||||
>
|
||||
{positiveGroups.map((g) => {
|
||||
const widthPct = (g.contribution / totalPositive) * 100;
|
||||
return (
|
||||
<div
|
||||
key={g.group}
|
||||
style={{
|
||||
width: `${widthPct}%`,
|
||||
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={`${g.group}: ${fmtContribution(g.contribution)} (${g.contribution_pct}%)`}
|
||||
>
|
||||
{widthPct >= 10 ? `${Math.round(widthPct)}%` : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{/* Positive groups — visible в баре */}
|
||||
{positiveGroups.map((g) => (
|
||||
<div
|
||||
key={g.group}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{g.group}:{" "}
|
||||
<strong style={{ color: "#374151" }}>
|
||||
{fmtContribution(g.contribution)}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Negative groups — отдельной "drag" линией под баром (legend для bar
|
||||
использует только positive, чтобы не было orphan swatches без сегмента) */}
|
||||
{byGroup
|
||||
.filter((g) => g.contribution < 0)
|
||||
.map((g) => (
|
||||
<div
|
||||
key={`neg-${g.group}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
Снижают балл — {g.group}:{" "}
|
||||
<strong style={{ color: "#dc2626" }}>
|
||||
{fmtContribution(g.contribution)}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-3 positive */}
|
||||
{topPositives.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#16a34a",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Топ-3 плюса
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{topPositives.map((f) => (
|
||||
<li
|
||||
key={f.factor}
|
||||
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "#16a34a",
|
||||
fontWeight: 600,
|
||||
marginRight: 6,
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</span>
|
||||
{f.verbal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-3 negative */}
|
||||
{topNegatives.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#dc2626",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Топ-3 минуса
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{topNegatives.map((f) => (
|
||||
<li
|
||||
key={f.factor}
|
||||
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "#dc2626",
|
||||
fontWeight: 600,
|
||||
marginRight: 6,
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
{f.verbal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle full breakdown */}
|
||||
{detailed.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
aria-expanded={expanded}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: "#1d4ed8",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{expanded
|
||||
? "Скрыть все факторы"
|
||||
: `Показать все факторы (${detailed.length})`}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
maxHeight: 280,
|
||||
overflowY: "auto",
|
||||
border: "1px solid #f3f4f6",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<thead
|
||||
style={{
|
||||
background: "#f9fafb",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
textAlign: "left",
|
||||
color: "#6b7280",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Фактор
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
textAlign: "right",
|
||||
color: "#6b7280",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Вклад
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
textAlign: "right",
|
||||
color: "#6b7280",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
%
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailed.map((f) => (
|
||||
<tr
|
||||
key={f.factor}
|
||||
style={{ borderTop: "1px solid #f3f4f6" }}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
color: "#374151",
|
||||
}}
|
||||
title={f.verbal}
|
||||
>
|
||||
{f.verbal}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
textAlign: "right",
|
||||
color: f.contribution >= 0 ? "#16a34a" : "#dc2626",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{fmtContribution(f.contribution)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
textAlign: "right",
|
||||
color: "#6b7280",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{f.contribution_pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -193,6 +193,28 @@ export interface ParcelSuccessRecommendation {
|
|||
note: string;
|
||||
}
|
||||
|
||||
// X1 (#47) — per-factor breakdown с verbal explain
|
||||
export interface FactorContribution {
|
||||
factor: string;
|
||||
category: string;
|
||||
category_ru: string;
|
||||
group: string;
|
||||
value: number;
|
||||
weight: number;
|
||||
contribution: number;
|
||||
contribution_pct: number;
|
||||
verbal: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}
|
||||
|
||||
export interface ScoreGroupTotal {
|
||||
group: string;
|
||||
contribution: number;
|
||||
count: number;
|
||||
contribution_pct: number;
|
||||
}
|
||||
|
||||
export interface ParcelAnalysis {
|
||||
cad_num: string;
|
||||
source: "cad_quarter" | "cad_building";
|
||||
|
|
@ -204,6 +226,10 @@ export interface ParcelAnalysis {
|
|||
score_explanation?: string;
|
||||
market_trend?: MarketTrend | null;
|
||||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
||||
score_breakdown_detailed?: FactorContribution[];
|
||||
score_top_3_positives?: FactorContribution[];
|
||||
score_top_3_negatives?: FactorContribution[];
|
||||
score_by_group?: ScoreGroupTotal[];
|
||||
poi_count: number;
|
||||
competitors: ParcelAnalysisCompetitor[];
|
||||
noise: ParcelAnalysisNoise | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue