diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index babb03f7..7743f5cb 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1,3 +1,4 @@ +import datetime as _dt import json import logging import math @@ -5,8 +6,6 @@ from typing import Annotated, Any import httpx from fastapi import APIRouter, Depends, HTTPException, Query -from shapely import wkt as _shp_wkt -from shapely.geometry import Polygon from sqlalchemy import text from sqlalchemy.orm import Session @@ -272,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 лет). # Добавляй регионы по мере расширения географии продукта. @@ -291,165 +339,6 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } -# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие -# для ЖК; будут править — храним в одном месте) -_GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум) -_GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation) -_GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium) -_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый -_GEOM_ASPECT_PENALTY = 0.3 -_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный -_GEOM_CONVEX_PENALTY = 0.3 -# Строительный минимум — physical possibility (под penalty) -_GEOM_MIN_WIDTH_PHYSICAL_M = 30 -_GEOM_NARROW_PENALTY = 0.5 -# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт) -_GEOM_MIN_WIDTH_COMFORT_M = 40 -_GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties) -_GEOM_LABEL_GOOD = 0.7 -_GEOM_LABEL_MEDIUM = 0.4 - - -def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: - """P1 (#45) — physical suitability участка по метрикам shape. - - Метрики: - - area_ha — площадь в гектарах (locally-projected metres via cos(lat)) - - perimeter_m — периметр - - aspect_ratio — длина / ширина минимального ограничивающего прямоугольника - - convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный) - - min_inscribed_rect_dim_m — длина короткой стороны MABR - - Suitability score 0..1 — composite (пороги — см. _GEOM_* константы): - - area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear - - −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD - - −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD - - −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M - - UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро" - комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел - обе проблемы сразу. - """ - try: - # Парсим WGS84 polygon (shapely imports теперь module-level) - poly = _shp_wkt.loads(geom_wkt) - if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"): - return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"} - - # Берём наибольший компонент для MultiPolygon - if poly.geom_type == "MultiPolygon": - poly = max(poly.geoms, key=lambda g: g.area) - assert isinstance(poly, Polygon) - - # Equirectangular-projection в метры через centroid-anchor. - # На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK). - centroid = poly.centroid - lat_rad = math.radians(centroid.y) - m_per_deg_lon = 111_320.0 * math.cos(lat_rad) - m_per_deg_lat = 110_540.0 - ext = list(poly.exterior.coords) - ext_m = [ - ( - (x - centroid.x) * m_per_deg_lon, - (y - centroid.y) * m_per_deg_lat, - ) - for x, y in ext - ] - poly_m = Polygon(ext_m) - area_m2 = poly_m.area - area_ha = area_m2 / 10_000.0 - perimeter_m = poly_m.length - - # Convex hull ratio - hull = poly_m.convex_hull - convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0 - - # MABR (minimum area bounding rectangle) → aspect_ratio + short side - try: - mabr = poly_m.minimum_rotated_rectangle - mabr_coords = list(mabr.exterior.coords) - # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны - side_lens: list[float] = [] - for i in range(4): - p1 = mabr_coords[i] - p2 = mabr_coords[i + 1] - side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1])) - short_side = min(side_lens) - long_side = max(side_lens) - aspect_ratio = long_side / short_side if short_side > 0 else 1.0 - except Exception as mabr_err: - logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err) - short_side = math.sqrt(area_m2) - aspect_ratio = 1.0 - - # Suitability score composite - if area_ha >= _GEOM_AREA_SCORE_FULL_HA: - area_subscore = 1.0 - elif area_ha <= _GEOM_MIN_AREA_HA: - area_subscore = 0.0 - else: - # linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0 - area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / ( - _GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA - ) - - suitability = area_subscore - penalties: list[str] = [] - if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD: - suitability -= _GEOM_ASPECT_PENALTY - penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})") - if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD: - suitability -= _GEOM_CONVEX_PENALTY - penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})") - if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M: - suitability -= _GEOM_NARROW_PENALTY - penalties.append(f"узкий (короткая сторона {short_side:.0f}м)") - suitability = max(0.0, min(1.0, suitability)) - - # Label — combine "микро" с penalties чтобы UI видел всё - is_micro = area_ha < _GEOM_LABEL_MICRO_HA - if suitability >= _GEOM_LABEL_GOOD and not is_micro: - label = "подходящий" - elif is_micro: - # combine с penalties: "микро" + первая penalty (для краткости) - if penalties: - label = f"микро, {penalties[0].split(' (')[0]}" - else: - label = "микро" - elif suitability >= _GEOM_LABEL_MEDIUM: - label = "сложная форма" - else: - label = "слабо подходит" - - return { - "data_available": True, - "area_ha": round(area_ha, 3), - "area_m2": round(area_m2), - "perimeter_m": round(perimeter_m), - "aspect_ratio": round(aspect_ratio, 2), - "convex_hull_ratio": round(convex_hull_ratio, 2), - "min_inscribed_rect_dim_m": round(short_side), - "suitability_score": round(suitability, 2), - "label": label, - "penalties": penalties, - "recommendation": ( - f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " - f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " - f"и площадь от {_GEOM_AREA_COMFORT_HA} га." - ), - "note": ( - "Оценка по форме участка (Shapely). Учитывает площадь, " - "вытянутость, изрезанность, минимальную ширину MABR." - ), - } - except Exception as e: - logger.warning("polygon suitability failed: %s", e) - return { - "data_available": False, - "note": f"Не удалось проанализировать геометрию: {e}", - } - - # P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда # содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны # эмпирически для ЕКБ. @@ -691,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] = {} @@ -728,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: @@ -912,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": ( @@ -929,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 по @@ -993,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( @@ -1357,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, @@ -1384,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), @@ -1406,8 +1385,6 @@ def analyze_parcel( "hydrology": hydrology, "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), - # P1 (#45) — physical suitability участка - "geometry_suitability": _polygon_suitability(geom_wkt), # P2 (#46) — соседи-здания + overlap check "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, diff --git a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx deleted file mode 100644 index a61103c0..00000000 --- a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client"; - -import type { - GeometrySuitability, - GeometrySuitabilityBaseLabel, -} from "@/types/site-finder"; - -interface Props { - data: GeometrySuitability; -} - -const LABEL_COLOR: Record< - GeometrySuitabilityBaseLabel, - { bg: string; fg: string; border: string } -> = { - подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, - "сложная форма": { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, - "слабо подходит": { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, - микро: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, -}; - -// label может быть combo "микро, узкий" — берём первую часть как ключ для цвета. -function colorForLabel(label: string | undefined) { - if (!label) return LABEL_COLOR["сложная форма"]; - const base = label.split(",")[0].trim() as GeometrySuitabilityBaseLabel; - return LABEL_COLOR[base] ?? LABEL_COLOR["сложная форма"]; -} - -function fmtArea(ha: number | undefined, m2: number | undefined): string { - if (ha !== undefined && ha >= 0.1) { - return `${ha.toFixed(2)} га`; - } - if (m2 !== undefined) { - return `${m2.toLocaleString("ru-RU")} м²`; - } - return "—"; -} - -export function GeometrySuitabilityBlock({ data }: Props) { - if (!data.data_available) { - return ( -
-
- Геометрия участка -
-
{data.note}
-
- ); - } - - const c = colorForLabel(data.label); - const score = data.suitability_score ?? 0; - const scorePct = Math.round(score * 100); - - return ( -
-
- - Геометрия участка - - - {data.label} · {scorePct}% - -
- - {/* Метрики */} -
-
-
Площадь
-
- {fmtArea(data.area_ha, data.area_m2)} -
-
-
-
Периметр
-
- {data.perimeter_m ?? "—"} м -
-
-
-
- Соотношение сторон -
-
- {data.aspect_ratio?.toFixed(2) ?? "—"} -
-
-
-
- Выпуклость -
-
- {data.convex_hull_ratio !== undefined - ? `${(data.convex_hull_ratio * 100).toFixed(0)}%` - : "—"} -
-
-
-
- Мин. ширина -
-
- {data.min_inscribed_rect_dim_m ?? "—"} м -
-
-
- - {/* Penalties */} - {data.penalties && data.penalties.length > 0 && ( -
- Проблемы формы: {data.penalties.join(", ")} -
- )} - - {/* Recommendation */} - {data.recommendation && ( -
- {data.recommendation} -
- )} -
- ); -} diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 8176c7f4..7cd76b64 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -2,7 +2,6 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; -import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; import { NeighborsBlock } from "./NeighborsBlock"; @@ -14,21 +13,15 @@ export function LandTab({ data }: Props) { const hasAny = data.geotech_risk !== undefined || data.geology !== undefined || - data.geometry_suitability !== undefined || data.neighbors_summary !== undefined; return (
- {/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */} + {/* P2 (#46) — Соседи + overlap warning */} {data.neighbors_summary && ( )} - {/* P1 (#45) — Geometry suitability */} - {data.geometry_suitability !== undefined && ( - - )} - {/* Zoning note */}
+ {/* X1 (#47): per-factor score breakdown с verbal explain */} + {data.score_breakdown_detailed && + data.score_breakdown_detailed.length > 0 && ( + + )} + {/* POI breakdown */}
= { + Социалка: "#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 ( +
+
+ Почему такой балл +
+ + {/* Stacked bar — % contribution по группам */} + {positiveGroups.length > 0 && ( +
+
+ {positiveGroups.map((g) => { + const widthPct = (g.contribution / totalPositive) * 100; + return ( +
+ {widthPct >= 10 ? `${Math.round(widthPct)}%` : ""} +
+ ); + })} +
+
+ {/* Positive groups — visible в баре */} + {positiveGroups.map((g) => ( +
+ + + {g.group}:{" "} + + {fmtContribution(g.contribution)} + + +
+ ))} +
+ {/* Negative groups — отдельной "drag" линией под баром (legend для bar + использует только positive, чтобы не было orphan swatches без сегмента) */} + {byGroup + .filter((g) => g.contribution < 0) + .map((g) => ( +
+ + + Снижают балл — {g.group}:{" "} + + {fmtContribution(g.contribution)} + + +
+ ))} +
+ )} + + {/* Top-3 positive */} + {topPositives.length > 0 && ( +
+
+ Топ-3 плюса +
+
    + {topPositives.map((f) => ( +
  • + + ▲ + + {f.verbal} +
  • + ))} +
+
+ )} + + {/* Top-3 negative */} + {topNegatives.length > 0 && ( +
+
+ Топ-3 минуса +
+
    + {topNegatives.map((f) => ( +
  • + + ▼ + + {f.verbal} +
  • + ))} +
+
+ )} + + {/* Toggle full breakdown */} + {detailed.length > 0 && ( +
+ + {expanded && ( +
+ + + + + + + + + + {detailed.map((f) => ( + + + + + + ))} + +
+ Фактор + + Вклад + + % +
+ {f.verbal} + = 0 ? "#16a34a" : "#dc2626", + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }} + > + {fmtContribution(f.contribution)} + + {f.contribution_pct.toFixed(1)}% +
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 5161efdd..85e684ed 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -178,30 +178,6 @@ export interface ParcelLocation { note: string; } -// P1 (#45) — physical suitability участка. -// label — base value один из BaseLabel'ов либо combo "микро, " -export type GeometrySuitabilityBaseLabel = - | "микро" - | "подходящий" - | "сложная форма" - | "слабо подходит"; - -export interface GeometrySuitability { - data_available: boolean; - area_ha?: number; - area_m2?: number; - perimeter_m?: number; - aspect_ratio?: number; - convex_hull_ratio?: number; - min_inscribed_rect_dim_m?: number; - suitability_score?: number; - // string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel - label?: string; - penalties?: string[]; - recommendation?: string; - note: string; -} - export interface SuccessRankingBucket { bucket: string; success_score: number; @@ -217,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"; @@ -228,6 +226,10 @@ export interface ParcelAnalysis { score_explanation?: string; market_trend?: MarketTrend | null; score_breakdown: Record; + 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; @@ -242,8 +244,6 @@ export interface ParcelAnalysis { score_without_center?: number; location?: ParcelLocation; success_recommendation?: ParcelSuccessRecommendation | null; - // P1 (#45) — physical suitability участка - geometry_suitability?: GeometrySuitability; // P2 (#46) — cad_buildings соседи + overlap check neighbors_summary?: NeighborsSummary; // X2 (#48) — confidence indicator