diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index 173e1af8..d65b412c 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
"period": "1995-2024 (30 лет)",
"model": "MRI-AGCM3-2-S",
"source": "open-meteo-climate",
- "note": ("Климатические нормали. " "Текущая погода — отдельный API."),
+ "note": ("Климатические нормали. Текущая погода — отдельный API."),
}
except Exception as e:
logger.warning("seasonal weather fetch failed: %s", e)
@@ -255,6 +255,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 лет).
# Добавляй регионы по мере расширения географии продукта.
@@ -461,16 +510,20 @@ def analyze_parcel(
# 4) Scoring: weighted sum с distance decay
score = 0.0
by_category: dict[str, list[dict[str, Any]]] = {}
+ # X1 (#47): per-POI breakdown с verbal explain для UI
+ factors_detailed: list[dict[str, Any]] = []
for p in 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": (
@@ -478,6 +531,23 @@ def analyze_parcel(
),
}
)
+ # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен.
+ if abs(contribution) < 0.01:
+ continue
+ factors_detailed.append(
+ {
+ "factor": f"{cat}_{round(distance_m)}m",
+ "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 по
@@ -542,6 +612,26 @@ def analyze_parcel(
else:
center_bonus = 0.0
+ # X1 (#47): centrality как отдельный synthetic factor в breakdown
+ 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": center_bonus,
+ "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(
@@ -906,6 +996,34 @@ 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)
+ score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
+ score_top_3_negatives = [f for f in factors_sorted if f["contribution"] < 0][-3:][::-1]
+
+ # By-group totals — для stacked-bar в UI
+ group_totals: dict[str, dict[str, float]] = {}
+ 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"]))
+ ]
+
return {
"cad_num": cad_num,
"source": source,
@@ -920,6 +1038,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),
diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx
index d5631f7b..9c6cadeb 100644
--- a/frontend/src/components/site-finder/OverviewTab.tsx
+++ b/frontend/src/components/site-finder/OverviewTab.tsx
@@ -3,6 +3,7 @@
import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder";
import { IsochronesPanel } from "./IsochronesPanel";
+import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
interface Props {
data: ParcelAnalysis;
@@ -154,6 +155,17 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
)}
+ {/* X1 (#47): per-factor score breakdown с verbal explain */}
+ {data.score_breakdown_detailed &&
+ data.score_breakdown_detailed.length > 0 && (
+
| + Фактор + | ++ Вклад + | ++ % + | +
|---|---|---|
| + {f.verbal} + | += 0 ? "#16a34a" : "#dc2626", + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }} + > + {fmtContribution(f.contribution)} + | ++ {f.contribution_pct.toFixed(1)}% + | +